ci: add site pipeline
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
images
|
||||||
|
tools
|
||||||
30
.gitea/workflows/site.yaml
Normal file
30
.gitea/workflows/site.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: site
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check file references and external requests
|
||||||
|
run: python3 tools/check_site.py
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check tooling
|
||||||
|
run: docker version --format '{{.Server.Version}}' && docker compose version
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -t rezayaghobi-site:${GITHUB_SHA::7} .
|
||||||
|
- name: Validate nginx config
|
||||||
|
run: docker run --rm rezayaghobi-site:${GITHUB_SHA::7} nginx -t
|
||||||
|
- name: Check site files are in the image
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint sh rezayaghobi-site:${GITHUB_SHA::7} \
|
||||||
|
-c 'test -f /usr/share/nginx/html/index.html && test -d /usr/share/nginx/html/assets'
|
||||||
5
Dockerfile
Normal file
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM nginxinc/nginx-unprivileged:1.28-alpine
|
||||||
|
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
|
||||||
|
COPY index.html favicon.svg robots.txt /usr/share/nginx/html/
|
||||||
|
COPY assets/ /usr/share/nginx/html/assets/
|
||||||
34
nginx/nginx.conf
Normal file
34
nginx/nginx.conf
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/css application/javascript image/svg+xml application/json;
|
||||||
|
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
|
||||||
|
# Only these paths are served; .git, tools/, README.md, images/ etc. return 404.
|
||||||
|
location = / {
|
||||||
|
try_files /index.html =404;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
}
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
}
|
||||||
|
location ~ ^/(favicon\.svg|robots\.txt)$ {
|
||||||
|
add_header Cache-Control "public, max-age=86400" always;
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
}
|
||||||
|
location ^~ /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=3600" always;
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
nginx/security-headers.conf
Normal file
5
nginx/security-headers.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
59
tools/check_site.py
Normal file
59
tools/check_site.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
INDEX = ROOT / "index.html"
|
||||||
|
SKIP = ("#", "mailto:", "tel:", "data:", "javascript:")
|
||||||
|
FETCH_RELS = {"stylesheet", "icon", "preload", "modulepreload", "manifest", "apple-touch-icon"}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(ref, base, where, fetched):
|
||||||
|
if not ref or ref.startswith(SKIP):
|
||||||
|
return
|
||||||
|
if ref.startswith(("http://", "https://", "//")):
|
||||||
|
if fetched:
|
||||||
|
errors.append(f"{where}: external request {ref}")
|
||||||
|
return
|
||||||
|
path = urlparse(ref).path
|
||||||
|
target = ROOT / path.lstrip("/") if path.startswith("/") else base.parent / path
|
||||||
|
if not target.exists():
|
||||||
|
errors.append(f"{where}: missing file {ref}")
|
||||||
|
|
||||||
|
|
||||||
|
class Page(HTMLParser):
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
a = dict(attrs)
|
||||||
|
refs = []
|
||||||
|
if tag in ("script", "img", "source", "iframe", "video", "audio"):
|
||||||
|
refs.append(a.get("src"))
|
||||||
|
for part in (a.get("srcset") or "").split(","):
|
||||||
|
if part.strip():
|
||||||
|
refs.append(part.split()[0])
|
||||||
|
elif tag == "link" and FETCH_RELS & set((a.get("rel") or "").split()):
|
||||||
|
refs.append(a.get("href"))
|
||||||
|
for ref in refs:
|
||||||
|
check(ref, INDEX, "index.html", True)
|
||||||
|
if tag == "a":
|
||||||
|
check(a.get("href"), INDEX, "index.html", False)
|
||||||
|
|
||||||
|
|
||||||
|
for name in ("index.html", "favicon.svg", "robots.txt"):
|
||||||
|
if not (ROOT / name).is_file():
|
||||||
|
errors.append(f"missing required file: {name}")
|
||||||
|
|
||||||
|
if INDEX.is_file():
|
||||||
|
Page().feed(INDEX.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
for css in ROOT.glob("assets/*.css"):
|
||||||
|
for ref in re.findall(r"url\(\s*['\"]?([^'\")]+)", css.read_text(encoding="utf-8")):
|
||||||
|
check(ref, css, str(css.relative_to(ROOT)), True)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors))
|
||||||
|
sys.exit(1)
|
||||||
|
print("site check passed")
|
||||||
Reference in New Issue
Block a user