diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f5bd3fda5..4bead5286 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -9,6 +9,10 @@ on: description: 'Version number (e.g., 4.30.0)' required: true type: string + expected_source_sha: + description: 'Exact 40-character commit SHA admitted for this release' + required: true + type: string release_notes: description: 'Release notes (markdown)' required: true @@ -108,6 +112,22 @@ jobs: visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }} visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }} steps: + - name: Verify admitted source commit + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + run: | + set -euo pipefail + if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected_source_sha must be an exact 40-character commit SHA" + exit 1 + fi + if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \ + "${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then + echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}." + exit 1 + fi + echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}" + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 49525a70b..b6e71af38 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2168,6 +2168,13 @@ artifact-selection behaviour. trigger, promotion resolver, rendered release body, current upgrade guide, or current release packet that routes systemd/LXC rollback through the Unified Agent installer, and must retain explicit Docker image guidance. +17. Bind every publishing release dispatch to the exact commit admitted by the + caller. `.github/workflows/create-release.yml` must require a full + 40-character `expected_source_sha` and, before checkout, reject the run + unless both `GITHUB_SHA` and `GITHUB_WORKFLOW_SHA` equal that commit. + `scripts/trigger-release.sh` and `scripts/trigger-stable-patch.sh` must send + the exact remote candidate SHA they already verified; branch ancestry or a + later branch tip is not equivalent release admission. ## Current State diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 521db80c8..f72ce74a9 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -933,6 +933,10 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { convergenceWorkflow := string(convergenceContent) required := []string{ `historical_asset_backfill_only:`, + `expected_source_sha:`, + `EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}`, + `"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"`, + `"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"`, `description: 'Repair an already-published release packet in place without rebuilding binaries'`, `SYFT_VERSION="1.42.4"`, `SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`, diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 8899c3918..216cc4598 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1595,6 +1595,31 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("did not produce a valid promotion metadata envelope", workflow) self.assertIn("Do not use this artifact to clear", workflow) + def test_release_dispatch_rejects_source_or_workflow_drift_before_checkout(self) -> None: + workflow = yaml.safe_load(read(".github/workflows/create-release.yml")) + steps = workflow["jobs"]["prepare"]["steps"] + self.assertEqual(steps[0]["name"], "Verify admitted source commit") + self.assertIn("actions/checkout@", steps[1]["uses"]) + expected = "a" * 40 + other = "b" * 40 + for admitted, source, workflow_sha, succeeds in ( + (expected, expected, expected, True), + (expected, other, expected, False), + (expected, expected, other, False), + (expected, other, other, False), + ("", expected, expected, False), + ("main", expected, expected, False), + ("a" * 39, expected, expected, False), + ): + with self.subTest(admitted=admitted, source=source, workflow=workflow_sha): + result = subprocess.run( + ["bash", "-c", steps[0]["run"]], + env={"PATH": os.defpath, "EXPECTED_SOURCE_SHA": admitted, + "GITHUB_SHA": source, "GITHUB_WORKFLOW_SHA": workflow_sha}, + capture_output=True, text=True, check=False, + ) + self.assertEqual(result.returncode == 0, succeeds, result.stdout + result.stderr) + def test_release_workflow_enforces_rc_lineage_soak_and_v5_notice(self) -> None: content = read(".github/workflows/create-release.yml") update_demo_workflow = read(".github/workflows/update-demo-server.yml") @@ -1694,6 +1719,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("build_rollback_section", renderer) self.assertIn("promotion metadata out of customer notes", renderer) self.assertIn("historical_asset_backfill_only:", content) + self.assertIn("expected_source_sha:", content) + self.assertIn('EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}', content) + self.assertIn('"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"', content) + self.assertIn('"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"', content) self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content) self.assertIn("draft: true", content) self.assertIn("activate_release:", content) diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 0d6acd7f4..79a901e79 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -120,6 +120,7 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ --require version \ + --require expected_source_sha \ --require release_notes \ --require release_screenshot_plan \ --require promoted_from_tag \ @@ -372,6 +373,7 @@ echo "Triggering release workflow..." if [ -n "$NOTES_FILE" ]; then jq -n \ --arg version "$VERSION" \ + --arg expected_source_sha "$LOCAL" \ --rawfile release_notes "$NOTES_FILE" \ --rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \ --arg rollback_version "$ROLLBACK_VERSION" \ @@ -387,6 +389,7 @@ if [ -n "$NOTES_FILE" ]; then --arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \ '{ version: $version, + expected_source_sha: $expected_source_sha, release_notes: $release_notes, release_screenshot_plan: $release_screenshot_plan, rollback_version: $rollback_version, diff --git a/scripts/trigger-stable-patch.sh b/scripts/trigger-stable-patch.sh index c4f759894..efe146cd8 100755 --- a/scripts/trigger-stable-patch.sh +++ b/scripts/trigger-stable-patch.sh @@ -203,6 +203,7 @@ else --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ --require version \ + --require expected_source_sha \ --require release_notes \ --require release_screenshot_plan \ --require promoted_from_tag \ @@ -219,6 +220,7 @@ else jq -n \ --arg version "$VERSION" \ + --arg expected_source_sha "$LOCAL_SHA" \ --rawfile release_notes "$NOTES_FILE" \ --rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \ --arg promoted_from_tag "" \ @@ -234,6 +236,7 @@ else --arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \ '{ version: $version, + expected_source_sha: $expected_source_sha, release_notes: $release_notes, release_screenshot_plan: $release_screenshot_plan, promoted_from_tag: $promoted_from_tag,