From 191ca2a289e203456892d826311ad20accd1a517 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 02:34:23 -0400 Subject: [PATCH 1/4] ci: publish canary container images to ghcr Nothing exercised the Containerfile, so it could rot unnoticed, and there was no ready artifact for anyone wanting to run walgit without a Rust and Node toolchain on hand. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ README.md | 3 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8938635..2d9f6a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,3 +81,58 @@ jobs: - name: Build the SPA and the SDK run: just web-build - run: just e2e + + # Canary image publish. Lives in this workflow, not a separate one, so `needs` gates it on the + # same green run that just tested the commit: a red main never produces a canary tag, and there + # is no workflow_run indirection re-deriving the SHA from an event payload. + # + # ghcr.io//walgit:canary moving tag, always the newest green main + # ghcr.io//walgit:sha-<7> immutable, what a rollback or a bug report pins to + # + # Idempotent: re-running a run on the same commit rewrites both tags to the same digest. + publish-canary: + name: publish canary image + needs: [build-test, e2e] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + # The only write scope this workflow needs; GITHUB_TOKEN carries nothing else. + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # type=sha with format=short is exactly sha-; both tags and the OCI + # source/revision labels come from one place rather than hand-built strings. + - name: Derive tags and labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=canary + type=sha,format=short,prefix=sha- + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Containerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # The build stage never copies .git, so this ARG is the binary's only build identity: + # without it walgit-server/build.rs falls back to "dev" and every image reports the same + # version on /healthz. Full 40 chars: build.rs takes the value verbatim. + build-args: | + WALGIT_BUILD_SHA=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index 049bc60..7b4e94d 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,8 @@ open https://walgit.localhost:8080/ * `walgit.standalone.toml` — the one-machine shape (self-signed TLS, rustfs, every role). Start here. * `walgit.example.toml` — every key with its default and a comment. -* `Containerfile`, `flake.nix` — an OCI image and a Nix package/devshell. +* `Containerfile`, `flake.nix` — an OCI image and a Nix package/devshell. Every green push to `main` publishes + that image to `ghcr.io//walgit` as `canary` (moving) and `sha-<7>` (immutable: what a rollback pins to). * `deploy/nginx.conf.example` — an optional nginx in front: public TLS, one `auth_request` per credential, and **byte offload**: walgit answers bundle/LFS downloads with `X-Accel-Redirect` and nginx streams + caches the object from the bucket itself (S3 presigned or GCS with walgit's bearer). The file documents the contract. From eb1f3320287b0964df0df3c5299839c9262fa033 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 02:38:20 -0400 Subject: [PATCH 2/4] ci: let main runs finish so every merged commit keeps a sha tag A cancelled run takes the image publish with it, which would leave some merged commits with no immutable tag to roll back to. Superseded pull request pushes are still worth cancelling. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d9f6a2..a4e6841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,10 @@ on: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + # Superseded PR pushes are worth cancelling; main is not. A cancelled main run takes + # publish-canary down with it (run-level cancellation ignores job-level concurrency), + # and that commit's immutable sha- tag would then never exist. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: CARGO_TERM_COLOR: always @@ -89,7 +92,9 @@ jobs: # ghcr.io//walgit:canary moving tag, always the newest green main # ghcr.io//walgit:sha-<7> immutable, what a rollback or a bug report pins to # - # Idempotent: re-running a run on the same commit rewrites both tags to the same digest. + # Re-running a run on the same commit is safe: it rebuilds and moves both tags to the new + # image. The tag set is a pure function of the commit, but the digest is not (the build is + # not bit-reproducible), so a re-run republishes rather than no-ops. publish-canary: name: publish canary image needs: [build-test, e2e] From 2216f0e987cccc65b32401ff26a4f7478ef71094 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 11:39:29 -0400 Subject: [PATCH 3/4] ci(canary): harden the publish job and make the rollback tag collision-proof The publish job is the only one holding a token that can write packages, so a compromised upstream action reaches the registry through it; a seven-character sha prefix is a namespace that collides, and the collision would move an older commit's supposedly immutable tag onto a newer image. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 30 ++++++++++++++++++++---------- README.md | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e6841..8fe016a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ concurrency: # and that commit's immutable sha- tag would then never exist. cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +# Least privilege by default: only publish-canary needs a write scope, and it asks for it itself. +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -89,8 +93,8 @@ jobs: # same green run that just tested the commit: a red main never produces a canary tag, and there # is no workflow_run indirection re-deriving the SHA from an event payload. # - # ghcr.io//walgit:canary moving tag, always the newest green main - # ghcr.io//walgit:sha-<7> immutable, what a rollback or a bug report pins to + # ghcr.io//walgit:canary moving tag, always the newest green main + # ghcr.io//walgit:sha-<40> immutable, what a rollback or a bug report pins to # # Re-running a run on the same commit is safe: it rebuilds and moves both tags to the new # image. The tag set is a pure function of the commit, but the digest is not (the build is @@ -105,28 +109,34 @@ jobs: # The only write scope this workflow needs; GITHUB_TOKEN carries nothing else. packages: write steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 + # This job holds a token that can write packages, so its actions are pinned to commit SHAs: + # a moving tag would let an upstream compromise reach the registry. The other jobs read only. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Nothing here runs git again; leaving the token in .git/config only widens the blast radius. + persist-credentials: false + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # type=sha with format=short is exactly sha-; both tags and the OCI - # source/revision labels come from one place rather than hand-built strings. + # format=long, not short: a seven-character prefix is a namespace that collides, and the + # collision would silently move an older commit's immutable tag onto a newer image. Both tags + # and the OCI source/revision labels come from one place rather than hand-built strings. - name: Derive tags and labels id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ghcr.io/${{ github.repository }} tags: | type=raw,value=canary - type=sha,format=short,prefix=sha- + type=sha,format=long,prefix=sha- - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: Containerfile diff --git a/README.md b/README.md index 7b4e94d..c20e337 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ open https://walgit.localhost:8080/ * `walgit.standalone.toml` — the one-machine shape (self-signed TLS, rustfs, every role). Start here. * `walgit.example.toml` — every key with its default and a comment. * `Containerfile`, `flake.nix` — an OCI image and a Nix package/devshell. Every green push to `main` publishes - that image to `ghcr.io//walgit` as `canary` (moving) and `sha-<7>` (immutable: what a rollback pins to). + that image to `ghcr.io//walgit` as `canary` (moving) and `sha-<40>` (immutable: what a rollback pins to). * `deploy/nginx.conf.example` — an optional nginx in front: public TLS, one `auth_request` per credential, and **byte offload**: walgit answers bundle/LFS downloads with `X-Accel-Redirect` and nginx streams + caches the object from the bucket itself (S3 presigned or GCS with walgit's bearer). The file documents the contract. From 01f47ad0b3d96753768dc6ec5bf95cb0bb758989 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 1 Sep 2026 12:51:17 -0400 Subject: [PATCH 4/4] ci(canary): write the rollback tag once so it stays worth pinning The build is not bit-reproducible, so a re-run of an already-published commit would repoint sha-<40> at a digest nobody chose, and a rollback that pinned it would land somewhere else than it did the first time. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fe016a..6ec5ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,9 +96,9 @@ jobs: # ghcr.io//walgit:canary moving tag, always the newest green main # ghcr.io//walgit:sha-<40> immutable, what a rollback or a bug report pins to # - # Re-running a run on the same commit is safe: it rebuilds and moves both tags to the new - # image. The tag set is a pure function of the commit, but the digest is not (the build is - # not bit-reproducible), so a re-run republishes rather than no-ops. + # Re-running a run on the same commit is safe, and it does NOT touch an existing sha- tag: the + # build is not bit-reproducible, so republishing that tag would repoint what a rollback pinned to + # at a digest nobody chose. A re-run moves `canary` only; the sha- tag is written once, ever. publish-canary: name: publish canary image needs: [build-test, e2e] @@ -123,6 +123,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # GHCR rejects an uppercase path, and github.repository carries the owner's real casing. + - name: Resolve the image name + id: image + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + # "Immutable" has to mean it: if this commit already has a sha- tag, leave it at the digest + # it already points to and let this run move `canary` alone. + - name: Decide whether the rollback tag is still unwritten + id: rollback + run: | + if docker buildx imagetools inspect "${{ steps.image.outputs.name }}:sha-${GITHUB_SHA}" >/dev/null 2>&1; then + echo "unwritten=false" >> "$GITHUB_OUTPUT" + else + echo "unwritten=true" >> "$GITHUB_OUTPUT" + fi + # format=long, not short: a seven-character prefix is a namespace that collides, and the # collision would silently move an older commit's immutable tag onto a newer image. Both tags # and the OCI source/revision labels come from one place rather than hand-built strings. @@ -130,10 +146,10 @@ jobs: id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: - images: ghcr.io/${{ github.repository }} + images: ${{ steps.image.outputs.name }} tags: | type=raw,value=canary - type=sha,format=long,prefix=sha- + type=sha,format=long,prefix=sha-,enable=${{ steps.rollback.outputs.unwritten }} - name: Build and push uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2