Skip to content

Commit 95856bc

Browse files
chrisleekr-bot[bot]chrisleekr-bot[bot]claude
authored
feat(workflows): publish SLSA provenance + SBOM attestations on every release tag (closes #58) (#94)
Co-authored-by: chrisleekr-bot[bot] <2890262+chrisleekr-bot[bot]@users.noreply.github.com> Co-authored-by: chrisleekr-bot[bot] <chrisleekr-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 88c13e9 commit 95856bc

7 files changed

Lines changed: 372 additions & 133 deletions

File tree

.github/workflows/docker-build.yml

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ on:
5454
permissions:
5555
contents: read
5656
security-events: write
57+
# Read-only attestations grant lets the `scan` job verify SLSA / SBOM
58+
# attestations via `gh attestation verify` before scanning. The `merge`
59+
# job overrides locally to add the `id-token: write` + `attestations: write`
60+
# scopes required to publish Sigstore-signed attestations.
61+
attestations: read
5762

5863
env:
5964
IMAGE_NAME: chrisleekr/github-app-playground
@@ -152,6 +157,16 @@ jobs:
152157
}},push-by-digest=true,name-canonical=true,push=true
153158
cache-from: type=gha,scope=${{ matrix.arch }}-${{ matrix.variant }}
154159
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}-${{ matrix.variant }}
160+
# SLSA v1 provenance (full BuildKit invocation) + SPDX SBOM.
161+
# build-push-action defaults `provenance` to false when
162+
# `push-by-digest=true` (the leaf-attestation problem), so set both
163+
# explicitly. BuildKit emits an attestation-manifest descriptor
164+
# alongside each leaf image; the merge step's index digest
165+
# references both, so `imagetools create` carries the descriptors
166+
# into the published manifest list automatically.
167+
# https://docs.docker.com/build/ci/github-actions/multi-platform/
168+
provenance: mode=max
169+
sbom: true
155170
build-args: |
156171
PACKAGE_VERSION=${{ steps.meta.outputs.package_version }}
157172
GIT_HASH=${{ steps.meta.outputs.git_hash }}
@@ -176,7 +191,15 @@ jobs:
176191
name: Merge manifest (${{ matrix.variant }})
177192
needs: build
178193
runs-on: ubuntu-24.04
179-
timeout-minutes: 10
194+
timeout-minutes: 15
195+
# Least-privilege override: this job is the only one that publishes
196+
# Sigstore-signed attestations, so it gets `id-token: write` (Sigstore
197+
# OIDC) + `attestations: write` (GitHub Attestations API). Build / scan
198+
# keep the top-level read-only set.
199+
permissions:
200+
contents: read
201+
id-token: write
202+
attestations: write
180203
strategy:
181204
fail-fast: false
182205
matrix:
@@ -223,16 +246,56 @@ jobs:
223246
env:
224247
IMAGE: ${{ env.IMAGE_NAME }}
225248
run: |
249+
# The leaf digests below are *index* digests emitted by BuildKit
250+
# when `provenance: mode=max` + `sbom: true` are set on the build
251+
# step — each index references both the per-arch image manifest
252+
# AND its `vnd.docker.reference.type=attestation-manifest`
253+
# sibling. `imagetools create` walks each index and copies all
254+
# descriptors into the merged list, so SBOM + provenance survive
255+
# the merge without any extra CLI plumbing here.
226256
# shellcheck disable=SC2046
227257
docker buildx imagetools create \
228258
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
229259
$(printf "${IMAGE}@sha256:%s " *)
230260
231-
- name: Inspect image
261+
- name: Inspect merged image and capture digest
262+
id: inspect
232263
env:
233264
IMAGE: ${{ env.IMAGE_NAME }}
234265
TAG: ${{ steps.tag.outputs.variant_tag }}
235-
run: docker buildx imagetools inspect "${IMAGE}:${TAG}"
266+
run: |
267+
docker buildx imagetools inspect "${IMAGE}:${TAG}"
268+
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" \
269+
--format '{{ .Manifest.Digest }}')
270+
if ! [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
271+
echo "ERROR: failed to parse merged manifest digest: $DIGEST" >&2
272+
exit 1
273+
fi
274+
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
275+
276+
- name: Generate CycloneDX SBOM for merged image
277+
uses: anchore/sbom-action@v0
278+
with:
279+
image: ${{ env.IMAGE_NAME }}@${{ steps.inspect.outputs.digest }}
280+
format: cyclonedx-json
281+
output-file: "sbom-${{ matrix.variant }}.cyclonedx.json"
282+
upload-artifact: false
283+
upload-release-assets: false
284+
285+
- name: Attest build provenance
286+
uses: actions/attest-build-provenance@v4
287+
with:
288+
subject-name: ${{ env.IMAGE_NAME }}
289+
subject-digest: ${{ steps.inspect.outputs.digest }}
290+
push-to-registry: true
291+
292+
- name: Attest SBOM
293+
uses: actions/attest-sbom@v4
294+
with:
295+
subject-name: ${{ env.IMAGE_NAME }}
296+
subject-digest: ${{ steps.inspect.outputs.digest }}
297+
sbom-path: "sbom-${{ matrix.variant }}.cyclonedx.json"
298+
push-to-registry: true
236299

237300
scan:
238301
name: Trivy scan (${{ matrix.variant }} / ${{ matrix.arch }})
@@ -263,6 +326,22 @@ jobs:
263326
PACKAGE_VERSION="${TAG_NAME#v}"
264327
echo "tag=${PACKAGE_VERSION}-${VARIANT}" >> "$GITHUB_OUTPUT"
265328
329+
# Regression gate: fail the workflow if the published image is missing
330+
# its Sigstore-signed SLSA provenance + SBOM attestations. Catches
331+
# silent drops from future refactors of the build / merge jobs.
332+
- name: Verify image attestations
333+
env:
334+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
335+
IMAGE_REF: oci://${{ env.IMAGE_NAME }}:${{ steps.imgtag.outputs.tag }}
336+
REPO: ${{ github.repository }}
337+
run: |
338+
# Two separate calls: each fails if the requested predicate type is
339+
# absent, so this catches a regression that drops EITHER attestation.
340+
gh attestation verify "$IMAGE_REF" --repo "$REPO" \
341+
--predicate-type https://slsa.dev/provenance/v1
342+
gh attestation verify "$IMAGE_REF" --repo "$REPO" \
343+
--predicate-type https://cyclonedx.org/bom
344+
266345
- name: Scan image with Trivy
267346
uses: aquasecurity/trivy-action@v0.35.0
268347
env:

CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ The scheduled research workflow in `.github/workflows/research.yml` also uses `C
9292

9393
Five workflow files form the pipeline; each owns one responsibility.
9494

95-
| Workflow | Trigger | Owns |
96-
| ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
97-
| `.github/workflows/ci.yml` | `pull_request` + `push: main` + `workflow_call` | Quality gates only: typecheck, lint, format, audit:ci, test, build |
98-
| `.github/workflows/secrets-scan.yml` | `push: branches-ignore: [gh-pages]` + `workflow_dispatch` | Standalone gitleaks secret scan — decoupled from ci.yml so every push (incl. chore/docs/ci/test branches) is gated |
99-
| `.github/workflows/dev-release.yml` | `push: branches-ignore: [main, v*]` + `workflow_dispatch` | Calls `ci.yml` → semantic-release dev (pre-release tag) → calls `docker-build.yml` |
100-
| `.github/workflows/release.yml` | `workflow_dispatch` only (manual) | Calls `ci.yml` → semantic-release prod → calls `docker-build.yml` |
101-
| `.github/workflows/docker-build.yml` | `workflow_call` + `workflow_dispatch` | Reusable image builder: matrix split-and-merge (amd64 on `ubuntu-24.04` + arm64 on `ubuntu-24.04-arm`), Trivy scan |
95+
| Workflow | Trigger | Owns |
96+
| ------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97+
| `.github/workflows/ci.yml` | `pull_request` + `push: main` + `workflow_call` | Quality gates only: typecheck, lint, format, audit:ci, test, build |
98+
| `.github/workflows/secrets-scan.yml` | `push: branches-ignore: [gh-pages]` + `workflow_dispatch` | Standalone gitleaks secret scan — decoupled from ci.yml so every push (incl. chore/docs/ci/test branches) is gated |
99+
| `.github/workflows/dev-release.yml` | `push: branches-ignore: [main, v*]` + `workflow_dispatch` | Calls `ci.yml` → semantic-release dev (pre-release tag) → calls `docker-build.yml` |
100+
| `.github/workflows/release.yml` | `workflow_dispatch` only (manual) | Calls `ci.yml` → semantic-release prod → calls `docker-build.yml` |
101+
| `.github/workflows/docker-build.yml` | `workflow_call` + `workflow_dispatch` | Reusable image builder: matrix split-and-merge (amd64 on `ubuntu-24.04` + arm64 on `ubuntu-24.04-arm`), SLSA v1 provenance + SBOM attestations (BuildKit + Sigstore), `gh attestation verify` regression gate, Trivy scan |
102102

103103
- **Bun version is single-sourced** via `.tool-versions` (`bun 1.3.12`). All workflows use `oven-sh/setup-bun@v2` with `bun-version-file: .tool-versions`.
104104
- **`audit:ci` (`scripts/audit-ci.ts`)** wraps `bun audit --json` to gate on severity: blocks on high+critical, warns on moderate+low, with an inline `IGNORED` GHSA allowlist (each entry must carry an `expires` date). Required because `bun audit` exits 1 on **any** finding regardless of `--audit-level`.
@@ -151,6 +151,7 @@ The `docs.yml` `pull_request:` trigger has no `paths:` filter, so these gates ru
151151

152152
## Recent Changes
153153

154+
- 20260502-supply-chain-attestations: `.github/workflows/docker-build.yml` now publishes SLSA v1 provenance + SBOM attestations on every release tag. The build step sets `provenance: mode=max` + `sbom: true` (overriding `docker/build-push-action`'s default of off-when-`push-by-digest=true`), so BuildKit emits per-arch attestation manifests that survive `imagetools create` via the index-digest references. The merge job additionally runs `anchore/sbom-action` + `actions/attest-build-provenance` + `actions/attest-sbom` to publish Sigstore-signed CycloneDX SBOM and SLSA provenance bound to the merged manifest digest, surfaced via the GitHub Attestations API and Docker Hub's "Build attestations" badge. The merge job carries scoped `id-token: write` + `attestations: write`; build/scan stay read-only via the new top-level `attestations: read`. The scan job calls `gh attestation verify` for both predicate types (`https://slsa.dev/provenance/v1` + `https://cyclonedx.org/bom`) before Trivy — a hard regression gate that fails the workflow if either attestation is silently dropped by a future refactor. Consumer-side `gh attestation verify` and `docker buildx imagetools inspect` recipes documented in `docs/operate/deployment.md` (Verifying image attestations) and the registry / API storage matrix is in `docs/operate/observability.md` (Supply-chain attestations). Closes #58.
154155
- 20260416-pipeline-redesign: CI/CD pipeline restructured for single-responsibility separation. Replaced `push.yml` + `semantic-release.yml` (which entangled lint-and-test, dev release, and docker build) with four single-purpose workflows: `ci.yml` (quality gates only), `dev-release.yml` (feature-branch orchestrator: ci → semrel-dev → docker), `release.yml` (manual prod orchestrator: ci → semrel-prod → docker), and a rewritten reusable `docker-build.yml` (matrix split-and-merge native amd64+arm64, no QEMU). Bun version single-sourced via `.tool-versions` (resolved drift between 1.3.8 in push.yml/semrel.yml and 1.3.12 in docker-build.yml/Dockerfile/package.json). `audit:ci` script (`scripts/audit-ci.ts`) wraps `bun audit --json` to restore severity-based gating (block high+critical, warn moderate+low, time-boxed GHSA allowlist) — `bun audit` itself exits 1 on any finding regardless of `--audit-level`. Single env-switched `release.config.mjs` (`SEMREL_CHANNEL=dev|prod`) replaces the file-swap hack. Prod release is now manual (`gh workflow run release.yml`); push-to-main only runs `ci.yml`. Job-spawner pod entrypoint moved from `src/k8s/job-entrypoint.ts` (TS source absent from production image) to `dist/k8s/job-entrypoint.js` (built by `scripts/build.ts`) — fixes a latent isolated-job-target bug. Defense-in-depth: every dynamic workflow input passes through `env:` before reaching any `run:` block.
155156
- 20260410-164348-scheduled-research-workflow: Config-only (no `src/` changes) — adds `.github/workflows/research.yml` invoking `anthropics/claude-code-action@v1` once daily (`cron: "0 5 * * *"` = 3pm AEST / 4pm AEDT) and on `workflow_dispatch` with an optional `focus_area` input. Hard 1-hour wall-clock budget (`timeout-minutes: 60`), at most one labelled GitHub issue per run, agent restricted to read + `WebSearch`/`WebFetch` + `gh issue/label create`, two repo secrets (`CLAUDE_CODE_OAUTH_TOKEN`, `PERSONAL_ACCESS_TOKEN`), `permissions: contents:read + issues:write + id-token:write`, `concurrency: research-workflow / cancel-in-progress: false`. 10 fixed focus areas mapped to `src/` subsystems. Two-label scheme (`research` + `area: <name>`). Inherits documented workarounds from `chrisleekr/personal-claw` `research.yml` (`allowed_bots: '*'`, `--disallowedTools ""`, PAT instead of OIDC). **Defense-in-depth against workflow injection**: every GitHub-context value (including `github.event.inputs.focus_area`) is passed via `env:` blocks rather than interpolated into `run:` scripts; the user-supplied `focus_area` is additionally validated against `^[a-z][a-z0-9-]{0,31}$` BEFORE being used (rejected values fall back to a random pick and log only their length, never the value itself) — satisfies Constitution Principle IV. **Failure surfacing**: relies on GitHub Actions' built-in workflow-failure email; no custom alerting. **Cost observability**: per Constitution Principle VI bullet 2, `claude-code-action`'s own per-turn cost output is captured by GitHub Actions stdout and retrieved post-mortem via `gh run view <run-id> --log | grep -iE 'cost|tokens|duration|usage'` (see `specs/.../research.md` §19 and `quickstart.md` Day-2 ops). **Test coverage gap** (Constitution Principle V) justified in `plan.md` Complexity Tracking; mitigated via `actionlint` static check + mandatory manual smoke test before merge (see `quickstart.md`).
156157
- 20260409-081113-project-housekeeping: Housekeeping — test coverage raised to 90% per-file threshold (lines + functions; Bun's `coverageThreshold` is applied per-file, not aggregated), ESLint migrated to unified `typescript-eslint` with `strictTypeChecked` preset, CI security scanning added (`bun audit`, `trivy` container scan with blocking `exit-code: "1"`, `gitleaks` full-history scan with `fetch-depth: 0`), Docker HEALTHCHECK on `/healthz`, gitleaks pre-commit hook, retry.ts input validation (maxAttempts/initialDelayMs/maxDelayMs/backoffFactor all reject NaN/Infinity/below-min with descriptive errors), `package.json` security overrides converted to exact version pins.

0 commit comments

Comments
 (0)