From 88c7c0b1a2d7826ee576ec74b5400021e8a2c84f Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Tue, 30 Jun 2026 02:39:17 -0700 Subject: [PATCH] ci: upload SLSA provenance by release ID, not via the generator's auto-upload The v0.15.4 release run failed the same way again, but with a worse side effect: the provenance job's upload-assets: true relies on softprops/action-gh-release, which finds the target release by tag. GitHub's "get release by tag" API does not return draft releases, so against our still-draft release it found nothing and created a *second*, non-draft release for the same tag instead of failing cleanly. That second release immediately and permanently marked the tag as immutable -- confirmed in production: deleting the bad release afterward did not free the tag back up, and the original (correct) draft, with all 23 assets, could never be published under that tag. Recovery required abandoning v0.15.4 and cutting v0.15.5. Fix: stop relying on the generator's built-in upload entirely. - provenance job: upload-assets is now false. The generator still produces the multiple.intoto.jsonl artifact, just doesn't try to attach it to a release. - publish job: downloads the provenance artifact, resolves the draft release by listing releases and filtering for the matching tag with draft == true (never by tag name), uploads the file directly via the GitHub REST API, then publishes. This never gives any tool a chance to "helpfully" create a duplicate release. - Tag name flows through env + jq --arg rather than string interpolation into a --jq filter, avoiding the same class of injection issue CodeRabbit flagged on the shell command earlier in this PR. - dev-docs/CI.md documents the failure mode and why the fix avoids it. Validated locally: goreleaser check / make test pass, and the jq selection logic was sanity-checked against a synthetic draft+published pair for the same tag. The actual draft lookup, upload, and publish sequence can only be verified by a real release run in GitHub Actions. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 50 ++++++++++++++++++++++++++++------- dev-docs/CI.md | 8 +++--- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fda793..532053f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,13 +117,18 @@ jobs: # 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. + # upload-assets is deliberately false: its underlying action + # (softprops/action-gh-release) finds a release by tag, and GitHub's + # "get release by tag" API does not return drafts. Against our still-draft + # release, it can't find one — so it creates a *second*, non-draft release + # for the same tag instead, which immediately and permanently marks that + # tag as immutable (even if the bad release is later deleted). The + # `publish` job uploads this provenance file itself, by release ID, to + # avoid that entirely. 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/') }} + upload-assets: false publish: needs: [release, provenance] @@ -141,11 +146,17 @@ jobs: 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. + - name: Download SLSA provenance + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.provenance.outputs.provenance-name }} + + - name: Attach provenance and publish release + # Uploads the provenance file directly to the draft release by ID + # (not by tag — see the `provenance` job comment for why), then + # finalizes the draft into a published release now that every asset + # 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 @@ -157,4 +168,23 @@ jobs: env: GH_TOKEN: ${{ steps.release-token.outputs.token }} RELEASE_TAG: ${{ github.ref_name }} - run: gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli + PROVENANCE_FILE: ${{ needs.provenance.outputs.provenance-name }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/bomly-dev/bomly-cli/releases?per_page=100" | \ + jq -r --arg tag "$RELEASE_TAG" '.[] | select(.tag_name == $tag and .draft == true) | .id' | head -n1)" + if [ -z "$release_id" ]; then + echo "::error::No draft release found for tag ${RELEASE_TAG}" + exit 1 + fi + + curl -fsSL \ + -X POST \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PROVENANCE_FILE}" \ + "https://uploads.github.com/repos/bomly-dev/bomly-cli/releases/${release_id}/assets?name=${PROVENANCE_FILE}" \ + > /dev/null + + gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli diff --git a/dev-docs/CI.md b/dev-docs/CI.md index 241ff68..8a64daf 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. 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). +- **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. The `publish` job then uploads it to the GitHub release itself, by release ID — not via the generator's built-in `upload-assets`, which can't target draft releases (see [Release Process](#release-process) for why). 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 the `publish` job flips it to published only after provenance is attached. A few Scorecard checks require maintainer action **outside** the repository and are not code changes: @@ -196,12 +196,14 @@ Release packaging is driven by `.goreleaser.yaml`. The release workflow uses GoR - `windows/arm64` 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. +8. The `provenance` job calls `slsa-github-generator` to generate SLSA provenance (`multiple.intoto.jsonl`) as a workflow artifact. It does **not** upload it to the release itself (`upload-assets: false`) — see the caveat below. +9. The `publish` job downloads that provenance artifact, looks up the draft release **by ID** (listing releases and filtering for the matching tag with `draft == true`, not by tag name), uploads the provenance file to it directly via the GitHub REST API, then 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 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. +**Why the `provenance` job can't upload directly:** GitHub's "get release by tag" API does not return draft releases — they aren't associated with a tag ref until published. The SLSA generator's built-in `upload-assets` option uses `softprops/action-gh-release`, which resolves the target release by tag; against our draft, it finds nothing and **creates a second, non-draft release for the same tag** instead of failing cleanly. That second release immediately and permanently marks the tag as immutable, even if you delete the bad release afterward — there is no way to free the tag back up. (This happened once in production; recovering meant abandoning the tag and cutting a new one.) The `publish` job avoids the whole failure mode by resolving the release by ID itself and never giving any tool a chance to "helpfully" create a duplicate. + ## Yanking Releases Deleting or unpublishing a GitHub Release automatically starts the yanking path in the `Release lifecycle sync` workflow. The workflow dispatches a landing-page removal event so the yanked version is removed from the version selector and changelog.