From 42c857ddd87a3b3f863e7eba42a617eeb6ab7e41 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Tue, 30 Jun 2026 01:47:42 -0700 Subject: [PATCH 1/2] ci: keep release draft until provenance lands, fixing immutable-release upload failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.15.3 release run (#222's first real release) failed: the SLSA provenance job tried to upload multiple.intoto.jsonl to the release after GoReleaser had already published it, and GitHub's immutable releases feature (GA Oct 2025) rejects asset uploads to any release once published — "Cannot upload assets to an immutable release." Per GitHub's own guidance, the fix is to keep the release as a draft until every asset is attached, and publish last: - .goreleaser.yaml: release.draft is now true. GoReleaser creates the release and uploads its assets (archives, packages, checksums, signature) without publishing. - release.yml: the provenance job now uploads to the still-draft release (drafts are exempt from the immutability restriction). A new `publish` job, gated on `needs: [release, provenance]`, mints a fresh release-bot app token and runs `gh release edit --draft=false` once both are done — preserving the existing requirement that publishing use an app-attributed token so `release: published` cascades to notify-landing-yank.yml. - dev-docs/CI.md: documents the new draft -> provenance -> publish sequencing and the immutable-releases constraint driving it. Known minor tradeoff: the Homebrew/Scoop/WinGet manifest PRs are opened by GoReleaser before publish, so their release-asset URLs are briefly (well under a minute) not yet publicly downloadable until the `publish` job finishes. Documented in dev-docs/CI.md; not fixed here since it's a narrow timing window with low practical impact. Validated locally: `goreleaser check` accepts the config (only the pre-existing, unrelated `brews` deprecation warning remains), and `make test` passes. The draft/publish/token-cascade behavior itself can only be verified by a real tagged release in GitHub Actions. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 49 ++++++++++++++++++++++++++++++----- .goreleaser.yaml | 7 ++++- dev-docs/CI.md | 19 +++++++------- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb0b860..386106b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,13 +73,11 @@ jobs: permission-pull-requests: write - name: Mint release token - # Used as GoReleaser's GITHUB_TOKEN to publish the release to this repo. - # The default GITHUB_TOKEN cannot be used here: GitHub does not start new - # workflow runs from events triggered by the default token, so a release - # published with it would never fire the `release: published` trigger on - # "Release lifecycle sync" (notify-landing-yank.yml) — and the - # landing-page docs sync (repository_dispatch) would silently never run. - # An app token is attributed to the app, so the publish event cascades. + # Used as GoReleaser's GITHUB_TOKEN to create the draft release on this + # repo and upload its assets (archives, packages, checksums, signature). + # The release stays a draft here (see .goreleaser.yaml) — actual + # publishing happens in the `publish` job below, after the SLSA + # provenance file is also attached. uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: release-token with: @@ -118,7 +116,44 @@ jobs: # shorter tag — slsa-verifier resolves the builder identity from this exact ref, # and SHA-pinning breaks that resolution. See .github/pinact.yaml, which excludes # this line from automated re-pinning. + # + # Uploads to the still-draft release created above — draft releases are + # exempt from GitHub's immutable-releases restriction, which otherwise + # rejects new assets on an already-published release. uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: base64-subjects: ${{ needs.release.outputs.hashes }} upload-assets: ${{ startsWith(github.ref, 'refs/tags/') }} + + publish: + needs: [release, provenance] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Mint release token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: release-token + with: + client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + owner: bomly-dev + repositories: bomly-cli + permission-contents: write + + - name: Publish release + # Finalizes the draft into a published release now that every asset — + # GoReleaser's archives/packages/checksums/signature, plus the SLSA + # provenance file from the `provenance` job — is attached. Must run + # last: immutable releases reject new assets once published. + # + # Uses the release-bot app token, not the default GITHUB_TOKEN: GitHub + # does not start new workflow runs from events triggered by the + # default token, so a release published with it would never fire the + # `release: published` trigger on "Release lifecycle sync" + # (notify-landing-yank.yml) — and the landing-page docs sync would + # silently never run. An app token is attributed to the app, so the + # publish event cascades. + env: + GH_TOKEN: ${{ steps.release-token.outputs.token }} + run: gh release edit "${{ github.ref_name }}" --draft=false --repo bomly-dev/bomly-cli diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 17f9e48..283abc5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -107,7 +107,12 @@ release: github: owner: bomly-dev name: bomly-cli - draft: false + # Stays a draft until the `publish` job in release.yml finalizes it. GitHub's + # immutable releases feature forbids adding assets once a release is + # published, and the SLSA provenance file is attached by a later job — so + # publishing has to be the last step, after every asset (including + # provenance) is in place. See dev-docs/CI.md. + draft: true prerelease: auto footer: | --- diff --git a/dev-docs/CI.md b/dev-docs/CI.md index 3ac858e..241ff68 100644 --- a/dev-docs/CI.md +++ b/dev-docs/CI.md @@ -35,7 +35,7 @@ Bomly dogfoods its own domain by tracking the project's [OpenSSF Scorecard](http - **Token-Permissions** — every workflow declares a top-level `permissions:` block scoped to `contents: read`. Any write scope (release publishing, the Guard PR comment, the smoke-goldens PR) is granted at the **job** level only, never at the top level. - **Pinned-Dependencies** — all GitHub Actions are pinned by full commit SHA with a trailing `# vX.Y.Z` comment (for example `actions/checkout@ # v7.0.0`). Dependabot's `github-actions` updater understands this form and bumps both the SHA and the comment, so pinning does not freeze us on stale actions. When adding a new `uses:`, pin it the same way — `pinact run` (suzuki-shunsuke/pinact) rewrites the whole tree, or resolve a single tag with `gh api repos///commits/ --jq .sha`. The `Smoke` and `Update Smoke Goldens` workflows install `pip`/`pipenv`/`poetry` from `.github/requirements-ci-tools.txt`, a hash-locked, fully-resolved requirements file (`pip install --require-hashes`) instead of an unpinned inline `pip install`. Regenerate it after bumping `.github/requirements-ci-tools.in` with `pip-compile --allow-unsafe --generate-hashes --output-file=requirements-ci-tools.txt requirements-ci-tools.in` run from `.github/` under the same Python version the workflows use (3.12), so the resolved hash set covers the right wheel tags. - **SAST** — CodeQL runs on every push, PR, and weekly. -- **Signed-Releases** — the `release` job signs `SHA256SUMS` keylessly with [cosign](https://docs.sigstore.dev/cosign/signing/overview/) (`SHA256SUMS.sigstore.json`, GitHub OIDC identity, no managed keys), and a separate `provenance` job calls the [slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator) generic builder to produce a single `multiple.intoto.jsonl` SLSA Build Level 3 provenance file over every release artifact's hash, uploaded to the same GitHub release. Verification commands for end users are in [docs/INSTALLATION.md](../docs/INSTALLATION.md#verify-release-checksums). The generator's `uses:` line is pinned to the `v2.1.0` tag, not a commit SHA — SHA-pinning it breaks `slsa-verifier`'s builder-identity check — and `.github/pinact.yaml` excludes that line from automated re-pinning so it doesn't regress. +- **Signed-Releases** — the `release` job signs `SHA256SUMS` keylessly with [cosign](https://docs.sigstore.dev/cosign/signing/overview/) (`SHA256SUMS.sigstore.json`, GitHub OIDC identity, no managed keys), and a separate `provenance` job calls the [slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator) generic builder to produce a single `multiple.intoto.jsonl` SLSA Build Level 3 provenance file over every release artifact's hash, uploaded to the same GitHub release. Verification commands for end users are in [docs/INSTALLATION.md](../docs/INSTALLATION.md#verify-release-checksums). The generator's `uses:` line is pinned to the `v2.1.0` tag, not a commit SHA — SHA-pinning it breaks `slsa-verifier`'s builder-identity check — and `.github/pinact.yaml` excludes that line from automated re-pinning so it doesn't regress. Because the provenance file is attached by a job downstream of `release`, and GitHub's immutable releases feature blocks adding assets after a release is published, `.goreleaser.yaml` keeps the release as a draft and a final `publish` job flips it to published only after `provenance` succeeds — see [Release Process](#release-process). A few Scorecard checks require maintainer action **outside** the repository and are not code changes: @@ -176,8 +176,8 @@ go build -tags "bomly_external_syft,bomly_external_grype" -o bin/bomly-lite ./cm Release packaging is driven by `.goreleaser.yaml`. The release workflow uses GoReleaser to create: -- A published GitHub Release with archives for `bomly` and `bomly-lite`. -- `SHA256SUMS`. +- A GitHub Release with archives for `bomly` and `bomly-lite`. +- `SHA256SUMS`, keylessly signed with cosign (`SHA256SUMS.sigstore.json`). - Linux `.deb`, `.rpm`, `.apk`, and Arch Linux package artifacts for the full `bomly` binary. - Homebrew cask, Scoop, and WinGet manifest pull requests. @@ -186,7 +186,7 @@ Release packaging is driven by `.goreleaser.yaml`. The release workflow uses GoR 1. Merge to `main`. 2. When ready to publish, a maintainer runs the `Auto Version` workflow from `main` and chooses a `patch`, `minor`, or `major` bump. 3. The `Auto Version` workflow updates `cmd/bomly/main.go`, commits the bump, creates a tag such as `v0.2.0`, and starts the `Release` workflow. -4. The `Release` workflow reruns validation and runs GoReleaser. +4. The `Release` workflow reruns validation, then the `release` job runs GoReleaser. 5. GoReleaser cross-compiles `bomly` and `bomly-lite`, packages archives for: - `linux/amd64` - `linux/arm64` @@ -194,12 +194,13 @@ Release packaging is driven by `.goreleaser.yaml`. The release workflow uses GoR - `darwin/arm64` - `windows/amd64` - `windows/arm64` -6. GoReleaser generates `SHA256SUMS` and Linux packages. -7. GoReleaser publishes the GitHub Release, using the configured GoReleaser header plus GitHub-native generated release notes, and uploads archives, packages, and checksums. -8. GoReleaser opens or updates package-manager manifest PRs for Homebrew, Scoop, and WinGet. -9. After the release is published, the `Release lifecycle sync` workflow dispatches the landing-page docs and changelog sync with the published timestamp. +6. GoReleaser generates `SHA256SUMS`, Linux packages, and the cosign signature, then creates the GitHub Release **as a draft** and uploads everything to it. +7. GoReleaser opens or updates package-manager manifest PRs for Homebrew, Scoop, and WinGet (these reference the release's download URLs, which aren't publicly fetchable until the release is published in step 9 — a brief window, typically under a minute). +8. The `provenance` job calls `slsa-github-generator` to attach SLSA provenance (`multiple.intoto.jsonl`) to the same draft release. +9. The `publish` job flips the release from draft to published, using the configured GoReleaser header plus GitHub-native generated release notes. +10. After the release is published, the `Release lifecycle sync` workflow dispatches the landing-page docs and changelog sync with the published timestamp. -The manual approval point for a release is the `Auto Version` workflow that creates the release tag. The GitHub Release is intentionally published automatically after validation so package-manager manifest PRs can reference public release assets and checksums. +The manual approval point for a release is the `Auto Version` workflow that creates the release tag. The GitHub Release stays a draft until every asset — including SLSA provenance — is attached, then a final job publishes it. This is required by GitHub's [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) feature: once a release is published, no further assets can be added by anyone, so the provenance file (generated by a separate downstream job, by design — see [Supply-Chain Hardening](#supply-chain-hardening-openssf-scorecard)) must land before publish, not after. ## Yanking Releases From 8c71b5b1aebf2d7c9f46d0d150f9dbc29171d180 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Tue, 30 Jun 2026 01:53:01 -0700 Subject: [PATCH 2/2] ci: avoid template-injection in release publish step Address CodeRabbit/zizmor finding on #223: github.ref_name was interpolated directly into the run: shell command, which expands before Bash parses the line. Pass it through env instead and reference it as a quoted shell variable, so a crafted tag name can't inject shell commands. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 386106b..5fda793 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,4 +156,5 @@ jobs: # publish event cascades. env: GH_TOKEN: ${{ steps.release-token.outputs.token }} - run: gh release edit "${{ github.ref_name }}" --draft=false --repo bomly-dev/bomly-cli + RELEASE_TAG: ${{ github.ref_name }} + run: gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli