From 05a15e8c23cdc59a14be15f89ce58902dfb2bf0c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 22 Apr 2026 20:17:30 -0700 Subject: [PATCH 1/2] feat: add tag-triggered publish pipeline with attestations Stands up Phase 4 publishing: a four-part workflow (tag entrypoint + reusable trusted builder + draft finalizer + continuous security) that turns a release-please tag into a GitHub Release carrying just the Lambda zip. Build provenance and SBOM are persisted to GitHub's Attestations API via actions/attest; no Cosign, checksum, or SBOM material is attached to the release. Adds codeql.yml, dependency-review.yml, scorecard.yml, and extends dependabot.yml to cover gomod. Documents verification in README and the pipeline architecture under docs/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 10 ++ .github/workflows/codeql.yml | 47 ++++++++ .github/workflows/dependency-review.yml | 30 +++++ .github/workflows/release.yml | 54 +++++++++ .github/workflows/reusable-release.yml | 87 ++++++++++++++ .github/workflows/scorecard.yml | 39 +++++++ README.md | 39 +++++++ docs/docs/explanation/release-architecture.md | 106 ++++++++++++++++++ scripts/publish-draft-release.sh | 11 ++ scripts/wait-for-draft-release.sh | 43 +++++++ 10 files changed, 466 insertions(+) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/reusable-release.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 docs/docs/explanation/release-architecture.md create mode 100755 scripts/publish-draft-release.sh create mode 100755 scripts/wait-for-draft-release.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d62ca52..0ab1238 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,16 @@ updates: - dependencies - github-actions + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + commit-message: + prefix: "chore(deps)" + labels: + - dependencies + - go + - package-ecosystem: npm directory: /docs schedule: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..717c031 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,47 @@ +name: CodeQL + +on: + push: + branches: + - master + pull_request: + branches: + - master + schedule: + - cron: "27 6 * * 1" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze Go + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@6f7376048dd6be43ea34c6bd4a3ecef7d7abd847 # v4.31.4 + with: + languages: go + build-mode: autobuild + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@6f7376048dd6be43ea34c6bd4a3ecef7d7abd847 # v4.31.4 + with: + category: "/language:go" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..7d2383d --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,30 @@ +name: Dependency Review + +on: + pull_request: + branches: + - master + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Review dependencies + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9e785a4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify-draft-release: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Wait for draft release handoff + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/wait-for-draft-release.sh + + release: + needs: verify-draft-release + uses: ./.github/workflows/reusable-release.yml + permissions: + contents: write + id-token: write + attestations: write + with: + tag: ${{ github.ref_name }} + + publish-draft-release: + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Publish draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./scripts/publish-draft-release.sh diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml new file mode 100644 index 0000000..530c411 --- /dev/null +++ b/.github/workflows/reusable-release.yml @@ -0,0 +1,87 @@ +name: Reusable Release + +on: + workflow_call: + inputs: + tag: + description: "Release tag (e.g. v1.2.3)" + required: true + type: string + +permissions: {} + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + + - name: Build Lambda zip + shell: bash + env: + GOOS: linux + GOARCH: arm64 + CGO_ENABLED: "0" + run: | + set -euo pipefail + rm -rf build dist + mkdir -p build dist + go build \ + -buildvcs=false \ + -tags lambda.norpc \ + -trimpath \ + -ldflags="-s -w" \ + -o build/bootstrap \ + ./cmd/github-token-broker + (cd build && zip -X ../dist/github-token-broker.zip bootstrap) + + - name: Generate checksums + shell: bash + run: | + set -euo pipefail + cd dist + sha256sum github-token-broker.zip > checksums.txt + cat checksums.txt + + - name: Generate SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dist/github-token-broker.zip + format: spdx-json + output-file: dist/github-token-broker.zip.spdx.json + upload-artifact: false + upload-release-assets: false + + - name: Attest build provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-checksums: dist/checksums.txt + + - name: Attest SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: dist/github-token-broker.zip + predicate-type: https://spdx.dev/Document + predicate-path: dist/github-token-broker.zip.spdx.json + + - name: Upload Lambda zip to draft release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + gh release upload "${TAG}" dist/github-token-broker.zip --clobber diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..6ff9f9f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,39 @@ +name: OpenSSF Scorecard + +on: + push: + branches: + - master + schedule: + - cron: "43 5 * * 2" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + security-events: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF results to code scanning + uses: github/codeql-action/upload-sarif@6f7376048dd6be43ea34c6bd4a3ecef7d7abd847 # v4.31.4 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md index 096601a..4a7586f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,45 @@ Successful responses have this shape: The token is intentionally logged nowhere. Callers should treat it as a secret and discard it after the task completes. +## Verification + +Releases ship only the Lambda zip. Build provenance and an SBOM are persisted to GitHub's Attestations API; verify them with [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) rather than downloading signature or SBOM files from the release page. + +```sh +TAG=v1.0.0 +gh release download "$TAG" -R meigma/github-token-broker -p 'github-token-broker.zip' + +gh release verify "$TAG" -R meigma/github-token-broker +gh release verify-asset "$TAG" ./github-token-broker.zip -R meigma/github-token-broker + +gh attestation verify ./github-token-broker.zip \ + --repo meigma/github-token-broker \ + --signer-workflow meigma/github-token-broker/.github/workflows/reusable-release.yml \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +``` + +The attestation call above validates the SLSA build provenance by default. To validate the SBOM attestation specifically, add a predicate filter: + +```sh +gh attestation verify ./github-token-broker.zip \ + --repo meigma/github-token-broker \ + --predicate-type https://spdx.dev/Document +``` + +For air-gapped or offline verification, download the attestation bundle first and pass it explicitly: + +```sh +gh attestation download ./github-token-broker.zip -R meigma/github-token-broker +gh attestation verify ./github-token-broker.zip \ + --bundle github-token-broker.zip.bundle.jsonl \ + --signer-workflow meigma/github-token-broker/.github/workflows/reusable-release.yml \ + --source-ref "refs/tags/$TAG" \ + --deny-self-hosted-runners +``` + +See [docs/explanation/release-architecture.md](docs/docs/explanation/release-architecture.md) for the full pipeline design and the rationale behind the attestation-only verification channel. + ## Documentation The Docusaurus site under [`docs/`](docs/) is the canonical location for configuration, deployment, and operational guidance. Published versions will be linked here once the site is deployed. diff --git a/docs/docs/explanation/release-architecture.md b/docs/docs/explanation/release-architecture.md new file mode 100644 index 0000000..1030ea3 --- /dev/null +++ b/docs/docs/explanation/release-architecture.md @@ -0,0 +1,106 @@ +--- +title: Release Architecture +description: How github-token-broker versions, builds, signs, and publishes releases. +--- + +# Release Architecture + +`github-token-broker` splits release work across four workflow pieces so that +versioning, publication, and attestation each have clear ownership and a +clear failure boundary. This is the pattern the [publishing +skill](https://github.com/anthropics/claude-code) baseline recommends, and it +is what downstream consumers rely on when they run `gh attestation verify`. + +## The four pieces + +1. **Versioning** — [`.github/workflows/release-please.yml`](https://github.com/meigma/github-token-broker/blob/master/.github/workflows/release-please.yml). + On every push to `master`, release-please reads the Conventional Commit + history and updates (or creates) an open release PR. Merging that PR + creates the `vX.Y.Z` tag and a **draft** GitHub Release. +2. **Tag entrypoint** — [`.github/workflows/release.yml`](https://github.com/meigma/github-token-broker/blob/master/.github/workflows/release.yml). + Triggered by the tag push. Three jobs in order: + - `verify-draft-release` — confirms the draft release exists for the tag + and bails early on mismatch. + - `release` — calls the reusable publish workflow. + - `publish-draft-release` — flips the release from draft to published + only after the reusable workflow succeeds. +3. **Reusable publish** — [`.github/workflows/reusable-release.yml`](https://github.com/meigma/github-token-broker/blob/master/.github/workflows/reusable-release.yml). + The trusted builder. Builds the Lambda zip, generates a checksum file + and an SBOM, creates GitHub Artifact Attestations for both, and uploads + the zip to the draft release. Its identity is what + `gh attestation verify --signer-workflow` validates. +4. **Continuous security** — CodeQL, dependency review, and OpenSSF + Scorecard workflows. They keep the release path honest between tagged + releases. + +## What ships on a release + +Only the Lambda zip. That's it. + +Provenance and the SBOM are pushed to [GitHub's Attestations +API](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) +and fetched on demand by `gh attestation verify`. They are **not** attached +as release assets. + +This design follows the publishing skill's rule: attestation metadata is +pipeline handoff data, not release payload. Attaching provenance bundles or +SBOM files to the release would duplicate what the Attestations API already +owns — and would invite consumers to verify the wrong thing (a local file +they downloaded) instead of the right thing (an API-resident attestation +bound to a specific digest and workflow identity). + +## Why no Cosign + +GitHub Artifact Attestations is a keyless Sigstore flow under the hood. +Adding a separate `cosign sign-blob` step produces a second signature over +the same content with a second identity, and yields a second bundle for +consumers to verify. That is pure duplication and a second key-management +surface. + +If a downstream consumer ever needs bare Sigstore bundles, GitHub's +attestation bundle is available via `gh attestation download`. See the +[Verification section of the README](https://github.com/meigma/github-token-broker#verification) +for the offline flow. + +## Why a separate reusable workflow + +`gh attestation verify --signer-workflow` pins attestations to a specific +workflow file's identity. Splitting the reusable publisher into its own +file gives the signer a stable, minimal identity that is distinct from the +tag-entrypoint workflow that called it. This is the SLSA Level 3 trusted +builder property — if `release.yml` is ever compromised or rewritten, the +reusable file's identity (and therefore the attestations consumers have +already verified) does not change. + +## How this differs from a typical GoReleaser flow + +Many Go projects drive releases with GoReleaser + Cosign + per-archive +SBOMs attached to the release. That pattern is a reasonable default for +multi-platform binary distributions where consumers expect to download +artifacts from the release page and verify signatures side by side. + +For this repo the Lambda zip is the only artifact and GitHub's Attestations +API is the verification channel, so we skip GoReleaser, Cosign, and +on-release SBOMs. The publishing skill's rule — "do not make checksums, +digests, SBOMs, and attestation metadata release assets unless the project +explicitly supports offline verification" — is what the current pipeline +follows. + +## Execution order for a real release + +1. Merge feature PRs with Conventional Commit titles. Release-please + updates the open release PR on each push to `master`. +2. Merge the release PR. Release-please creates the tag and the draft + release. +3. `release.yml` fires on the tag push. If any step fails (`verify`, + `release`, or `publish-draft-release`), the release stays in draft and + is effectively invisible to consumers. +4. Fix forward. Tags are immutable, so a broken tagged release is resolved + by the next Conventional Commit rolling a new version. + +## Further reading + +- [Using artifact attestations to establish provenance for builds](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) +- [`gh attestation verify`](https://cli.github.com/manual/gh_attestation_verify) +- [SLSA — Build Levels](https://slsa.dev/spec/v1.0/levels) +- [release-please](https://github.com/googleapis/release-please) diff --git a/scripts/publish-draft-release.sh b/scripts/publish-draft-release.sh new file mode 100755 index 0000000..95387dd --- /dev/null +++ b/scripts/publish-draft-release.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Flip the existing draft release to published. Runs only after all build +# and attestation steps have succeeded, so a broken release stays invisible +# until it is complete. + +set -euo pipefail + +: "${GITHUB_REF_NAME:?GITHUB_REF_NAME must be set by the GitHub Actions runtime}" +: "${GH_TOKEN:?GH_TOKEN must be set (pass in secrets.GITHUB_TOKEN)}" + +gh release edit "${GITHUB_REF_NAME}" --draft=false diff --git a/scripts/wait-for-draft-release.sh b/scripts/wait-for-draft-release.sh new file mode 100755 index 0000000..6ae4b3f --- /dev/null +++ b/scripts/wait-for-draft-release.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Confirm that release-please created a draft release matching the pushed tag. +# Fails early if the tag does not resolve to a draft release, so the tag +# workflow bails before doing any build work. + +set -euo pipefail + +: "${GITHUB_REF_NAME:?GITHUB_REF_NAME must be set by the GitHub Actions runtime}" +: "${GH_TOKEN:?GH_TOKEN must be set (pass in secrets.GITHUB_TOKEN)}" + +tag="${GITHUB_REF_NAME}" + +attempt_max=6 +attempt_sleep=10 + +for attempt in $(seq 1 "${attempt_max}"); do + if release_json="$(gh release view "${tag}" --json tagName,isDraft,url 2>/dev/null)"; then + tag_name="$(jq -r '.tagName' <<<"${release_json}")" + is_draft="$(jq -r '.isDraft' <<<"${release_json}")" + release_url="$(jq -r '.url' <<<"${release_json}")" + + printf 'draft release: %s\n' "${release_url}" + + if [[ "${tag_name}" != "${tag}" ]]; then + printf 'release tag mismatch: expected %s, got %s\n' "${tag}" "${tag_name}" >&2 + exit 1 + fi + + if [[ "${is_draft}" != "true" ]]; then + printf 'release %s is not a draft release\n' "${tag}" >&2 + exit 1 + fi + + exit 0 + fi + + printf 'release %s not visible yet (attempt %d/%d); sleeping %ds\n' \ + "${tag}" "${attempt}" "${attempt_max}" "${attempt_sleep}" + sleep "${attempt_sleep}" +done + +printf 'release %s did not appear within %d attempts\n' "${tag}" "${attempt_max}" >&2 +exit 1 From f5f196d03f28720acbf51387aa9a6cd3ab2a2525 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 22 Apr 2026 20:27:51 -0700 Subject: [PATCH 2/2] ci: drop dependency-review workflow pending feature setup The `dependency-review` action fails on this repo with "Dependency review is not supported on this repository" despite dependency graph being on at the repo level. Looks like an org/feature-level gate we need to investigate separately; removing to unblock the publish pipeline. CodeQL + Scorecard still ship in this PR, so Phase 4's continuous security baseline is still covered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dependency-review.yml | 30 ------------------------- 1 file changed, 30 deletions(-) delete mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 7d2383d..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Dependency Review - -on: - pull_request: - branches: - - master - -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - dependency-review: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Review dependencies - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 - with: - fail-on-severity: high - comment-summary-in-pr: on-failure