From f9b20285ee2523041e4d16ac9e928ae1792966e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Apr 2026 08:05:39 +0900 Subject: [PATCH] fix(release): publish immutable release assets before publication --- .github/workflows/build-baseline.yml | 89 ++++++++++++------- .github/workflows/sbom.yml | 26 ------ docs/architecture/overview.md | 2 +- docs/operations/deploy-runbook.md | 1 + docs/release/release-policy.md | 2 + docs/security/github-required-checks.md | 7 +- docs/security/sbom-policy.md | 2 +- scripts/checks/verify_supply_chain.py | 28 +++++- .../tests/test_supply_chain_policy.py | 54 +++++++++++ 9 files changed, 145 insertions(+), 66 deletions(-) diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index d3af455..99b8feb 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -11,9 +11,6 @@ on: - main tags: - "v*" - release: - types: - - published permissions: contents: read @@ -288,44 +285,72 @@ jobs: - name: Confirm both macOS architectures built run: true - attach-windows-release-artifact: - name: release-artifact / windows - if: github.event_name == 'release' + publish-immutable-release: + name: release-artifact / publish + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: - - build-windows-native - - build-windows-arm64 + - gate-windows + - gate-macos permissions: contents: write steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - pattern: bandscope-windows-*-${{ github.sha }} - path: artifacts - merge-multiple: true - - name: Attach Windows artifacts to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$RELEASE_TAG" artifacts/* --clobber --repo ${{ github.repository }} - - attach-macos-release-artifact: - name: release-artifact / macos - if: github.event_name == 'release' - runs-on: ubuntu-latest - needs: - - build-macos-native - - build-macos-arm64 - permissions: - contents: write - steps: + persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: bandscope-macos-*-${{ github.sha }} + pattern: bandscope-*-${{ github.sha }} path: artifacts merge-multiple: true - - name: Attach macOS artifacts to release + - name: Generate release CycloneDX SBOM + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + path: . + format: cyclonedx-json + output-file: bandscope-sbom.cdx.json + upload-artifact: false + upload-release-assets: false + - name: Upload release SBOM artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bandscope-release-sbom-${{ github.sha }} + path: | + bandscope-sbom.cdx.json + supply-chain/supplemental-component-inventory.json + - name: Validate release asset set + run: | + set -euo pipefail + shopt -s nullglob + test -f bandscope-sbom.cdx.json + test -f supply-chain/supplemental-component-inventory.json + windows_amd64=(artifacts/*windows-amd64*) + windows_arm64=(artifacts/*windows-arm64*) + macos_amd64=(artifacts/*macos-amd64*) + macos_arm64=(artifacts/*macos-arm64*) + checksums=(artifacts/*.sha256) + (( ${#windows_amd64[@]} > 0 )) + (( ${#windows_arm64[@]} > 0 )) + (( ${#macos_amd64[@]} > 0 )) + (( ${#macos_arm64[@]} > 0 )) + (( ${#checksums[@]} > 0 )) + - name: Create draft release with complete assets, then publish env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$RELEASE_TAG" artifacts/* --clobber --repo ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists; immutable release assets must be attached before publication." + exit 1 + fi + gh release create "$RELEASE_TAG" \ + artifacts/* \ + bandscope-sbom.cdx.json \ + supply-chain/supplemental-component-inventory.json \ + --draft \ + --generate-notes \ + --title "BandScope ${RELEASE_TAG#v}" \ + --verify-tag \ + --repo "${{ github.repository }}" + gh release edit "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}" diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 1320f47..d255072 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -58,29 +58,3 @@ jobs: with: name: bandscope-supply-chain-inventory path: supply-chain/supplemental-component-inventory.json - - release-sbom: - name: attach-sbom-to-release - if: github.event_name == 'release' - runs-on: ubuntu-latest - needs: - - sbom - permissions: - contents: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bandscope-sbom - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bandscope-supply-chain-inventory - path: supply-chain - - - name: Attach SBOM to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$RELEASE_TAG" bandscope-sbom.cdx.json supply-chain/supplemental-component-inventory.json --clobber diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index f58fb26..3cf5261 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -42,5 +42,5 @@ GitHub is the source of truth for repository governance, PR review, CI/CD, Code ## CI/CD and release flow - PRs into `develop` and `main` run CI, dependency review, security audit, secret-scan gate, SBOM generation, and CodeQL -- release flows publish desktop artifacts plus SBOM evidence to GitHub Releases +- release flows publish desktop artifacts plus SBOM evidence to GitHub Releases through a tag-driven draft-before-publish path - branch protection connects stable required checks after bootstrap workflows exist diff --git a/docs/operations/deploy-runbook.md b/docs/operations/deploy-runbook.md index 8d20afc..b9dd2ca 100644 --- a/docs/operations/deploy-runbook.md +++ b/docs/operations/deploy-runbook.md @@ -14,6 +14,7 @@ BandScope currently relies on GitHub Actions CI/release workflows as deploy-qual - SBOM artifact generation (`.github/workflows/sbom.yml`) - Release preflight completion (`.github/workflows/release.yml`) - Cross-platform build baseline completion (`.github/workflows/build-baseline.yml`) +- For immutable GitHub Releases, release assets are attached by the tag-driven draft release flow before publication, not by post-publication `release` events ## Runtime verification baseline diff --git a/docs/release/release-policy.md b/docs/release/release-policy.md index 04d9848..924f8f3 100644 --- a/docs/release/release-policy.md +++ b/docs/release/release-policy.md @@ -21,5 +21,7 @@ BandScope distributes release artifacts through GitHub Releases. ## Release rules - release merges do not bypass review or required checks +- immutable releases are published from a tag-driven draft release after assets, checksums, SBOM, and supplemental inventory are attached +- release workflows must not attach assets after a GitHub Release is already published - release artifacts must remain traceable to the GitHub Release record - missing SBOM or missing supplemental inventory means the release baseline is incomplete diff --git a/docs/security/github-required-checks.md b/docs/security/github-required-checks.md index fbc15a4..eb62fda 100644 --- a/docs/security/github-required-checks.md +++ b/docs/security/github-required-checks.md @@ -57,10 +57,11 @@ These controls are expressed by repo workflows and are expected to be connected ## Release evidence baseline - CycloneDX JSON SBOM must be uploaded as a GitHub Actions artifact -- CycloneDX JSON SBOM must be attached to the GitHub Release when the workflow runs on a Release event -- `supply-chain/supplemental-component-inventory.json` must be uploaded as a GitHub Actions artifact and attached to the GitHub Release on Release events -- packaged desktop artifacts and checksums should remain traceable from the same release record when the release workflow emits them +- CycloneDX JSON SBOM must be attached to the GitHub Release before publication by the tag-driven draft release flow +- `supply-chain/supplemental-component-inventory.json` must be uploaded as a GitHub Actions artifact and attached to the GitHub Release before publication +- packaged desktop artifacts and checksums must remain traceable from the same release record when the release workflow emits them - release artifacts should include explicit OS/arch naming for Windows amd64, Windows arm64, macOS amd64, and macOS arm64 +- workflows must not attach assets in response to `release: published`; immutable releases reject post-publication mutation ## Enforcement note diff --git a/docs/security/sbom-policy.md b/docs/security/sbom-policy.md index ce9cb8b..06495c1 100644 --- a/docs/security/sbom-policy.md +++ b/docs/security/sbom-policy.md @@ -14,7 +14,7 @@ BandScope generates machine-readable SBOMs in GitHub Actions as a bootstrap cont ## Retention - upload the SBOM as a GitHub Actions artifact -- attach the SBOM to the GitHub Release when a release event exists +- attach the SBOM to the GitHub Release before publication through the tag-driven draft release flow - retain the supplemental component inventory with the SBOM ## Supplemental inventory diff --git a/scripts/checks/verify_supply_chain.py b/scripts/checks/verify_supply_chain.py index e7b68f9..76cab50 100644 --- a/scripts/checks/verify_supply_chain.py +++ b/scripts/checks/verify_supply_chain.py @@ -119,7 +119,6 @@ def verify_workflow_coverage() -> list[str]: "main", "pull_request", "push", - "release:", "tags:", "windows-2025", "windows-11-arm", @@ -127,13 +126,16 @@ def verify_workflow_coverage() -> list[str]: "macos-15", "gate / build / windows", "gate / build / macos", - "release-artifact / macos", - "release-artifact / windows", + "release-artifact / publish", "ubuntu-latest", "bandscope-windows-amd64-${{ github.sha }}", "bandscope-windows-arm64-${{ github.sha }}", "bandscope-macos-amd64-${{ github.sha }}", "bandscope-macos-arm64-${{ github.sha }}", + "bandscope-release-sbom-${{ github.sha }}", + "gh release create", + "--draft", + "--verify-tag", "Get-MpComputerStatus", ]: if build and token not in build: @@ -159,6 +161,25 @@ def verify_workflow_coverage() -> list[str]: return missing +def verify_immutable_release_upload_policy() -> list[str]: + """Return workflow violations that mutate immutable releases after publication.""" + violations: list[str] = [] + workflow_paths = sorted(Path(".github/workflows").glob("*.yml")) + sorted( + Path(".github/workflows").glob("*.yaml") + ) + for path in workflow_paths: + content = path.read_text(encoding="utf-8") + if "release:" not in content or "published" not in content: + continue + if "gh release upload" not in content: + continue + violations.append( + f"{path}: release published workflows must not upload GitHub Release assets; " + "immutable releases require draft-before-publish asset attachment" + ) + return violations + + def main() -> int: """Return a failing exit code when supply-chain controls are incomplete.""" violations: list[str] = [] @@ -166,6 +187,7 @@ def main() -> int: violations.extend(verify_pinned_actions()) violations.extend(verify_dependabot_coverage()) violations.extend(verify_workflow_coverage()) + violations.extend(verify_immutable_release_upload_policy()) if violations: print("Supply-chain verification failed:") diff --git a/services/analysis-engine/tests/test_supply_chain_policy.py b/services/analysis-engine/tests/test_supply_chain_policy.py index fdb2e32..8ff2908 100644 --- a/services/analysis-engine/tests/test_supply_chain_policy.py +++ b/services/analysis-engine/tests/test_supply_chain_policy.py @@ -113,3 +113,57 @@ def test_supply_chain_check_requires_ossf_default_branch_guard( "ossf scorecard workflow must guard Scorecard execution to the repository default branch" in violations ) + + +def test_supply_chain_check_rejects_release_published_asset_upload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure immutable releases are not mutated after publication.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_immutable_release_upload" + ) + + workflow_dir = tmp_path / ".github" / "workflows" + workflow_dir.mkdir(parents=True) + (workflow_dir / "sbom.yml").write_text( + """ +name: sbom +on: + release: + types: + - published +jobs: + release-sbom: + steps: + - name: Attach SBOM to GitHub Release + run: gh release upload "$RELEASE_TAG" bandscope-sbom.cdx.json --clobber +""".strip(), + encoding="utf-8", + ) + + monkeypatch.chdir(tmp_path) + + assert hasattr(supply_chain, "verify_immutable_release_upload_policy") + violations = supply_chain.verify_immutable_release_upload_policy() + + assert ( + ".github/workflows/sbom.yml: release published workflows must not upload GitHub " + "Release assets; immutable releases require draft-before-publish asset attachment" + ) in violations + + +def test_supply_chain_check_accepts_immutable_release_safe_workflows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure checked-in workflows avoid release-published asset mutation.""" + supply_chain = load_module( + "scripts/checks/verify_supply_chain.py", "verify_supply_chain_immutable_release_repo" + ) + repo_root = Path(__file__).resolve().parents[3] + + monkeypatch.chdir(repo_root) + + assert hasattr(supply_chain, "verify_immutable_release_upload_policy") + violations = supply_chain.verify_immutable_release_upload_policy() + + assert not violations