diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 829db2bab..3455567fc 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -191,6 +191,9 @@ jobs: - name: Run Helm Pages publication retry tests run: python3 scripts/release_control/helm_pages_retry_test.py + - name: Helm publication version binding + run: python3 scripts/release_control/helm_publish_version_test.py + - name: Run status audit unit tests run: python3 scripts/release_control/status_audit_test.py diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index b73ffdc35..9b785a382 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -22,7 +22,7 @@ on: required: true type: string app_version: - description: "Application version to embed (defaults to chart version)." + description: "Application version (must equal chart version; defaults to it)." required: false type: string default: "" @@ -36,7 +36,7 @@ on: description: "Chart version (required when running manually, use format 4.24.0)" required: true app_version: - description: "Application version to embed (defaults to chart version)" + description: "Application version (must equal chart version; defaults to it)" required: false permissions: @@ -90,6 +90,13 @@ jobs: APP_VERSION="$CHART_VERSION" fi + # Pulse's server and agent image defaults use Chart.AppVersion. + # Source provenance alone cannot detect a mismatched image override. + if [ "$APP_VERSION" != "$CHART_VERSION" ]; then + echo "::error::Release chart app_version must equal chart_version." + exit 1 + fi + IS_PRERELEASE="false" if [[ "$APP_VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$APP_VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$APP_VERSION" =~ -beta\.[0-9]+$ ]]; then IS_PRERELEASE="true" diff --git a/.gitignore b/.gitignore index 65b5b0b1f..002e47b3b 100644 --- a/.gitignore +++ b/.gitignore @@ -242,6 +242,7 @@ scripts/release_control/* !scripts/release_control/governance_stage_guard.py !scripts/release_control/governance_stage_guard_test.py !scripts/release_control/helm_pages_retry_test.py +!scripts/release_control/helm_publish_version_test.py !scripts/release_control/live_runtime_proof.py !scripts/release_control/live_runtime_proof_test.py !scripts/release_control/mobile_relay_auth_approvals_proof.py diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index daf9a4e82..a5c18c6ec 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -5168,3 +5168,20 @@ passed. A read-only probe of job 101235205647 retained the expected failure text without ESC bytes. Private containment classification and successful scheduled reconciliation still require post-integration evidence; this is not customer convergence or release qualification. + +### 2026-09-05 — Bind hosted chart application version to release version + +The hosted Helm publisher rejects a supplied application version that differs +from the chart version before emitting version outputs or packaging. Both server +and agent image defaults use Chart.AppVersion, so an exact source SHA and chart +digest alone do not prevent an override from selecting another release's images. +The normal release caller already supplies equal versions; default and release +event paths remain unchanged. This does not remove users' image value overrides. + +Verification: helm_publish_version_test.py executes the actual version-resolution +shell. Four mismatches failed assertions before the fix and are rejected after it; +stable/alpha/beta/RC equal and default versions and release-event defaults pass. +All 4 tests, 7 Helm Pages retry tests and 47 promotion-policy tests pass locally. +No hosted publication or installed-image qualification is claimed. +External reference retrieved 2026-09-05: https://helm.sh/docs/topics/charts/#the-appversion-field +explains that application version is separate from chart version. diff --git a/scripts/release_control/helm_publish_version_test.py b/scripts/release_control/helm_publish_version_test.py new file mode 100644 index 000000000..dc1b4f6f6 --- /dev/null +++ b/scripts/release_control/helm_publish_version_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Exercise hosted Helm version resolution without packaging or publication.""" +from pathlib import Path +import os +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class HelmPublishVersionTests(unittest.TestCase): + def resolve(self, chart="", app="", tag=""): + workflow = (ROOT / ".github/workflows/publish-helm-chart.yml").read_text() + step = workflow.split(" - name: Determine chart version\n", 1)[1] + script = textwrap.dedent(step.split(" run: |\n", 1)[1].split( + " - name:", 1)[0]) + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "output" + result = subprocess.run( + ["bash", "-euo", "pipefail", "-c", script], cwd=ROOT, + env={"PATH": os.environ["PATH"], "INPUT_CHART_VERSION": chart, + "INPUT_APP_VERSION": app, "RELEASE_TAG_NAME": tag, + "GITHUB_OUTPUT": str(output)}, + capture_output=True, text=True) + return result, output.read_text() if output.exists() else "" + + def test_matching_and_default_application_versions(self): + for version in ("6.4.3", "6.4.3-rc.2", "6.5.0-beta.1", "6.5.0-alpha.1"): + for app in ("", version): + with self.subTest(version=version, app=app): + result, output = self.resolve(version, app) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("\n" + version + "\n", output) + self.assertIn("is_prerelease=" + ("true" if "-" in version else "false"), output) + + def test_release_event_uses_tag(self): + result, output = self.resolve(tag="v6.4.3-rc.2") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("\n6.4.3-rc.2\n", output) + + def test_mismatched_application_rejected_before_outputs(self): + for chart, app in (("6.4.3", "6.4.2"), ("6.4.3", "6.4.3-rc.2"), + ("6.4.3-rc.2", "6.4.3"), ("6.4.3", "latest")): + with self.subTest(chart=chart, app=app): + result, output = self.resolve(chart, app) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + def test_missing_version_rejected(self): + result, output = self.resolve() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 9509cd3b2..35ce7ca0f 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2434,6 +2434,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("sync_chart_release_metadata.py", helm) self.assertNotIn("sync_chart_release_metadata.py", helm_pages) self.assertIn("--chart deploy/helm/pulse/Chart.yaml", helm) + version_guard = 'if [ "$APP_VERSION" != "$CHART_VERSION" ]; then' + self.assertIn(version_guard, helm) + self.assertLess(helm.index(version_guard), helm.index(" - name: Package chart")) + self.assertIn( + "python3 scripts/release_control/helm_publish_version_test.py", + (Path(__file__).resolve().parents[2] / ".github/workflows/canonical-governance.yml").read_text(), + ) self.assertIn('git checkout --detach "refs/tags/${RELEASE_TAG}"', helm) self.assertIn("Verify public GHCR chart identity and provenance", helm) self.assertIn("helm registry logout ghcr.io || true", helm)