mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 00:34:43 +00:00
Bind Helm delivery to release provenance
This commit is contained in:
parent
918d87c47a
commit
f70da05467
10 changed files with 387 additions and 30 deletions
|
|
@ -2687,11 +2687,13 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) {
|
|||
// Chart-version resolver prefers inputs over release-event tag.
|
||||
`if [ -n "${INPUT_CHART_VERSION}" ]; then`,
|
||||
`RELEASE_TAG="${RELEASE_TAG_NAME}"`,
|
||||
`name: Verify public GHCR chart read`,
|
||||
`name: Verify public GHCR chart identity and provenance`,
|
||||
`helm registry logout ghcr.io || true`,
|
||||
`helm show chart`,
|
||||
`oci://ghcr.io/${{ github.repository_owner }}/pulse-chart/pulse`,
|
||||
`--version "${{ steps.versions.outputs.chart_version }}"`,
|
||||
`uses: actions/attest@`,
|
||||
`subject-digest: ${{ steps.push.outputs.chart_digest }}`,
|
||||
`./scripts/verify-release-helm-chart.sh`,
|
||||
`value: ${{ jobs.publish.outputs.chart_digest }}`,
|
||||
`chart_digest: ${{ steps.proof.outputs.chart_digest }}`,
|
||||
)
|
||||
|
||||
content, err := os.ReadFile(workflowPath)
|
||||
|
|
|
|||
|
|
@ -397,9 +397,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
self.assertIn("r2_prefix: $r2_prefix", activation)
|
||||
self.assertIn(".r2_prefix == $r2_prefix", activation)
|
||||
self.assertIn(".r2_prefix == $r2_prefix", convergence)
|
||||
for digest_field in ("server_image_digest", "control_plane_image_digest"):
|
||||
for digest_field in (
|
||||
"server_image_digest",
|
||||
"control_plane_image_digest",
|
||||
"helm_chart_digest",
|
||||
):
|
||||
self.assertIn(digest_field, activation)
|
||||
self.assertIn(digest_field, convergence)
|
||||
self.assertIn(digest_field, recovery_activation)
|
||||
self.assertIn(digest_field, commit_verdict)
|
||||
self.assertIn("committed=true", activation)
|
||||
self.assertIn("Immutably committed, attested, and publicly verified ${TAG}", activation)
|
||||
self.assertIn("Draft activation marker digest does not match", activation)
|
||||
|
|
@ -1951,6 +1957,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
dry_run_workflow = read(".github/workflows/release-dry-run.yml")
|
||||
helm = read(".github/workflows/publish-helm-chart.yml")
|
||||
helm_pages = read(".github/workflows/helm-pages.yml")
|
||||
convergence = read(".github/workflows/release-convergence.yml")
|
||||
artifact_validator = read("scripts/release_control/validate_artifact_release_line.py")
|
||||
chart = read("deploy/helm/pulse/Chart.yaml")
|
||||
chart_sync = read("scripts/sync_chart_release_metadata.py")
|
||||
|
|
@ -2125,11 +2132,23 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
|||
self.assertNotIn("sync_chart_release_metadata.py", helm_pages)
|
||||
self.assertIn("--chart deploy/helm/pulse/Chart.yaml", helm)
|
||||
self.assertIn('git checkout --detach "refs/tags/${RELEASE_TAG}"', helm)
|
||||
self.assertIn("Verify public GHCR chart read", helm)
|
||||
self.assertIn("Verify public GHCR chart identity and provenance", helm)
|
||||
self.assertIn("helm registry logout ghcr.io || true", helm)
|
||||
self.assertIn("helm show chart", helm)
|
||||
self.assertIn("oci://ghcr.io/${{ github.repository_owner }}/pulse-chart/pulse", helm)
|
||||
self.assertIn('--version "${{ steps.versions.outputs.chart_version }}"', helm)
|
||||
self.assertIn("actions/attest@", helm)
|
||||
self.assertIn("verify-release-helm-chart.sh", helm)
|
||||
self.assertIn("subject-digest: ${{ steps.push.outputs.chart_digest }}", helm)
|
||||
self.assertIn("value: ${{ jobs.publish.outputs.chart_digest }}", helm)
|
||||
self.assertIn("chart_digest: ${{ steps.proof.outputs.chart_digest }}", helm)
|
||||
self.assertIn("${GITHUB_SHA} does not match ${RELEASE_TAG}", helm)
|
||||
self.assertIn("verify-release-helm-chart.sh", helm_pages)
|
||||
self.assertRegex(
|
||||
helm_pages,
|
||||
r"(?s)- name: Verify immutable chart identity\n\s+env:\n\s+GH_TOKEN: \$\{\{ github\.token \}\}",
|
||||
)
|
||||
self.assertIn(
|
||||
"chart_digest: ${{ needs.acquire_customer_promotion_lease.outputs.helm_chart_digest }}",
|
||||
convergence,
|
||||
)
|
||||
self.assertNotIn("versions/latest/restore", helm)
|
||||
self.assertNotIn("-f visibility=public", helm)
|
||||
self.assertNotIn("Package visibility configuration attempted", helm)
|
||||
|
|
|
|||
132
scripts/release_control/verify_release_helm_chart_test.py
Normal file
132
scripts/release_control/verify_release_helm_chart_test.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "scripts" / "verify-release-helm-chart.sh"
|
||||
SOURCE_SHA = "a" * 40
|
||||
DIGEST = "sha256:" + "b" * 64
|
||||
|
||||
|
||||
class VerifyReleaseHelmChartTests(unittest.TestCase):
|
||||
def run_verifier(
|
||||
self,
|
||||
*,
|
||||
digest: str = DIGEST,
|
||||
expected_digest: str = "",
|
||||
gh_exit: int = 0,
|
||||
gh_version: str = "2.97.0",
|
||||
source_sha: str = SOURCE_SHA,
|
||||
) -> tuple[subprocess.CompletedProcess[str], str]:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
temp_path = Path(temp)
|
||||
bin_path = temp_path / "bin"
|
||||
bin_path.mkdir()
|
||||
gh_log = temp_path / "gh.log"
|
||||
(bin_path / "helm").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/bin/sh
|
||||
printf 'Pulled: ghcr.io/rcourtman/pulse-chart/pulse:6.4.1\n'
|
||||
printf 'Digest: %s\n' "$CHART_DIGEST"
|
||||
: > "$6/pulse-6.4.1.tgz"
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bin_path / "gh").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/bin/sh
|
||||
if [ "$1" = version ]; then
|
||||
printf 'gh version %s (test)\n' "$GH_VERSION"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$*" >> "$GH_LOG"
|
||||
exit "$GH_EXIT"
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
for command in (bin_path / "helm", bin_path / "gh"):
|
||||
command.chmod(0o755)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"PATH": f"{bin_path}:{env['PATH']}",
|
||||
"CHART_DIGEST": digest,
|
||||
"GH_LOG": str(gh_log),
|
||||
"GH_EXIT": str(gh_exit),
|
||||
"GH_VERSION": gh_version,
|
||||
}
|
||||
)
|
||||
args = [str(SCRIPT), "v6.4.1", source_sha, "rcourtman/Pulse"]
|
||||
if expected_digest:
|
||||
args.append(expected_digest)
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result, gh_log.read_text(encoding="utf-8") if gh_log.exists() else ""
|
||||
|
||||
def test_emits_digest_after_exact_hosted_provenance_verification(self) -> None:
|
||||
result, calls = self.run_verifier()
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), f"chart_digest={DIGEST}")
|
||||
self.assertIn(f"oci://ghcr.io/rcourtman/pulse-chart/pulse@{DIGEST}", calls)
|
||||
self.assertIn("--repo rcourtman/Pulse", calls)
|
||||
self.assertIn("--bundle-from-oci", calls)
|
||||
self.assertIn(
|
||||
"--signer-workflow github.com/rcourtman/Pulse/.github/workflows/publish-helm-chart.yml",
|
||||
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_rejects_a_chart_that_moved_after_activation(self) -> None:
|
||||
expected = "sha256:" + "c" * 64
|
||||
result, calls = self.run_verifier(expected_digest=expected)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Exact-version Helm chart moved", result.stderr)
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
def test_rejects_missing_or_malformed_registry_digest(self) -> None:
|
||||
result, calls = self.run_verifier(digest="unknown")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("did not report one exact OCI digest", result.stderr)
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
def test_rejects_unsafe_github_cli_before_registry_resolution(self) -> None:
|
||||
result, calls = self.run_verifier(gh_version="2.96.1")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("too old for release attestation policy enforcement", result.stderr)
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
def test_rejects_invalid_source_identity(self) -> None:
|
||||
result, calls = self.run_verifier(source_sha="main")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Invalid release source SHA", result.stderr)
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
90
scripts/verify-release-helm-chart.sh
Executable file
90
scripts/verify-release-helm-chart.sh
Executable file
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Resolve the exact-version OCI Helm chart to its registry digest and verify
|
||||
# that GitHub's signed build provenance binds it to the release source and the
|
||||
# canonical hosted chart publisher.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then
|
||||
echo "Usage: $0 <tag> <source-sha> [owner/repo] [expected-digest]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
TAG="$1"
|
||||
SOURCE_SHA="$2"
|
||||
REPOSITORY="${3:-${GITHUB_REPOSITORY:-rcourtman/Pulse}}"
|
||||
EXPECTED_DIGEST="${4:-}"
|
||||
OWNER="${REPOSITORY%%/*}"
|
||||
VERSION="${TAG#v}"
|
||||
SUBJECT="ghcr.io/${OWNER}/pulse-chart/pulse"
|
||||
SIGNER_WORKFLOW="github.com/${REPOSITORY}/.github/workflows/publish-helm-chart.yml"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then
|
||||
echo "Invalid release tag: ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid release source SHA: ${SOURCE_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "Invalid GitHub repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_DIGEST" ] && \
|
||||
[[ ! "$EXPECTED_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "Invalid expected Helm chart digest: ${EXPECTED_DIGEST}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in gh helm; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "${command} is required to verify the release Helm chart." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
"${SCRIPT_DIR}/require-safe-gh-attestation.sh"
|
||||
|
||||
pull_dir="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$pull_dir"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
pull_output="$(
|
||||
helm pull "oci://${SUBJECT}" \
|
||||
--version "$VERSION" \
|
||||
--destination "$pull_dir" 2>&1
|
||||
)" || {
|
||||
echo "$pull_output" >&2
|
||||
echo "Unable to resolve the exact-version Helm chart ${SUBJECT}:${VERSION}." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$pull_output" >&2
|
||||
|
||||
mapfile -t resolved_digests < <(
|
||||
sed -nE 's/^Digest:[[:space:]]*(sha256:[0-9a-f]{64})\r?$/\1/p' \
|
||||
<<<"$pull_output" | sort -u
|
||||
)
|
||||
if [ "${#resolved_digests[@]}" -ne 1 ]; then
|
||||
echo "Helm did not report one exact OCI digest for ${SUBJECT}:${VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
chart_digest="${resolved_digests[0]}"
|
||||
if [ -n "$EXPECTED_DIGEST" ] && [ "$chart_digest" != "$EXPECTED_DIGEST" ]; then
|
||||
echo "Exact-version Helm chart moved: expected ${EXPECTED_DIGEST}, observed ${chart_digest}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh attestation verify "oci://${SUBJECT}@${chart_digest}" \
|
||||
--repo "$REPOSITORY" \
|
||||
--bundle-from-oci \
|
||||
--signer-workflow "$SIGNER_WORKFLOW" \
|
||||
--source-digest "$SOURCE_SHA" \
|
||||
--deny-self-hosted-runners \
|
||||
--predicate-type https://slsa.dev/provenance/v1 \
|
||||
>/dev/null
|
||||
|
||||
printf 'chart_digest=%s\n' "$chart_digest"
|
||||
echo "[OK] Helm chart ${VERSION} and hosted build provenance resolve to ${chart_digest}." >&2
|
||||
Loading…
Add table
Add a link
Reference in a new issue