Publish portable candidate build provenance

This commit is contained in:
pulse-triage[bot]
2026-08-30 20:34:04 +01:00
parent 31a9bcebf5
commit cb079c9de0
13 changed files with 289 additions and 35 deletions
+6
View File
@@ -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**:
+39 -1
View File
@@ -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: |
+6 -7
View File
@@ -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 \
+2
View File
@@ -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:
+12 -2
View File
@@ -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
+33
View File
@@ -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)
@@ -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.
@@ -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
+33
View File
@@ -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)
@@ -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"`,
@@ -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")
@@ -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)
+68 -12
View File
@@ -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}."