diff --git a/.github/workflows/README.md b/.github/workflows/README.md index a0fef8c6a..f4113c740 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -44,6 +44,12 @@ a check fails. Six-hour lock-watch evidence explicitly records its narrower `release_lock` mode and skipped full-surface checks. The job is read-only and requires the public `PULSE_UPDATE_SIGNING_PUBLIC_KEY` repository variable. +Future release candidates also carry +`release-build-provenance.sigstore.json`, produced by the hosted +`build-release-candidate.yml` job after complete candidate validation. The +bundle is covered by the immutable candidate manifest and lets consumers +verify downloaded files offline against the candidate-builder identity. + ## Issue Triage Automation **Files**: diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 6fb8349ca..626ff160d 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -634,7 +634,9 @@ jobs: timeout-minutes: 60 permissions: actions: read + attestations: write contents: read + id-token: write outputs: artifact_name: ${{ steps.identity.outputs.artifact_name }} manifest_artifact_name: ${{ steps.identity.outputs.manifest_artifact_name }} @@ -817,7 +819,43 @@ jobs: VERSION: ${{ inputs.version }} run: ./scripts/validate-release.sh "${VERSION}" --skip-docker - - name: Create immutable candidate manifest + - name: Create candidate subject manifest + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + python3 scripts/release_candidate_manifest.py create \ + --release-dir release \ + --version "${VERSION}" \ + --source-sha "${GITHUB_SHA}" \ + --output release-candidate-manifest/release-candidate.json + jq -r \ + '.assets[] | "\(.sha256) release/\(.name)"' \ + release-candidate-manifest/release-candidate.json \ + > "$RUNNER_TEMP/release-candidate-subjects.sha256" + + # Generate provenance at the hosted boundary that assembled and validated + # the complete candidate, rather than later in the publication job. Keep + # the Sigstore bundle with the candidate so customers can verify an exact + # downloaded asset without access to GitHub's attestation API. + - name: Attest complete release candidate + id: attest_release_candidate + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-checksums: ${{ runner.temp }}/release-candidate-subjects.sha256 + + - name: Preserve portable build provenance + env: + PROVENANCE_BUNDLE: ${{ steps.attest_release_candidate.outputs.bundle-path }} + run: | + set -euo pipefail + test -s "${PROVENANCE_BUNDLE}" + jq -e 'type == "object"' "${PROVENANCE_BUNDLE}" >/dev/null + install -m 0644 \ + "${PROVENANCE_BUNDLE}" \ + release/release-build-provenance.sigstore.json + + - name: Seal immutable candidate manifest env: VERSION: ${{ inputs.version }} run: | diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 24bd9900d..49b84b087 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -307,7 +307,9 @@ jobs: if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }} permissions: actions: write + attestations: write contents: read + id-token: write uses: ./.github/workflows/build-release-candidate.yml secrets: inherit with: @@ -798,8 +800,6 @@ jobs: timeout-minutes: 30 permissions: contents: write - id-token: write - attestations: write outputs: release_id: ${{ steps.create_release.outputs.release_id }} release_url: ${{ steps.create_release.outputs.release_url }} @@ -839,11 +839,6 @@ jobs: --version "${{ needs.prepare.outputs.version }}" \ --source-sha "${GITHUB_SHA}" - - name: Attest release assets - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-path: release/* - - name: Prepare release notes id: generate_notes run: | @@ -1164,6 +1159,10 @@ jobs: if ls release/*.tgz 1> /dev/null 2>&1; then release_upload_with_retry "${TAG}" release/*.tgz --clobber fi + release_upload_with_retry \ + "${TAG}" \ + release/release-build-provenance.sigstore.json \ + --clobber for bare_agent in \ release/pulse-agent-linux-amd64 \ release/pulse-agent-linux-arm64 \ diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index e54374c14..a2f7524da 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -73,7 +73,9 @@ jobs: if: ${{ inputs.version != '' }} permissions: actions: write + attestations: write contents: read + id-token: write uses: ./.github/workflows/build-release-candidate.yml secrets: inherit with: diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index 594136ceb..d4718aa89 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -73,6 +73,13 @@ Normal stable publication and stable dry runs select `signpath` directly. policy is invalid. - Release checksums and detached signatures are published alongside artifacts and verified independently after publication. +- The GitHub-hosted `build-release-candidate.yml` assembly job emits SLSA v1 + provenance for every candidate file after complete local validation. Its + Sigstore bundle is then added to the immutable candidate as + `release-build-provenance.sigstore.json`; publication cannot replace that + bundle without failing the candidate manifest. This preserves the exact + builder evidence for offline verification instead of recreating provenance + in the later publication job. - The exact-version OCI Helm chart is published only by the hosted `publish-helm-chart.yml` workflow. Its SHA-256 manifest digest and GitHub build-provenance attestation must bind to the release source commit before @@ -84,8 +91,11 @@ Normal stable publication and stable dry runs select `signpath` directly. literal signer-identity matcher fix. The shared `scripts/require-safe-gh-attestation.sh` guard enforces this floor. The published checksum manifest must carry build provenance from the exact - `create-release.yml` workflow and release source commit; repository-level - provenance is not sufficient. + `build-release-candidate.yml` workflow and release source commit; + repository-level provenance is not sufficient. Immutable releases created + before portable candidate bundles remain verified against their original + `create-release.yml` publication provenance. Both paths reject provenance + emitted from a self-hosted runner. - Every new release is assembled and validated as a draft. Its activation marker is uploaded and digest-checked before publication; GitHub must then report the published release as immutable, protecting its tag and complete diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 35c5d703b..964d1cec7 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -26,6 +26,39 @@ See the [Code Signing Policy](CODE_SIGNING_POLICY.md) for build provenance, approval roles, signing scope, and reporting requirements. Release downloads are published on the [GitHub Releases page](https://github.com/rcourtman/Pulse/releases). +## Verify release build provenance + +New release packets include `release-build-provenance.sigstore.json`, the +Sigstore bundle emitted by the hosted workflow that assembled and validated +the candidate. Verify a downloaded asset against that exact workflow and the +release source commit with GitHub CLI 2.97.0 or newer: + +```bash +export PULSE_VERSION=vX.Y.Z +export PULSE_ASSET=pulse-vX.Y.Z-linux-amd64.tar.gz +gh release download "${PULSE_VERSION}" --repo rcourtman/Pulse \ + --pattern "${PULSE_ASSET}" \ + --pattern release-build-provenance.sigstore.json +SOURCE_SHA="$(gh api "repos/rcourtman/Pulse/releases/tags/${PULSE_VERSION}" \ + --jq .target_commitish)" +printf '%s\n' "${SOURCE_SHA}" > release-source-sha.txt +gh attestation verify "${PULSE_ASSET}" \ + --repo rcourtman/Pulse \ + --bundle release-build-provenance.sigstore.json \ + --signer-workflow github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml \ + --source-digest "${SOURCE_SHA}" \ + --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 +``` + +For an offline target, also run `gh attestation trusted-root > +trusted_root.jsonl` on the connected trusted machine and transfer that file +with the asset, bundle, and `release-source-sha.txt`. On the offline target, +restore `SOURCE_SHA="$(cat release-source-sha.txt)"` and add +`--custom-trusted-root trusted_root.jsonl` to the verification command. Refresh +the trusted root whenever importing newly signed material; an old copy cannot +report later key revocation or rotation. + ## 🚀 Quick Start (Recommended) ### Proxmox VE (LXC installer) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 01353781d..497deb44a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -964,6 +964,13 @@ artifact-selection behaviour. unpublished draft until every exact-version customer artifact required by the cut has passed its owned proof. It must then be published and publicly verified before any mutable customer pointer or live environment advances. + After complete local packet validation, the GitHub-hosted candidate assembly + job must issue SLSA v1 provenance over every file represented by the + pre-provenance candidate manifest. It must preserve the resulting portable + Sigstore bundle as `release-build-provenance.sigstore.json` before sealing + the final immutable candidate manifest. The publication job may transport + only that manifest-bound bundle; it must not replace candidate-build + provenance with a later publication-bound attestation. Standard post-upload validation must compare that manifest with GitHub's server-side asset digests instead of downloading the complete release packet again. Historical repair and @@ -4568,6 +4575,12 @@ activation path. Attestation policy decisions require GitHub CLI 2.97.0 or newer so signer repository and workflow names are matched literally. The shared verifier must also bind the downloaded `checksums.txt` bytes to the immutable release and to -SLSA v1 provenance from the exact `create-release.yml` workflow and expected -source SHA. Multi-asset download retries clear both the activation marker and -checksum manifest first so a partial attempt cannot poison every later retry. +SLSA v1 provenance from the exact expected source SHA while rejecting +self-hosted provenance. A manifest-bound +`release-build-provenance.sigstore.json` must itself pass the immutable release +asset proof and must verify `checksums.txt` from the exact +`build-release-candidate.yml` signer using that local bundle. Immutable +historical releases without a portable candidate bundle retain verification +against their original `create-release.yml` publication provenance. Multi-asset +download retries clear the activation marker, checksum manifest, and portable +bundle first so a partial attempt cannot poison every later retry. diff --git a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md index c5619347a..cd83ce9e1 100644 --- a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md +++ b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md @@ -73,6 +73,11 @@ Normal stable publication and stable dry runs select `signpath` directly. policy is invalid. - Release checksums and detached signatures are published alongside artifacts and verified independently after publication. +- The GitHub-hosted `build-release-candidate.yml` assembly job emits SLSA v1 + provenance for every candidate file after complete local validation. Its + portable Sigstore bundle is published as + `release-build-provenance.sigstore.json` and covered by the immutable + candidate manifest. Verification rejects self-hosted provenance. - Every new release is assembled and validated as a draft. Its activation marker is uploaded and digest-checked before publication; GitHub must then report the published release as immutable, protecting its tag and complete diff --git a/frontend-modern/public/docs/INSTALL.md b/frontend-modern/public/docs/INSTALL.md index 35c5d703b..964d1cec7 100644 --- a/frontend-modern/public/docs/INSTALL.md +++ b/frontend-modern/public/docs/INSTALL.md @@ -26,6 +26,39 @@ See the [Code Signing Policy](CODE_SIGNING_POLICY.md) for build provenance, approval roles, signing scope, and reporting requirements. Release downloads are published on the [GitHub Releases page](https://github.com/rcourtman/Pulse/releases). +## Verify release build provenance + +New release packets include `release-build-provenance.sigstore.json`, the +Sigstore bundle emitted by the hosted workflow that assembled and validated +the candidate. Verify a downloaded asset against that exact workflow and the +release source commit with GitHub CLI 2.97.0 or newer: + +```bash +export PULSE_VERSION=vX.Y.Z +export PULSE_ASSET=pulse-vX.Y.Z-linux-amd64.tar.gz +gh release download "${PULSE_VERSION}" --repo rcourtman/Pulse \ + --pattern "${PULSE_ASSET}" \ + --pattern release-build-provenance.sigstore.json +SOURCE_SHA="$(gh api "repos/rcourtman/Pulse/releases/tags/${PULSE_VERSION}" \ + --jq .target_commitish)" +printf '%s\n' "${SOURCE_SHA}" > release-source-sha.txt +gh attestation verify "${PULSE_ASSET}" \ + --repo rcourtman/Pulse \ + --bundle release-build-provenance.sigstore.json \ + --signer-workflow github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml \ + --source-digest "${SOURCE_SHA}" \ + --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 +``` + +For an offline target, also run `gh attestation trusted-root > +trusted_root.jsonl` on the connected trusted machine and transfer that file +with the asset, bundle, and `release-source-sha.txt`. On the offline target, +restore `SOURCE_SHA="$(cat release-source-sha.txt)"` and add +`--custom-trusted-root trusted_root.jsonl` to the verification command. Refresh +the trusted root whenever importing newly signed material; an old copy cannot +report later key revocation or rotation. + ## 🚀 Quick Start (Recommended) ### Proxmox VE (LXC installer) diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 9fbde9e7e..c3f2ce0b5 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -882,8 +882,7 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { `gh release upload "$@"`, `gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s`, `gh release upload failed after ${max_attempts} attempts`, - `uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4`, - `subject-path: release/*`, + `release/release-build-provenance.sigstore.json`, `gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate`, `git push origin "refs/tags/${TAG}" --force`, `--rawfile body "$NOTES_FILE"`, diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 4537029d8..cada48c40 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1377,12 +1377,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertRegex( workflow, r"(?ms)^ build_release_candidate:\n.*?^ permissions:\n" - r" actions: write\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", + r" actions: write\n attestations: write\n contents: read\n" + r" id-token: write\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", ) self.assertRegex( release_workflow, r"(?ms)^ build_release_candidate:\n.*?^ permissions:\n" - r" actions: write\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", + r" actions: write\n attestations: write\n contents: read\n" + r" id-token: write\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", ) self.assertIn("Definitive Dry-Run Verdict", workflow) self.assertIn('require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success', workflow) @@ -1731,10 +1733,27 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("tests/integration/release-integration-diagnostics/", content) self.assertIn("--target runtime_prebuilt", docker_build) self.assertIn("--target agent_runtime_prebuilt", docker_build) - self.assertIn("id-token: write", content) - self.assertIn("attestations: write", content) - self.assertIn("uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", content) - self.assertIn("subject-path: release/*", content) + self.assertIn("id-token: write", candidate_workflow) + self.assertIn("attestations: write", candidate_workflow) + self.assertIn( + "uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4", + candidate_workflow, + ) + self.assertIn( + "subject-checksums: ${{ runner.temp }}/release-candidate-subjects.sha256", + candidate_workflow, + ) + self.assertIn("Preserve portable build provenance", candidate_workflow) + self.assertIn("release-build-provenance.sigstore.json", candidate_workflow) + self.assertLess( + candidate_workflow.index("Validate complete candidate locally"), + candidate_workflow.index("Attest complete release candidate"), + ) + self.assertLess( + candidate_workflow.index("Attest complete release candidate"), + candidate_workflow.index("Seal immutable candidate manifest"), + ) + self.assertIn("release-build-provenance.sigstore.json", content) build_script = read("scripts/build-release.sh") release_asset_helper = read("scripts/release_asset_common.sh") backfill_script = read("scripts/backfill-release-assets.sh") diff --git a/scripts/release_control/verify_github_release_integrity_test.py b/scripts/release_control/verify_github_release_integrity_test.py index e8502b223..b874d0ce8 100644 --- a/scripts/release_control/verify_github_release_integrity_test.py +++ b/scripts/release_control/verify_github_release_integrity_test.py @@ -65,6 +65,10 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): fi printf '%s\\n' '{{"schema_version": 1}}' > "$2/release-activation.json" printf '%s\\n' 'abc pulse-v6.5.0-linux-amd64.tar.gz' > "$2/checksums.txt" + if [ "$HAS_PORTABLE_PROVENANCE" = true ]; then + printf '%s\\n' '{{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json"}}' \ + > "$2/release-build-provenance.sigstore.json" + fi exit 0 fi shift @@ -95,6 +99,13 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): "GH_VERSION": gh_version, "PARTIAL_DOWNLOAD_ONCE": str(partial_download_once).lower(), "DOWNLOAD_STATE": str(root / "download-state"), + "HAS_PORTABLE_PROVENANCE": str( + any( + asset.get("name") + == "release-build-provenance.sigstore.json" + for asset in release.get("assets", []) + ) + ).lower(), } ) result = subprocess.run( @@ -109,8 +120,8 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): return result, call_text @staticmethod - def release(*, immutable: bool = True) -> dict: - return { + def release(*, immutable: bool = True, portable_provenance: bool = False) -> dict: + release = { "id": 123, "tag_name": "v6.5.0", "target_commitish": SOURCE_SHA, @@ -127,6 +138,16 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): } ], } + if portable_provenance: + release["assets"].append( + { + "name": "release-build-provenance.sigstore.json", + "state": "uploaded", + "size": 1200, + "digest": "sha256:" + "c" * 64, + } + ) + return release def test_accepts_immutable_release_with_verified_attestation(self) -> None: result, calls = self.run_verifier(self.release()) @@ -143,8 +164,28 @@ class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): calls, ) self.assertIn(f"--source-digest {SOURCE_SHA}", calls) + self.assertIn("--deny-self-hosted-runners", calls) self.assertIn("--predicate-type https://slsa.dev/provenance/v1", calls) + def test_prefers_portable_candidate_build_provenance(self) -> None: + result, calls = self.run_verifier(self.release(portable_provenance=True)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("portable candidate-build provenance", result.stdout) + self.assertIn( + "--signer-workflow github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml", + calls, + ) + self.assertIn("--bundle ", calls) + self.assertIn("release-build-provenance.sigstore.json", calls) + + def test_rejects_invalid_portable_provenance_asset(self) -> None: + release = self.release(portable_provenance=True) + release["assets"][1]["size"] = 0 + result, calls = self.run_verifier(release) + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid portable provenance asset", result.stderr) + self.assertNotIn("release verify", calls) + def test_rejects_mutable_release_before_attestation(self) -> None: result, calls = self.run_verifier(self.release(immutable=False)) self.assertNotEqual(result.returncode, 0) diff --git a/scripts/verify-github-release-integrity.sh b/scripts/verify-github-release-integrity.sh index e5a6e4f35..9e486bf8e 100755 --- a/scripts/verify-github-release-integrity.sh +++ b/scripts/verify-github-release-integrity.sh @@ -59,7 +59,9 @@ attestation_json="$(mktemp)" activation_dir="$(mktemp -d)" activation_asset="${activation_dir}/release-activation.json" checksums_asset="${activation_dir}/checksums.txt" -SIGNER_WORKFLOW="github.com/${REPO}/.github/workflows/create-release.yml" +provenance_asset="${activation_dir}/release-build-provenance.sigstore.json" +legacy_signer_workflow="github.com/${REPO}/.github/workflows/create-release.yml" +candidate_signer_workflow="github.com/${REPO}/.github/workflows/build-release-candidate.yml" cleanup() { rm -f "$release_json" "$attestation_json" rm -rf "$activation_dir" @@ -97,6 +99,30 @@ if ! jq -e \ exit 1 fi +# New release candidates carry the exact Sigstore bundle created by the hosted +# candidate builder. Existing immutable releases predate that asset and retain +# their publication-workflow provenance, so continuity checks remain valid +# until the next stable packet is activated. +provenance_asset_count="$( + jq '[.assets[]? | select(.name == "release-build-provenance.sigstore.json")] | length' \ + "$release_json" +)" +if [ "$provenance_asset_count" -gt 1 ]; then + echo "GitHub release ${TAG} contains duplicate portable provenance assets." >&2 + exit 1 +fi +if [ "$provenance_asset_count" = 1 ] && ! jq -e \ + '[.assets[]? | select( + .name == "release-build-provenance.sigstore.json" and + .state == "uploaded" and + (.size | type == "number" and . > 0) and + (.digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) + )] | length == 1' \ + "$release_json" >/dev/null; then + echo "GitHub release ${TAG} has an invalid portable provenance asset." >&2 + exit 1 +fi + verified=false for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do if gh release verify "$TAG" --repo "$REPO" --format json > "$attestation_json"; then @@ -124,17 +150,24 @@ fi # release attestation rather than trusting filename and JSON identity alone. downloaded=false for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do - # A previous attempt can leave either asset behind after a partial - # download. Clear both because gh release download refuses to overwrite + # A previous attempt can leave any asset behind after a partial + # download. Clear them because gh release download refuses to overwrite # existing files unless explicitly told to do so. - rm -f "$activation_asset" "$checksums_asset" - if gh release download "$TAG" \ - --repo "$REPO" \ - --pattern release-activation.json \ - --pattern checksums.txt \ - --dir "$activation_dir" && \ + rm -f "$activation_asset" "$checksums_asset" "$provenance_asset" + download_args=( + "$TAG" + --repo "$REPO" + --pattern release-activation.json + --pattern checksums.txt + --dir "$activation_dir" + ) + if [ "$provenance_asset_count" = 1 ]; then + download_args+=(--pattern release-build-provenance.sigstore.json) + fi + if gh release download "${download_args[@]}" && \ [ -s "$activation_asset" ] && \ - [ -s "$checksums_asset" ]; then + [ -s "$checksums_asset" ] && \ + { [ "$provenance_asset_count" = 0 ] || [ -s "$provenance_asset" ]; }; then downloaded=true break fi @@ -174,11 +207,29 @@ if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; echo "GitHub release checksum manifest verification returned malformed JSON for ${TAG}." >&2 exit 1 fi +signer_workflow="$legacy_signer_workflow" +bundle_args=() +if [ "$provenance_asset_count" = 1 ]; then + if ! gh release verify-asset "$TAG" "$provenance_asset" \ + --repo "$REPO" --format json > "$attestation_json"; then + echo "GitHub release portable provenance asset verification failed for ${TAG}." >&2 + exit 1 + fi + if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; then + echo "GitHub release portable provenance asset verification returned malformed JSON for ${TAG}." >&2 + exit 1 + fi + signer_workflow="$candidate_signer_workflow" + bundle_args=(--bundle "$provenance_asset") +fi + if ! gh attestation verify "$checksums_asset" \ --repo "$REPO" \ - --signer-workflow "$SIGNER_WORKFLOW" \ + --signer-workflow "$signer_workflow" \ --source-digest "$EXPECTED_SOURCE_SHA" \ + --deny-self-hosted-runners \ --predicate-type https://slsa.dev/provenance/v1 \ + "${bundle_args[@]}" \ >/dev/null; then echo "Release checksum manifest build provenance verification failed for ${TAG}." >&2 exit 1 @@ -187,4 +238,9 @@ fi release_id="$(jq -r '.id' "$release_json")" source_sha="$(jq -r '.target_commitish' "$release_json")" asset_count="$(jq -r '.assets | length' "$release_json")" -echo "[OK] GitHub release ${TAG} is immutable, release-attested, activation-asset-bound, and build-provenance-bound: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}." +if [ "$provenance_asset_count" = 1 ]; then + provenance_status="portable candidate-build provenance" +else + provenance_status="legacy publication provenance" +fi +echo "[OK] GitHub release ${TAG} is immutable, release-attested, activation-asset-bound, and build-provenance-bound (${provenance_status}): release_id=${release_id} source_sha=${source_sha} assets=${asset_count}."