diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8938635..6ec5ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,14 @@ 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' }} + +# 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 @@ -81,3 +88,82 @@ 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-<40> immutable, what a rollback or a bug report pins to + # + # 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] + 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: + # 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + 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. + - name: Derive tags and labels + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=raw,value=canary + type=sha,format=long,prefix=sha-,enable=${{ steps.rollback.outputs.unwritten }} + + - name: Build and push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + 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..c20e337 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-<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.