diff --git a/README.md b/README.md index 4221c10..80fc563 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ # action/site-publish -Composite Gitea Action that publishes a website to the fritzlab k8s cluster. -Supports `static`, `hugo`, `mkdocs` (content → Garage S3 + ExternalName Service) -and `docker` (Dockerfile → Deployment + headless Service). Handles manifest -rendering, TLS via cert-manager, and Garage bucket aliases. +Composite Gitea Action that publishes a **static-content** website to the +fritzlab k8s cluster. Supports `static`, `hugo`, and `mkdocs`. Content goes +to a Garage S3 bucket; Traefik fronts the bucket via an `ExternalName` +Service with cert-manager TLS. -Renamed from `fritzlab/publish-site` → `action/site-publish` as part of the -2026 action-org consolidation. +> **Containerized web apps (Dockerfile-based) are NOT handled here.** Use the +> standard image-producer chain instead: +> [`action/image-build`](https://code.fritzlab.net/action/image-build) + +> [`action/image-push`](https://code.fritzlab.net/action/image-push) + +> [`action/image-deploy`](https://code.fritzlab.net/action/image-deploy). +> Hand-author the apps-repo manifests once (Deployment, Service, Ingress, +> Certificate, kustomization with `images:` block) and let `image-deploy` +> pin the tag on every push. See `sjc001/websites/rainsounds.vino.network/` +> for the canonical example. site-publish errors out explicitly if +> `site.yaml` has `type: docker`. ## Convention @@ -28,7 +36,7 @@ Or do it manually. `site.yaml`: ```yaml domain: my-site.vino.network -type: static # static | hugo | mkdocs | docker +type: static # static | hugo | mkdocs # content_dir: html # subdirectory containing content (default: repo root) # aliases: # additional hostnames (each gets a globalAlias on the bucket) # - www.my-site.vino.network @@ -68,12 +76,11 @@ my-site.fritzlab.net 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net. | Input | Required | Default | Description | |---|---|---|---| | `token` | yes | | Gitea token for apps repo push | -| `s3-access-key` | static/hugo/mkdocs | | Garage `ci-deploy-key` access key id | -| `s3-secret-key` | static/hugo/mkdocs | | Garage `ci-deploy-key` secret key | +| `s3-access-key` | yes | | Garage `ci-deploy-key` access key id | +| `s3-secret-key` | yes | | Garage `ci-deploy-key` secret key | | `s3-endpoint` | no | `http://garage.storage.svc:3900` | Garage S3 endpoint | -| `garage-admin-token` | static/hugo/mkdocs (only if site has `aliases`) | | Garage admin API token (`admin-token` from `garage-rpc-secret` in `storage` ns) | +| `garage-admin-token` | only if site has `aliases` | | Garage admin API token (`admin-token` from `garage-rpc-secret` in `storage` ns) | | `garage-admin-endpoint` | no | `http://garage.storage.svc:3903` | Garage admin API endpoint | -| `registry-password` | docker | inputs.token | Container registry password | | `username` | no | `ci-bot` | Gitea username | Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`, @@ -105,3 +112,10 @@ There is no "first-deploy vs. update" branching — every deploy is idempotent. No nginx pods, no per-site Docker images. Garage matches `Host:` header to bucket name (or any of its globalAliases), so every site shares a single ExternalName target. + +## History + +- 2026-05-06: removed `type: docker` support. The single docker site + (`rainsounds.vino.network`) migrated to the `image-*` chain. site-publish + is now scoped strictly to static-content sites. +- 2026-05-06: renamed from `fritzlab/publish-site` → `action/site-publish`. diff --git a/action.yaml b/action.yaml index 7e5122d..c93fd9e 100644 --- a/action.yaml +++ b/action.yaml @@ -1,24 +1,21 @@ name: Publish Site -description: Build and deploy a site (static, hugo, mkdocs, or docker) to fritzlab k8s. +description: Build and deploy a static-content site (static, hugo, mkdocs) to Garage S3 with Traefik + cert-manager. Containerized apps should use action/image-build + action/image-push + action/image-deploy. inputs: token: description: Gitea token (ci-bot) for apps repo push and API operations required: true s3-access-key: - description: Garage ci-deploy-key access key id (required for static/hugo/mkdocs) - required: false + description: Garage ci-deploy-key access key id + required: true s3-secret-key: - description: Garage ci-deploy-key secret access key (required for static/hugo/mkdocs) - required: false - registry-password: - description: Container registry password (required for docker type; defaults to token) - required: false + description: Garage ci-deploy-key secret access key + required: true s3-endpoint: description: Garage S3 endpoint URL required: false default: http://garage.storage.svc:3900 garage-admin-token: - description: Garage admin API token (required for static/hugo/mkdocs to reconcile bucket aliases) + description: Garage admin API token (required only when site.yaml has aliases — used to reconcile bucket globalAliases) required: false garage-admin-endpoint: description: Garage admin API endpoint URL @@ -43,7 +40,6 @@ runs: SITE_DIR: ${{ github.workspace }} ACTION_DIR: ${{ github.action_path }} GITHUB_RUN_NUMBER: ${{ github.run_number }} - REGISTRY_PASSWORD: ${{ inputs.registry-password || inputs.token }} CI_BOT_USER: ${{ inputs.username }} - name: Deploy diff --git a/scripts/__pycache__/build.cpython-314.pyc b/scripts/__pycache__/build.cpython-314.pyc new file mode 100644 index 0000000..ed3961d Binary files /dev/null and b/scripts/__pycache__/build.cpython-314.pyc differ diff --git a/scripts/__pycache__/deploy.cpython-314.pyc b/scripts/__pycache__/deploy.cpython-314.pyc new file mode 100644 index 0000000..f757087 Binary files /dev/null and b/scripts/__pycache__/deploy.cpython-314.pyc differ diff --git a/scripts/__pycache__/utils.cpython-314.pyc b/scripts/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..29f458b Binary files /dev/null and b/scripts/__pycache__/utils.cpython-314.pyc differ diff --git a/scripts/build.py b/scripts/build.py index cc11e6b..14d933a 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,12 +1,11 @@ -"""Build phase — content prep (static) or docker image build.""" +"""Build phase — content prep for static-content sites.""" -import os import shutil import subprocess import tempfile from pathlib import Path -from utils import EXCLUDE_FILES, die, env, parse_site_yaml, run +from utils import EXCLUDE_FILES, env, parse_site_yaml, run def build_static(site_dir, cfg): @@ -55,35 +54,6 @@ def build_static(site_dir, cfg): print(f"Build complete — content at {html_dir}") -def docker_login(): - password = os.environ.get("REGISTRY_PASSWORD", "") - user = env("CI_BOT_USER", "ci-bot") - if not password: - die("REGISTRY_PASSWORD is required for docker builds") - subprocess.run( - f"echo '{password}' | docker login code.fritzlab.net -u {user} --password-stdin", - shell=True, check=True, - ) - - -def build_docker(site_dir, cfg): - docker_login() - image = cfg["image"] - run_number = env("GITHUB_RUN_NUMBER", "0") - tags = [f"{image}:latest", f"{image}:{run_number}"] - - cmd_parts = ["docker", "build"] - for tag in tags: - cmd_parts += ["-t", tag] - cmd_parts += ["--network", "host", "--provenance=false"] - for key, val in cfg.get("build_args", {}).items(): - cmd_parts += ["--build-arg", f"{key}={val}"] - cmd_parts.append(str(site_dir)) - - run(" ".join(cmd_parts)) - print(f"Docker build complete — tags: {', '.join(tags)}") - - def cmd_build(): site_dir = Path(env("SITE_DIR")) cfg = parse_site_yaml(site_dir) @@ -92,7 +62,4 @@ def cmd_build(): print("Site disabled — skipping build") return - if cfg["type"] == "docker": - build_docker(site_dir, cfg) - else: - build_static(site_dir, cfg) + build_static(site_dir, cfg) diff --git a/scripts/deploy.py b/scripts/deploy.py index 6c41aac..e2aad28 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -1,9 +1,8 @@ -"""Deploy phase — S3 sync or docker push, manifest rendering, alias reconcile.""" +"""Deploy phase — S3 sync, manifest rendering, alias reconcile.""" import json import os import shutil -import subprocess import tempfile from pathlib import Path from urllib.error import HTTPError, URLError @@ -108,62 +107,7 @@ def ensure_bucket_aliases(site_name, aliases, admin_token): raise -def docker_push(cfg): - image = cfg["image"] - run_number = env("GITHUB_RUN_NUMBER", "0") - for tag in [f"{image}:latest", f"{image}:{run_number}"]: - run(f"docker push {tag}") - - -def docker_tag_cleanup(cfg, token): - """Keep the 3 newest numeric tags, delete the rest via Gitea API.""" - image = cfg["image"] - parts = image.split("/") - if len(parts) < 3: - print(f"Cannot parse image for tag cleanup: {image}") - return - org = parts[1] - pkg_name = parts[2] - - url = f"https://{GITEA_HOST}/api/v1/packages/{org}?type=container" - req = Request(url, headers={"Authorization": f"token {token}"}) - try: - with urlopen(req) as resp: - packages = json.loads(resp.read()) - except Exception as e: - print(f"Warning: failed to list packages for cleanup: {e}") - return - - numeric_versions = [] - for pkg in packages: - if pkg.get("name") == pkg_name: - ver = pkg.get("version", "") - if ver.isdigit(): - numeric_versions.append(int(ver)) - - numeric_versions.sort() - to_delete = numeric_versions[:-3] if len(numeric_versions) > 3 else [] - - for ver in to_delete: - del_url = f"https://{GITEA_HOST}/api/v1/packages/{org}/container/{pkg_name}/{ver}" - print(f" Deleting {pkg_name}:{ver}") - req = Request(del_url, method="DELETE", headers={"Authorization": f"token {token}"}) - try: - urlopen(req) - except Exception as e: - print(f" Warning: failed to delete {pkg_name}:{ver}: {e}") - - if to_delete: - print(f" Cleaned up {len(to_delete)} old tags") - else: - print(" No old tags to clean up") - - -def docker_cleanup(): - run("docker system prune --all --force") - - -def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, extra=None): +def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg): """Always re-render manifests from current site.yaml. Templates own domain + aliases, so changes propagate without manual edits.""" manifests_dir.mkdir(parents=True, exist_ok=True) @@ -173,11 +117,8 @@ def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, ex "domain": cfg["domain"], "aliases": cfg["aliases"], "namespace": NAMESPACE, - "site_type": cfg["type"], } - if extra: - template_vars.update(extra) - render_templates(action_dir, template_vars, app_dir, manifests_dir, cfg["type"]) + render_templates(action_dir, template_vars, app_dir, manifests_dir) def deploy_static(site_name, site_dir, action_dir, token, cfg): @@ -193,30 +134,6 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg): commit_and_push(apps_dir, f"Deploy {site_name}") -def deploy_docker(site_name, site_dir, action_dir, token, cfg): - docker_push(cfg) - - run_number = env("GITHUB_RUN_NUMBER", "0") - image = cfg["image"] - apps_dir = clone_apps(token) - app_dir = apps_dir / "sjc001" / "websites" / site_name - manifests_dir = app_dir / "manifests" - - render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, extra={ - "image": image, - "port": cfg["port"], - "health_path": cfg["health_path"], - "replicas": cfg["replicas"], - }) - # Pin to this build's tag (kustomize edit appends an `images:` override). - run(f"cd {manifests_dir} && kustomize edit set image {image}={image}:{run_number}") - - commit_and_push(apps_dir, f"Deploy {site_name} #{run_number}") - - docker_tag_cleanup(cfg, token) - docker_cleanup() - - def decommission(site_name, token): """Remove manifests from apps repo.""" user = env("CI_BOT_USER", "ci-bot") @@ -249,7 +166,4 @@ def cmd_deploy(): decommission(site_name, token) return - if cfg["type"] == "docker": - deploy_docker(site_name, site_dir, action_dir, token, cfg) - else: - deploy_static(site_name, site_dir, action_dir, token, cfg) + deploy_static(site_name, site_dir, action_dir, token, cfg) diff --git a/scripts/setup.py b/scripts/setup.py index 528ac0f..701ae1d 100644 --- a/scripts/setup.py +++ b/scripts/setup.py @@ -24,13 +24,6 @@ def ensure_aws(): subprocess.run(["aws", "--version"], check=True) -def ensure_docker(): - if shutil.which("docker"): - subprocess.run(["docker", "version", "--format", "{{.Client.Version}}"], check=True) - return - print("WARNING: docker CLI not found — docker builds will fail", file=sys.stderr) - - def ensure_jinja2(): try: import jinja2 @@ -45,5 +38,4 @@ def ensure_jinja2(): if __name__ == "__main__": ensure_jinja2() ensure_aws() - ensure_docker() print("Setup complete") diff --git a/scripts/utils.py b/scripts/utils.py index 75ba94e..fcb7436 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -1,4 +1,4 @@ -"""Shared utilities for the publish-site action.""" +"""Shared utilities for the site-publish action.""" import os import shutil @@ -20,7 +20,26 @@ EXCLUDE_FILES = { "Dockerfile", ".dockerignore", "go.mod", "go.sum", } -VALID_TYPES = {"static", "hugo", "mkdocs", "docker"} +VALID_TYPES = {"static", "hugo", "mkdocs"} + +DOCKER_DEPRECATION_MSG = """\ +type: docker is no longer supported by action/site-publish. + +site-publish handles only static-content sites (static, hugo, mkdocs) +that ship to Garage S3. For containerized web apps, use the standard +image-producer chain: + + - uses: action/image-build@v1 # build + smoke-test + - uses: action/image-push@v1 # push + prune + - uses: action/image-deploy@v1 # apps repo image-pin + +Hand-author your apps-repo manifests once (Deployment, Service, Ingress, +Certificate, kustomization with images: block) under +sjc001/websites//manifests/. image-deploy will pin the tag on +every CI run. See action/image-deploy README and +sjc001/websites/rainsounds.vino.network/manifests/ for the canonical +example.\ +""" def k8s_name(name): @@ -57,6 +76,10 @@ def parse_site_yaml(site_dir): die("domain is required in site.yaml") site_type = cfg.get("type", "static") + + if site_type == "docker": + die(DOCKER_DEPRECATION_MSG) + if site_type not in VALID_TYPES: die(f"Unknown site type: {site_type} (valid: {', '.join(sorted(VALID_TYPES))})") @@ -65,20 +88,10 @@ def parse_site_yaml(site_dir): "type": site_type, "enabled": cfg.get("enabled", True), "aliases": cfg.get("aliases") or [], + "content_dir": cfg.get("content_dir", ""), + "tidy": cfg.get("tidy", True), } - if site_type == "docker": - if not cfg.get("image"): - die("image is required in site.yaml for type: docker") - site["image"] = cfg["image"] - site["port"] = cfg.get("port", 8080) - site["build_args"] = cfg.get("build_args") or {} - site["health_path"] = cfg.get("health_path", "/healthz") - site["replicas"] = cfg.get("replicas", 1) - else: - site["content_dir"] = cfg.get("content_dir", "") - site["tidy"] = cfg.get("tidy", True) - print("Site config:") for k, v in site.items(): print(f" {k}: {v}") @@ -96,30 +109,21 @@ def clone_apps(token): return apps_dir -def render_templates(action_dir, template_vars, app_dir, manifests_dir, site_type): - """Render Jinja2 templates, selecting the right set for the site type.""" +def render_templates(action_dir, template_vars, app_dir, manifests_dir): + """Render Jinja2 templates for a static-content site.""" templates_dir = Path(action_dir) / "templates" jinja_env = Environment( loader=FileSystemLoader(str(templates_dir)), keep_trailing_newline=True, ) - if site_type == "docker": - service_tmpl = "service-docker.yaml.j2" - tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", - "kustomization.yaml.j2", service_tmpl, "deployment.yaml.j2"] - else: - service_tmpl = "service-static.yaml.j2" - tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", - "kustomization.yaml.j2", service_tmpl] + tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", + "kustomization.yaml.j2", "service.yaml.j2"] for tmpl_name in tmpl_names: tmpl = jinja_env.get_template(tmpl_name) rendered = tmpl.render(**template_vars) out_name = tmpl_name.replace(".j2", "") - # service-docker.yaml / service-static.yaml → service.yaml - if out_name.startswith("service-"): - out_name = "service.yaml" dest = app_dir / out_name if tmpl_name == "app.yaml.j2" else manifests_dir / out_name dest.write_text(rendered) print(f" Rendered {tmpl_name} -> {dest}") diff --git a/templates/deployment.yaml.j2 b/templates/deployment.yaml.j2 deleted file mode 100644 index b49f94b..0000000 --- a/templates/deployment.yaml.j2 +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ site_k8s }} - namespace: {{ namespace }} - labels: - app: {{ site_k8s }} -spec: - replicas: {{ replicas }} - strategy: - type: Recreate - selector: - matchLabels: - app: {{ site_k8s }} - template: - metadata: - labels: - app: {{ site_k8s }} - spec: - securityContext: - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - fsGroup: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: {{ site_k8s }} - image: {{ image }}:latest - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: {{ port }} - resources: - requests: - memory: 64Mi - cpu: 50m - limits: - memory: 256Mi - livenessProbe: - httpGet: {path: {{ health_path }}, port: http} - periodSeconds: 30 - timeoutSeconds: 3 - readinessProbe: - httpGet: {path: {{ health_path }}, port: http} - periodSeconds: 10 - timeoutSeconds: 3 - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] diff --git a/templates/kustomization.yaml.j2 b/templates/kustomization.yaml.j2 index eff8360..14ebd47 100644 --- a/templates/kustomization.yaml.j2 +++ b/templates/kustomization.yaml.j2 @@ -1,9 +1,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: -{% if site_type == "docker" %} -- deployment.yaml -{% endif %} - service.yaml - ingress.yaml - certificate.yaml diff --git a/templates/service-docker.yaml.j2 b/templates/service-docker.yaml.j2 deleted file mode 100644 index e8703dc..0000000 --- a/templates/service-docker.yaml.j2 +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ site_k8s }} - namespace: {{ namespace }} -spec: - clusterIP: None - selector: - app: {{ site_k8s }} - ports: - - name: http - port: 80 - targetPort: http diff --git a/templates/service-static.yaml.j2 b/templates/service.yaml.j2 similarity index 100% rename from templates/service-static.yaml.j2 rename to templates/service.yaml.j2