From 7c373a51623a254020b01d713227fa304eb4c849 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:53:21 +0100 Subject: [PATCH] fix(release): reject draft Helm chart publication retries Reproduce draft and unknown publication states reaching the Pages index boundary. Require an explicitly non-draft existing release before uploading, editing or advertising its chart, without implicitly publishing operator drafts. Exercise the actual publication shell with a fake GitHub CLI and wire its seven retry tests into canonical governance. Existing digest and maturity behaviour remains covered. Change-source: pulse-maintainer --- .github/workflows/canonical-governance.yml | 3 + .github/workflows/helm-pages.yml | 13 ++- .gitignore | 1 + .../subsystems/deployment-installability.md | 9 ++ .../release_control/helm_pages_retry_test.py | 110 ++++++++++++++++++ .../release_promotion_policy_test.py | 15 +++ 6 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 scripts/release_control/helm_pages_retry_test.py diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 3cf40e206..829db2bab 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -188,6 +188,9 @@ jobs: - name: Run stable release continuity diagnostic unit tests run: python3 scripts/release_control/release_continuity_test.py + - name: Run Helm Pages publication retry tests + run: python3 scripts/release_control/helm_pages_retry_test.py + - name: Run status audit unit tests run: python3 scripts/release_control/status_audit_test.py diff --git a/.github/workflows/helm-pages.yml b/.github/workflows/helm-pages.yml index 6a8cb1423..970dfd3ce 100644 --- a/.github/workflows/helm-pages.yml +++ b/.github/workflows/helm-pages.yml @@ -200,8 +200,17 @@ jobs: fi local_digest="sha256:$(sha256sum "${chart_path}" | cut -d ' ' -f 1)" - if existing_prerelease="$(gh release view "${chart_release}" --repo "${GITHUB_REPOSITORY}" \ - --json isPrerelease --jq '.isPrerelease' 2>/dev/null)"; then + if existing_state="$(gh release view "${chart_release}" --repo "${GITHUB_REPOSITORY}" \ + --json isPrerelease,isDraft --jq '[.isPrerelease, .isDraft] | @tsv' 2>/dev/null)"; then + existing_prerelease="$(awk -F '\t' '{print $1}' <<<"${existing_state}")" + existing_draft="$(awk -F '\t' '{print $2}' <<<"${existing_state}")" + # A draft asset is visible to this authenticated workflow but not + # to Helm consumers. Do not turn an interrupted publication into + # a public index entry, or implicitly publish an operator's draft. + if [[ "${existing_draft}" != "false" ]]; then + echo "::error::Chart release ${chart_release} is not confirmed published; refusing to modify it or advertise it through Pages." + exit 1 + fi # Immutable releases refuse asset replacement, so a convergence # retry must recognise the exact chart it already published # rather than clobber it (v6.4.3-rc.1 retry, 2026-09-02). The diff --git a/.gitignore b/.gitignore index 99d5297ef..65b5b0b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -241,6 +241,7 @@ scripts/release_control/* !scripts/release_control/format_staged_go_test.py !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/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 481dd5339..daf9a4e82 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1140,6 +1140,15 @@ artifact-selection behaviour. and qualified by the exact create-release run. It must bind that artifact to the activated source run, tag, commit, and activation marker, and must not repeat chart packaging or the pre-activation kind install/upgrade smoke. + An existing chart release must explicitly report a non-draft state before + convergence uploads assets, edits metadata, or advertises it through Pages. + Matching assets visible to the authenticated workflow do not make a draft + publicly downloadable. Draft or unknown publication state fails closed + without implicitly publishing an operator-owned draft. Published matching + assets remain reusable without replacement. The executable retry fixtures + in `scripts/release_control/helm_pages_retry_test.py` and the release-policy + test `test_helm_pages_retry_requires_explicitly_published_chart` protect this + boundary; they are not live publication or installed Helm qualification. Release-to-convergence and cross-repository child-run observation should use short bounded polls so GitHub indexing cannot add tens of seconds after a required exact run or activation marker has already completed. diff --git a/scripts/release_control/helm_pages_retry_test.py b/scripts/release_control/helm_pages_retry_test.py new file mode 100644 index 000000000..4ccfb78f0 --- /dev/null +++ b/scripts/release_control/helm_pages_retry_test.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Execute the chart publication shell with a fake GitHub CLI; no network/writes.""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +CHART = b"qualified chart fixture" +DIGEST = "sha256:" + hashlib.sha256(CHART).hexdigest() + + +class HelmPagesRetryTests(unittest.TestCase): + def run_publication(self, *, draft=False, digest=DIGEST, prerelease=False, + version="6.4.3", exists=True): + workflow = (ROOT / ".github/workflows/helm-pages.yml").read_text() + step = workflow.split(" - name: Publish chart release and merge Pages index\n", 1)[1] + script = textwrap.dedent(step.split(" run: |\n", 1)[1].split( + " git -C gh-pages config", 1)[0]) + script = script.replace("${{ github.repository }}", "test/pulse") + script += '\nprintf "PAGES_BOUNDARY\\n"\n' + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / "dist").mkdir() + (root / "dist" / f"pulse-{version}.tgz").write_bytes(CHART) + fake = root / "gh" + fake.write_text(textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, subprocess, sys + args = sys.argv[1:] + with open("calls.jsonl", "a") as log: + log.write(json.dumps(args) + "\\n") + if args[:2] == ["release", "view"]: + if os.environ["EXISTS"] == "false": + sys.exit(1) + payload = {"isDraft": json.loads(os.environ["DRAFT"]), + "isPrerelease": json.loads(os.environ["PRERELEASE"])} + elif args[0] == "api": + payload = {"assets": [{"name": "pulse-" + os.environ["VERSION"] + ".tgz", + "digest": os.environ["DIGEST"]}]} + else: + sys.exit(0) + result = subprocess.run(["jq", "-r", args[args.index("--jq") + 1]], + input=json.dumps(payload), text=True) + sys.exit(result.returncode) + ''')) + fake.chmod(0o755) + env = {"PATH": f"{root}:{os.environ['PATH']}", "VERSION": version, + "GITHUB_REPOSITORY": "test/pulse", "TARGET_COMMITISH": "a" * 40, + "DRAFT": json.dumps(draft), "PRERELEASE": json.dumps(prerelease), + "DIGEST": digest, "EXISTS": json.dumps(exists)} + result = subprocess.run(["bash", "-c", script], cwd=root, env=env, + capture_output=True, text=True) + calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()] + return result, calls + + def test_draft_with_matching_asset_cannot_reach_pages(self): + result, calls = self.run_publication(draft=True) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("PAGES_BOUNDARY", result.stdout) + self.assertFalse(any(c[:2] in (["release", "edit"], ["release", "upload"], + ["release", "create"]) for c in calls)) + + def test_draft_without_asset_is_not_modified(self): + result, calls = self.run_publication(draft=True, digest="") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(calls), 1) + + def test_unknown_draft_state_fails_closed(self): + result, _ = self.run_publication(draft=None) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("PAGES_BOUNDARY", result.stdout) + + def test_published_matching_chart_retry_is_read_only(self): + result, calls = self.run_publication() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PAGES_BOUNDARY", result.stdout) + self.assertEqual(len(calls), 2) + + def test_published_mismatched_chart_is_not_replaced(self): + result, calls = self.run_publication(digest="sha256:" + "c" * 64) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(calls), 2) + self.assertNotIn("PAGES_BOUNDARY", result.stdout) + + def test_published_stable_classification_is_corrected(self): + result, calls = self.run_publication(prerelease=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls[-1][:2], ["release", "edit"]) + self.assertIn("--prerelease=false", calls[-1]) + self.assertIn("--latest=false", calls[-1]) + + def test_new_stable_and_preview_classification(self): + for version, expected in (("6.4.3", "false"), ("6.4.3-rc.2", "true")): + with self.subTest(version=version): + result, calls = self.run_publication(exists=False, version=version) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls[-1][:2], ["release", "create"]) + self.assertIn(f"--prerelease={expected}", calls[-1]) + self.assertIn("--latest=false", calls[-1]) + + +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 0bb237430..9509cd3b2 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -16,6 +16,7 @@ import yaml from yaml.constructor import ConstructorError import record_rc_to_ga_blocked as blocked_record +import helm_pages_retry_test from live_runtime_proof import evaluate_live_runtime from release_promotion_policy_support import ( REQUIRED_STAGED_GOVERNANCE_INPUTS, @@ -581,6 +582,20 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("--json isDraft,publishedAt,tagName", floating_tags) self.assertIn("Floating-tag promotion refuses inactive release", floating_tags) + def test_helm_pages_retry_requires_explicitly_published_chart(self) -> None: + runner = helm_pages_retry_test.HelmPagesRetryTests() + for draft, digest in ((True, helm_pages_retry_test.DIGEST), (True, ""), + (None, helm_pages_retry_test.DIGEST)): + with self.subTest(draft=draft, digest=digest): + result, calls = runner.run_publication(draft=draft, digest=digest) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("PAGES_BOUNDARY", result.stdout) + self.assertEqual(len(calls), 1, "draft refusal must precede any mutation") + result, calls = runner.run_publication(draft=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("PAGES_BOUNDARY", result.stdout) + self.assertEqual(len(calls), 2, "matching published chart retry is read-only") + def test_each_post_commit_surface_failure_is_retriable_convergence_debt(self) -> None: release_workflow = read(".github/workflows/create-release.yml") convergence = read(".github/workflows/release-convergence.yml")