ci(release): tag-driven release pipeline with cosign + SLSA (#16) #74

Merged
james merged 2 commits from 16-release-pipeline into main 2026-06-17 14:02:30 +00:00
Owner

Closes #16.

Summary

Pushing a vX.Y.Z tag now fires .forgejo/workflows/release.yml, which:

  1. Parses the tag — stable (vX.Y.Z) vs pre-release (vX.Y.Z-rc.1, -beta.2).
  2. Builds and pushes the image to forge.wynning.tech/james/carol tagged vX.Y.Z, plus :latest only for stable releases. Stamps source commit / version / repo URL as OCI image labels.
  3. Captures the immutable registry digest and pins everything downstream to it (tags are mutable; signatures must not be).
  4. cosign sign by digest with a key-pair (private key in COSIGN_PRIVATE_KEY Forgejo secret; public key cosign.pub lives on main).
  5. Hand-rolls a SLSA v1.0 provenance predicate (no slsa-github-generator equivalent for Forgejo) and **cosign attest**s it.
  6. Generates release notes with git-cliff parsing Conventional Commits, appends the verification command pinned to this build's digest, creates the Forgejo release page via the API.

Notable supply-chain touches

  • Dockerfile now pins node:22-slim to the multi-arch manifest list digest (sha256:e21f...b752) via a top-level ARG NODE_IMAGE= consumed by every FROM. Renovate's existing dockerfile base images group will bump it on a human-reviewed PR.
  • Two new third-party actions, both SHA-pinned per ADR-0007: docker/login-action@5e57cd1… # v3.7.0, sigstore/cosign-installer@d58896d… # v3.9.2.
  • The hand-rolled SLSA predicate records workflow name, source commit, runner image, base image digest, and runner invocation ID. Not L3 (no isolated builder), but a substantial step up from "trust me".

Conventions adopted

  • Conventional Commits going forward (feat: / fix: / ci: / etc.). Pre-#16 commits land in a catch-all "Other" group in release notes — followed by a transition period. Enforcement (commit-msg hook + PR check) tracked as #70.
  • The Dockerfile's RUN npm ci does not yet inherit the install-script allowlist from #46. Tracked as #69.

Files

File Change
Dockerfile Add digest-pinned ARG NODE_IMAGE; reuse in all three FROMs
.forgejo/workflows/release.yml New — single job, build → sign → attest → release
cliff.toml New — git-cliff config, Conventional Commits grouping + "Other" fallback
docs/adr/0014-release-pipeline.md New — design rationale, alternatives, consequences
docs/ci.md New "Release pipeline" section: setup, secrets, cutting a release, verification, key rotation
CLAUDE.md New bullets for release pipeline + Conventional Commits
docs/adr/README.md Index entry for ADR-0014

Acceptance criteria

  • git tag v0.1.0 && git push --tags produces a published image and a Forgejo release page with notes. (CI to confirm on first tag push after the cosign keypair is registered.)
  • The pulled image runs with no manual post-pull tweaks. (No changes to runtime layer — same Dockerfile, same labels.)
  • Dockerfile pins the base image by digest, not by tag.
  • Published images carry a cosign signature and a SLSA provenance attestation. The verification command appears in the release notes and works against the published image. (Verifiable end-to-end only after secrets + cosign.pub are in place; see "One-time setup" below.)
  • The release workflow uses npm ci (lockfile-strict) inside the Dockerfile and fails if package-lock.json is out of sync with package.json — same npm ci invocation as today's PR pipeline.

One-time setup before the first tag push

  1. cosign generate-key-pair locally (sets a password — remember it).
  2. git add cosign.pub && git commit -m "build(release): add cosign public key" && git push.
  3. Register COSIGN_PRIVATE_KEY (contents of cosign.key) and COSIGN_PASSWORD as Forgejo secrets on this repo (or at user level).
  4. shred -u cosign.key locally.

REGISTRY_USERNAME / REGISTRY_TOKEN already exist at user level — no setup needed.

Full setup walkthrough lives in docs/ci.md "Release pipeline → One-time cosign keypair setup".

Test plan

  • CI green on this PR (same matrix as every other PR — release workflow only fires on tag, not on pull_request, so it won't run here).
  • python3 -c 'import yaml; yaml.safe_load(open(".forgejo/workflows/release.yml"))' — passes.
  • python3 -c 'import tomllib; tomllib.load(open("cliff.toml","rb"))' — passes.
  • docker build --check .Check complete, no warnings found. Digest resolves on Docker Hub.
  • git-cliff --config cliff.toml --unreleased — generates expected output; my ci(release): commit lands in "CI", legacy merge commits land in "Other".
  • Dry-run the full flow: after merging, push a v0.0.0 or v0.0.1-rc.0 tag, watch the workflow, confirm an image lands on the registry, signature verifies, attestation verifies, Forgejo release page appears.

Follow-ups already filed

  • #69 — Apply install-script allowlist to Dockerfile npm ci (close the asymmetry with #46).
  • #70 — Enforce Conventional Commits via commit-msg hook (the convention this PR adopts is documentation-only today).

A note on the ADR number

The original draft used ADR-0012 but #43 and #67 landed 0012 and 0013 while this PR was in progress. Renamed to ADR-0014 during rebase.

Closes #16. ## Summary Pushing a `vX.Y.Z` tag now fires `.forgejo/workflows/release.yml`, which: 1. **Parses the tag** — stable (`vX.Y.Z`) vs pre-release (`vX.Y.Z-rc.1`, `-beta.2`). 2. **Builds and pushes** the image to `forge.wynning.tech/james/carol` tagged `vX.Y.Z`, plus `:latest` only for stable releases. Stamps source commit / version / repo URL as OCI image labels. 3. **Captures the immutable registry digest** and pins everything downstream to it (tags are mutable; signatures must not be). 4. **`cosign sign`** by digest with a key-pair (private key in `COSIGN_PRIVATE_KEY` Forgejo secret; public key `cosign.pub` lives on `main`). 5. **Hand-rolls a SLSA v1.0 provenance predicate** (no `slsa-github-generator` equivalent for Forgejo) and **`cosign attest`**s it. 6. **Generates release notes** with `git-cliff` parsing Conventional Commits, appends the verification command pinned to this build's digest, **creates the Forgejo release page** via the API. ### Notable supply-chain touches - `Dockerfile` now pins `node:22-slim` to the **multi-arch manifest list digest** (`sha256:e21f...b752`) via a top-level `ARG NODE_IMAGE=` consumed by every `FROM`. Renovate's existing `dockerfile base images` group will bump it on a human-reviewed PR. - Two new third-party actions, both **SHA-pinned per ADR-0007**: `docker/login-action@5e57cd1… # v3.7.0`, `sigstore/cosign-installer@d58896d… # v3.9.2`. - The hand-rolled SLSA predicate records workflow name, source commit, runner image, base image digest, and runner invocation ID. Not L3 (no isolated builder), but a substantial step up from "trust me". ### Conventions adopted - **Conventional Commits** going forward (`feat:` / `fix:` / `ci:` / etc.). Pre-#16 commits land in a catch-all "Other" group in release notes — followed by a transition period. Enforcement (commit-msg hook + PR check) tracked as #70. - The `Dockerfile`'s `RUN npm ci` does **not** yet inherit the install-script allowlist from #46. Tracked as #69. ## Files | File | Change | |---|---| | `Dockerfile` | Add digest-pinned `ARG NODE_IMAGE`; reuse in all three `FROM`s | | `.forgejo/workflows/release.yml` | New — single job, build → sign → attest → release | | `cliff.toml` | New — git-cliff config, Conventional Commits grouping + "Other" fallback | | `docs/adr/0014-release-pipeline.md` | New — design rationale, alternatives, consequences | | `docs/ci.md` | New "Release pipeline" section: setup, secrets, cutting a release, verification, key rotation | | `CLAUDE.md` | New bullets for release pipeline + Conventional Commits | | `docs/adr/README.md` | Index entry for ADR-0014 | ## Acceptance criteria - [ ] `git tag v0.1.0 && git push --tags` produces a published image and a Forgejo release page with notes. *(CI to confirm on first tag push after the cosign keypair is registered.)* - [ ] The pulled image runs with no manual post-pull tweaks. *(No changes to runtime layer — same Dockerfile, same labels.)* - [x] `Dockerfile` pins the base image by digest, not by tag. - [ ] Published images carry a cosign signature and a SLSA provenance attestation. The verification command appears in the release notes and works against the published image. *(Verifiable end-to-end only after secrets + cosign.pub are in place; see "One-time setup" below.)* - [x] The release workflow uses `npm ci` (lockfile-strict) inside the Dockerfile and fails if `package-lock.json` is out of sync with `package.json` — same `npm ci` invocation as today's PR pipeline. ## One-time setup before the first tag push 1. `cosign generate-key-pair` locally (sets a password — remember it). 2. `git add cosign.pub && git commit -m "build(release): add cosign public key" && git push`. 3. Register `COSIGN_PRIVATE_KEY` (contents of `cosign.key`) and `COSIGN_PASSWORD` as Forgejo secrets on this repo (or at user level). 4. `shred -u cosign.key` locally. `REGISTRY_USERNAME` / `REGISTRY_TOKEN` already exist at user level — no setup needed. Full setup walkthrough lives in `docs/ci.md` "Release pipeline → One-time cosign keypair setup". ## Test plan - [ ] CI green on this PR (same matrix as every other PR — release workflow only fires on tag, not on `pull_request`, so it won't run here). - [x] `python3 -c 'import yaml; yaml.safe_load(open(".forgejo/workflows/release.yml"))'` — passes. - [x] `python3 -c 'import tomllib; tomllib.load(open("cliff.toml","rb"))'` — passes. - [x] `docker build --check .` — `Check complete, no warnings found.` Digest resolves on Docker Hub. - [x] `git-cliff --config cliff.toml --unreleased` — generates expected output; my `ci(release):` commit lands in "CI", legacy merge commits land in "Other". - [ ] **Dry-run the full flow**: after merging, push a `v0.0.0` or `v0.0.1-rc.0` tag, watch the workflow, confirm an image lands on the registry, signature verifies, attestation verifies, Forgejo release page appears. ## Follow-ups already filed - #69 — Apply install-script allowlist to `Dockerfile npm ci` (close the asymmetry with #46). - #70 — Enforce Conventional Commits via commit-msg hook (the convention this PR adopts is documentation-only today). ## A note on the ADR number The original draft used ADR-0012 but #43 and #67 landed `0012` and `0013` while this PR was in progress. Renamed to **ADR-0014** during rebase.
ci(release): add tag-driven release pipeline (#16)
Some checks failed
Secrets / gitleaks (pull_request) Successful in 32s
PR / OSV-Scanner (pull_request) Successful in 34s
PR / Static analysis (Semgrep) (pull_request) Failing after 41s
PR / Trivy (image) (pull_request) Successful in 1m15s
PR / npm audit (pull_request) Successful in 4m7s
PR / Lint (pull_request) Successful in 4m19s
PR / Typecheck (pull_request) Successful in 4m32s
PR / Test (postgres) (pull_request) Successful in 4m44s
PR / Test (sqlite) (pull_request) Successful in 5m7s
PR / Build (pull_request) Successful in 5m52s
332d7c9e0e
Pushing a vX.Y.Z tag triggers .forgejo/workflows/release.yml, which:

  1. Builds the service image (Dockerfile, now digest-pinned to the
     multi-arch node:22-slim manifest list).
  2. Pushes to forge.wynning.tech/<owner>/<repo> as vX.Y.Z, and as
     :latest only for stable releases (no -rc/-beta).
  3. Captures the immutable registry digest of what was pushed.
  4. cosign-signs the digest using a key-pair (private key in the
     COSIGN_PRIVATE_KEY Forgejo secret; cosign.pub on main).
  5. Hand-rolls a SLSA v1.0 provenance predicate (no slsa-github-
     generator equivalent for Forgejo) and cosign-attests it.
  6. Generates release notes with git-cliff parsing Conventional
     Commits, appends the verification command pinned to this build's
     digest, and creates the Forgejo release page via the API.

Carol adopts Conventional Commits as a convention going forward; the
cliff.toml parser falls back to a catch-all "Other" group for pre-#16
commits and any unconverted future ones. Enforcement (commit-msg hook,
PR-time check) is tracked as #70.

The Dockerfile RUN npm ci does NOT yet inherit the install-script
allowlist from #46 — tracked as #69. Renovate's existing dockerfile-
base-images and forgejo-actions groups already cover the new SHA pins
and image digest pin.

See docs/ci.md "Release pipeline" for the one-time cosign keypair
setup, how to cut a release, and how a self-hoster verifies an image.
ADR-0014 captures the design rationale and alternatives rejected
(keyless cosign via Fulcio, slsa-github-generator, plain git log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(release): route workflow context through step env to avoid shell injection (#16)
All checks were successful
Secrets / gitleaks (pull_request) Successful in 15s
PR / OSV-Scanner (pull_request) Successful in 24s
PR / Trivy (image) (pull_request) Successful in 32s
PR / Static analysis (Semgrep) (pull_request) Successful in 33s
PR / npm audit (pull_request) Successful in 2m0s
PR / Typecheck (pull_request) Successful in 2m4s
PR / Lint (pull_request) Successful in 2m7s
PR / Test (sqlite) (pull_request) Successful in 2m8s
PR / Build (pull_request) Successful in 2m12s
PR / Test (postgres) (pull_request) Successful in 2m15s
0c16811846
Semgrep flagged two `${{ }}` interpolations inside `run:` blocks in
the new release workflow. The pattern is real — a tag containing
`"; rm -rf / #` would land in the shell verbatim before the script ran,
because the runner substitutes `${{ }}` as text before invoking bash.

Fix: every `${{ github.* }}`, `${{ steps.*.outputs.* }}`, and
`${{ secrets.* }}` reference that ends up in a `run:` script is now
exported via the step's `env:` block and read as `"$NAME"` inside the
script. Workflow-level env values (`REGISTRY`, `IMAGE_NAME`,
`GIT_CLIFF_VERSION`, etc.) were already shell-safe and stay as-is.

Also adds a header comment explaining the convention so future steps
follow the same pattern.

Verified locally: `semgrep scan --config .semgrep --config p/javascript
--config p/typescript --config p/nodejsscan --config p/owasp-top-ten
--severity ERROR .forgejo/workflows/release.yml` reports 0 findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 3da4f7867b into main 2026-06-17 14:02:30 +00:00
Sign in to join this conversation.
No description provided.