diff --git a/.gitea/workflows/gates.yml b/.gitea/workflows/gates.yml index bc6b3b2..64fbc4c 100644 --- a/.gitea/workflows/gates.yml +++ b/.gitea/workflows/gates.yml @@ -14,9 +14,28 @@ name: Gates # token. A moved tag upstream would execute there. ci.yml itself still uses no # third-party action; its only `uses:` is this workflow. # -# Resolved with `git ls-remote refs/tags/vN 'refs/tags/vN^{}'` on -# 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the -# listed SHA is the commit. +# Resolved with `git ls-remote refs/tags/vN 'refs/tags/vN^{}'` — the +# three originals on 2026-08-07, the two artifact actions on 2026-08-12. Every +# tag pinned here is lightweight — no `^{}` peel row — so the listed SHA is the +# commit. download-artifact's `v3-node20` is the exception that proves the +# rule: it is annotated, and its peel row names a different commit than the tag +# object. This file pins `v3.1.0-node20`, which is lightweight. +# +# The artifact actions are pinned to v3, and to the node20 rebuilds of v3, for +# two separate reasons. +# +# v4 and v5 abort in the *client*, not the server. @actions/artifact asks +# isGhes(), Gitea answers the way GitHub Enterprise Server does, and the action +# refuses before it uploads anything. The server side is present: Gitea 1.27.1 +# routes the whole v4 twirp service — CreateArtifact, FinalizeArtifact, +# ListArtifacts, GetSignedArtifactURL and DeleteArtifact all answer 401, where +# an unrouted path answers 404. The forks that patch isGhes() out are +# third-party code, which is what the pinning rule above exists to keep away +# from the job that holds the signing subkey. +# +# The stock v3 tags — upload ff15f030 (v3.2.1), download 9bc31d5c (v3.0.2) — +# declare `using: node16`, and a runner is not required to still ship a node16. +# The `-node20` rebuilds are that same action code on a runtime that exists. on: workflow_call: @@ -130,7 +149,30 @@ jobs: working-directory: web run: npm run assert-bundled + # The package and container jobs consume this bundle instead of building + # their own. The upload is last on purpose: a bundle that failed the + # licence inventory or any check above must not reach the jobs that + # package it into a tarball and an image. + # + # Artifact names are scoped to the run, so the ci.yml and release.yml + # runs that share this runner cannot read each other's. + # + # A later move to v4 has to add `include-hidden-files: true` here. + # `npm run build` writes the freshness stamp to the hidden file + # web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop + # dotfiles by default. It is inactive today — v3 keeps them, and the + # package job's target path skips the stamp check regardless — but a move + # to v4 that also pointed the download back at web/dist would fail with + # "web/dist is stale". + - name: Upload the built web UI + uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 + with: + name: web-dist + path: web/dist + if-no-files-found: error + package: + needs: [frontend] runs-on: ubuntu-24.04 steps: @@ -149,20 +191,22 @@ jobs: - name: Create the fetch temp dir zig assumes run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + # `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to + # exist before the packaging gate runs. It arrives from the frontend job, + # already formatted, linted, typechecked, tested and licence-checked. + # + # The target is deliberately not `web/dist`: build.zig runs the freshness + # stamp check for that exact path and no other (milestone-15 ruling 5), + # and the check shells out to `node`. Here it would buy nothing — the + # stamp hashes the web/ sources, not the bundle, so against a checkout of + # the same commit that built the bundle it can only agree. An explicit + # path is the case build.zig documents for a bundle built elsewhere, and + # taking it keeps node out of this job entirely. + - name: Download the web UI built by the frontend job + uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: web/package-lock.json - - # `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has - # to exist before the packaging gate runs. - - name: Build the web UI - working-directory: web - run: | - npm ci - npm run build + name: web-dist + path: web-dist-ci # Ruling 2: build.zig.zon is the only place besides the tag that carries # the version, and ruling 5 makes verify-dist assert the two agree. The @@ -180,7 +224,7 @@ jobs: zig build dist \ -Dversion-string="$CI_VERSION" \ -Dgit-commit="$GITHUB_SHA" \ - -Dweb-dist=web/dist \ + -Dweb-dist=web-dist-ci \ -Doptimize=ReleaseSafe # verify-dist owns every assert the CI shell used to make: ELF static @@ -196,42 +240,51 @@ jobs: zig build verify-dist \ -Dversion-string="$CI_VERSION" \ -Dgit-commit="$GITHUB_SHA" \ - -Dweb-dist=web/dist \ + -Dweb-dist=web-dist-ci \ -Doptimize=ReleaseSafe + # deploy/docker/Dockerfile copies both of these trees and nothing else + # out of zig-out/dist: the binary comes from dist/bin//, and + # /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/ + # directory, which the container job then hashes against the image. The + # tarballs and SHA256SUMS stay behind — no later job reads them, and + # verify-dist has already checked them here. + # + # The zip round-trip drops the executable bit. That is survivable only + # because the Dockerfile chmods the binary itself and the contents + # assertion compares sha256 of file contents, never modes. The archive + # modes are asserted by verify-dist, above, on the originals. + - name: Upload the staged payload for the container job + uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20 + with: + name: dist-payload + path: | + zig-out/dist/bin + zig-out/dist/stage + if-no-files-found: error + container: + needs: [package] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: Set up Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + # Restored under zig-out/dist because that is where the Dockerfile's + # COPY lines look, with the repository root as the build context. This + # job compiles nothing and bundles nothing: the payload is the one the + # package job already built and verify-dist already checked, which is + # also the point — an image built from a second, independent `dist` run + # would prove nothing about the artifacts the release publishes. + - name: Download the staged payload built by the package job + uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20 with: - version: ${{ env.ZIG_VERSION }} + name: dist-payload + path: zig-out/dist - # zig 0.16.0's package fetcher creates tmp/.zip inside the global - # cache without creating tmp/ first (src/Package/Fetch.zig:1499), and - # setup-zig's restored cache never contains tmp/. Without this, every - # dependency fetch dies with "failed to create temporary zip file: - # FileNotFound" before any network I/O. - - name: Create the fetch temp dir zig assumes - run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp" - - - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - cache-dependency-path: web/package-lock.json - - - name: Build the web UI - working-directory: web - run: | - npm ci - npm run build - - # Same single source of truth as the package job (rulings 2 and 5). + # Same single source of truth as the package job (rulings 2 and 5). This + # job still needs the version for the stage directory name it hashes + # against the image and for the VERSION build arg. - name: Read the version from build.zig.zon run: | set -euo pipefail @@ -239,15 +292,6 @@ jobs: test -n "$version" echo "CI_VERSION=$version" >> "$GITHUB_ENV" - - name: Build the release artifacts - run: | - set -euo pipefail - zig build dist \ - -Dversion-string="$CI_VERSION" \ - -Dgit-commit="$GITHUB_SHA" \ - -Dweb-dist=web/dist \ - -Doptimize=ReleaseSafe - # Image tags, container names and published host ports are all # daemon-global. This workflow is called by both ci.yml and release.yml # and the self-hosted runners share one docker daemon, so the fixed diff --git a/.gitignore b/.gitignore index dfd8d67..aed4d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ zig-pkg/ web/node_modules/ web/dist/ web/dist-sourcemap/ +web-dist-ci/