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.