From 4e23d79de43d4dc46e73f9af5db22a703703bb18 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 9 Oct 2026 01:37:59 -0400 Subject: [PATCH 1/2] fix(release): let the first bundle release pass without an upgrade baseline No stable release has shipped signed packages yet. Every final tag so far skipped bundles or predates the claim pipeline. So releases/stable/release-candidates.json does not exist, and the three "Pin actual OLD and NEW signed packages" jobs refused with "No published stable package baseline". transitions now treats a 404 as a first release only when no final stable tag records a candidate manifest and no baseline-manifest input was given. It still validates the receipt, emits empty matrices and baseline=none. The packaged upgrade arms skip on that output. The gate accepts their skip only when their own planner succeeded with baseline=none, so a missing baseline after a real bundle release still blocks. --- .github/workflows/stable-release.yml | 9 ++++ scripts/releases/stable.py | 50 +++++++++++++++++-- tests/scripts/test_stable_release.py | 39 +++++++++++++++ .../docs/developer-guide/stable-releases.md | 3 ++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index e8cbcd24b4..13ed2158a1 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -279,6 +279,7 @@ jobs: outputs: windows: ${{ steps.plan.outputs.windows }} macos: ${{ steps.plan.outputs.macos }} + baseline: ${{ steps.plan.outputs.baseline }} steps: - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 with: @@ -313,6 +314,7 @@ jobs: outputs: windows: ${{ steps.plan.outputs.windows }} macos: ${{ steps.plan.outputs.macos }} + baseline: ${{ steps.plan.outputs.baseline }} steps: - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 with: @@ -347,6 +349,7 @@ jobs: outputs: windows: ${{ steps.plan.outputs.windows }} macos: ${{ steps.plan.outputs.macos }} + baseline: ${{ steps.plan.outputs.baseline }} steps: - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1 with: @@ -412,6 +415,8 @@ jobs: windows-packaged: name: Windows signed-package acceptance needs: transitions-win32 + # The first bundle release has no OLD package to upgrade from. + if: needs.transitions-win32.outputs.baseline != 'none' strategy: fail-fast: false matrix: ${{ fromJSON(needs.transitions-win32.outputs.windows) }} @@ -428,6 +433,8 @@ jobs: macos-packaged-arm64: name: macOS arm64 signed-package acceptance needs: transitions-darwin-arm64 + # The first bundle release has no OLD package to upgrade from. + if: needs.transitions-darwin-arm64.outputs.baseline != 'none' strategy: fail-fast: false matrix: ${{ fromJSON(needs.transitions-darwin-arm64.outputs.macos) }} @@ -444,6 +451,8 @@ jobs: macos-packaged-x64: name: macOS x64 signed-package acceptance needs: transitions-darwin-x64 + # The first bundle release has no OLD package to upgrade from. + if: needs.transitions-darwin-x64.outputs.baseline != 'none' strategy: fail-fast: false matrix: ${{ fromJSON(needs.transitions-darwin-x64.outputs.macos) }} diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index 9df21127c0..b9433235ef 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -83,6 +83,12 @@ SKIPPED_BY = { } +# Each signed-package upgrade arm and the transitions job that plans it. +PACKAGED_BY = {"macos-packaged-arm64": "transitions-darwin-arm64", + "macos-packaged-x64": "transitions-darwin-x64", + "windows-packaged": "transitions-win32"} + + def gate_expectations(required: list[str], *, skip_bundles: bool, skip_tests: bool) -> dict: """Each gated job's required result under the claim's flags.""" active = {flag for flag, on in (("skipBundles", skip_bundles), ("skipTests", skip_tests)) if on} @@ -95,6 +101,13 @@ def require_gate(needs: dict, required: list[str], *, skip_bundles: bool, skip_t if not required or len(set(required)) != len(required): raise ValueError("Invalid required-job list") expected = gate_expectations(required, skip_bundles=skip_bundles, skip_tests=skip_tests) + for packaged, planner in PACKAGED_BY.items(): + # The first bundle release has no OLD package, so its planner skips the + # upgrade arm. Only that planner's own successful output can excuse it. + planned = needs.get(planner, {}) + if (expected.get(packaged) == "success" and planned.get("result") == "success" + and (planned.get("outputs") or {}).get("baseline") == "none"): + expected[packaged] = "skipped" failures = [f"{name}={needs.get(name, {}).get('result', 'missing')} (expected {want})" for name, want in expected.items() if needs.get(name, {}).get("result") != want] if failures: @@ -640,14 +653,36 @@ def _stage_transition(env: dict, archive: str, base: str, row: dict) -> dict: "manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()} -def _published_baseline(env: dict, base: str) -> dict: +def _bundle_release_shipped() -> bool: + """True when any final stable tag records a published candidate manifest. + + Publication writes the stable baseline pointer for exactly these releases. + Tags from before the claim pipeline carry prose, not a record, and shipped + no signed packages this pipeline can upgrade from. + """ + for tag in output(["git", "tag", "-l", "v*"]).split(): + if not STABLE_TAG_RE.fullmatch(tag): + continue + try: + record = tag_record(output(["git", "tag", "-l", tag, "--format=%(contents)"])) + except json.JSONDecodeError: + continue + if isinstance(record, dict) and record.get("candidateManifestSha256") is not None: + return True + return False + + +def _published_baseline(env: dict, base: str) -> dict | None: + """The previous stable package manifest, or None for the first bundle release.""" try: previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json", expected_origin=base) except urllib.error.HTTPError as error: - if error.code == 404: + if error.code != 404: + raise + if env.get("BASELINE_MANIFEST_URL") or _bundle_release_shipped(): raise ValueError("No published stable package baseline. Supply baseline-manifest for an actual previous stable release; acceptance cannot be skipped.") from error - raise + return None published = json.loads(output(["gh", "release", "view", previous["tag"], "--repo", env["GITHUB_REPOSITORY"], "--json", "tagName,isDraft,isPrerelease"])) if published["tagName"] != previous["tag"] or published["isDraft"] or published["isPrerelease"]: raise ValueError("Upgrade baseline must be a published stable release") @@ -677,10 +712,17 @@ def transitions(env: dict) -> None: previous = _published_baseline(env, base) receipt_manifest = _receipt_from_env(env, receipt, "RECEIPT", base) matrices = {"windows": {"include": []}, "macos": {"include": []}} + if previous is None: + # Native smokes still gate this release. Its packages become the + # baseline that the next release must upgrade from. + validate_receipt(receipt_manifest, receipt, receipt_manifest.get("tag"), + receipt_manifest.get("commit"), base, archive=receipt_manifest.get("archive")) + emit({**matrices, "baseline": "none"}, env) + return for row in plan_receipt_transitions(previous, receipt_manifest, receipt, base): matrices[row["transition"]["platform"]]["include"].append( _stage_transition(env, archive, base, row)) - emit(matrices, env) + emit({**matrices, "baseline": "published"}, env) # The candidate manifest is written after the smokes (decision 23): the smoke diff --git a/tests/scripts/test_stable_release.py b/tests/scripts/test_stable_release.py index 1060d7df1a..e437d111f0 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -937,6 +937,45 @@ def test_transitions_from_the_bundle_receipt_emit_two_windows_rows(tmp_path, r2_ assert macos["include"] == [] +def test_first_bundle_release_plans_no_upgrade_arm_until_a_bundle_release_shipped( + tmp_path, r2_server, https_origin, monkeypatch): + from scripts.releases import stable + + url, digest = _staged_receipt(tmp_path, r2_server, https_origin, monkeypatch, "darwin-arm64") + env = {**_transitions_env(tmp_path, https_origin.base, "darwin-arm64", url, digest), + "BASELINE_MANIFEST_URL": ""} + # The latest final tag skipped bundles; an older tag predates the claim pipeline. + tags = {"v1.2.2": json.dumps({"candidateManifestSha256": None}), "v1.2.1": "Weekly release"} + + def git(argv): + return " ".join(tags) if argv[3] == "v*" else tags[argv[3]] + + monkeypatch.setattr(stable, "output", git) + stable.main(["transitions"], env) + emitted = dict(line.split("=", 1) + for line in (tmp_path / "output").read_text(encoding="utf-8").splitlines()) + assert emitted["baseline"] == "none" + assert json.loads(emitted["macos"]) == json.loads(emitted["windows"]) == {"include": []} + tags["v1.2.2"] = json.dumps({"candidateManifestSha256": "3" * 64}) + with pytest.raises(ValueError, match="No published stable package baseline"): + stable.main(["transitions"], env) + + +def test_gate_excuses_an_upgrade_arm_only_for_its_planners_first_release_output(): + from scripts.releases.stable import PACKAGED_BY, require_gate + + required = [*PACKAGED_BY, *PACKAGED_BY.values()] + needs = {name: {"result": "skipped"} for name in PACKAGED_BY} + needs.update({name: {"result": "success", "outputs": {"baseline": "none"}} + for name in PACKAGED_BY.values()}) + require_gate(needs, required, skip_bundles=False, skip_tests=False) + for packaged, planner in PACKAGED_BY.items(): + for planned in ({"result": "success", "outputs": {"baseline": "published"}}, + {"result": "success"}, {"result": "failure", "outputs": {"baseline": "none"}}): + with pytest.raises(ValueError, match=packaged): + require_gate({**needs, planner: planned}, required, skip_bundles=False, skip_tests=False) + + def test_candidate_manifest_needs_every_call_and_stages_the_archive_manifest( tmp_path, r2_server, https_origin, monkeypatch): from scripts.releases import stable diff --git a/website/docs/developer-guide/stable-releases.md b/website/docs/developer-guide/stable-releases.md index 5fde40d8c6..3756a4df44 100644 --- a/website/docs/developer-guide/stable-releases.md +++ b/website/docs/developer-guide/stable-releases.md @@ -415,6 +415,9 @@ local helper suite. Never store the bootstrap output as a repo channel list. The last successful stable release that shipped bundles records `releases/stable/release-candidates.json` on the configured R2 public origin. A release that skipped bundles does not replace it. +The first release that ships bundles has no baseline. It still runs every +native smoke but skips the signed-package upgrade arms. A missing baseline +after any final tag records a candidate manifest is a blocker. It identifies actual Windows universal MSIX bundles, macOS ZIPs and package provenance. The next run combines those records with its candidate manifest and uses the existing native bundled-update drivers. From 25a096f317f762c92a27b1eaa3abc5c414fdaf4e Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 9 Oct 2026 01:41:35 -0400 Subject: [PATCH 2/2] fix(release): treat any missing stable baseline as no upgrade arm A release that skipped bundles never writes the stable baseline pointer, so its absence is normal at any point, not only before the first bundle release. Drop the final-tag scan. A 404 on the default pointer now always plans no upgrade arm. A supplied baseline-manifest that does not exist still blocks, because that is a bad input. --- .github/workflows/stable-release.yml | 6 ++-- scripts/releases/stable.py | 35 ++++++------------- tests/scripts/test_stable_release.py | 15 +++----- .../docs/developer-guide/stable-releases.md | 6 ++-- 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 13ed2158a1..b7e6a8ac8c 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -415,7 +415,7 @@ jobs: windows-packaged: name: Windows signed-package acceptance needs: transitions-win32 - # The first bundle release has no OLD package to upgrade from. + # Without a published stable baseline there is no OLD package to upgrade from. if: needs.transitions-win32.outputs.baseline != 'none' strategy: fail-fast: false @@ -433,7 +433,7 @@ jobs: macos-packaged-arm64: name: macOS arm64 signed-package acceptance needs: transitions-darwin-arm64 - # The first bundle release has no OLD package to upgrade from. + # Without a published stable baseline there is no OLD package to upgrade from. if: needs.transitions-darwin-arm64.outputs.baseline != 'none' strategy: fail-fast: false @@ -451,7 +451,7 @@ jobs: macos-packaged-x64: name: macOS x64 signed-package acceptance needs: transitions-darwin-x64 - # The first bundle release has no OLD package to upgrade from. + # Without a published stable baseline there is no OLD package to upgrade from. if: needs.transitions-darwin-x64.outputs.baseline != 'none' strategy: fail-fast: false diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index b9433235ef..b5c34687eb 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -102,8 +102,8 @@ def require_gate(needs: dict, required: list[str], *, skip_bundles: bool, skip_t raise ValueError("Invalid required-job list") expected = gate_expectations(required, skip_bundles=skip_bundles, skip_tests=skip_tests) for packaged, planner in PACKAGED_BY.items(): - # The first bundle release has no OLD package, so its planner skips the - # upgrade arm. Only that planner's own successful output can excuse it. + # Without a published baseline there is no OLD package, so the planner + # skips the upgrade arm. Only that planner's own successful output can excuse it. planned = needs.get(planner, {}) if (expected.get(packaged) == "success" and planned.get("result") == "success" and (planned.get("outputs") or {}).get("baseline") == "none"): @@ -653,35 +653,20 @@ def _stage_transition(env: dict, archive: str, base: str, row: dict) -> dict: "manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()} -def _bundle_release_shipped() -> bool: - """True when any final stable tag records a published candidate manifest. - - Publication writes the stable baseline pointer for exactly these releases. - Tags from before the claim pipeline carry prose, not a record, and shipped - no signed packages this pipeline can upgrade from. - """ - for tag in output(["git", "tag", "-l", "v*"]).split(): - if not STABLE_TAG_RE.fullmatch(tag): - continue - try: - record = tag_record(output(["git", "tag", "-l", tag, "--format=%(contents)"])) - except json.JSONDecodeError: - continue - if isinstance(record, dict) and record.get("candidateManifestSha256") is not None: - return True - return False - - def _published_baseline(env: dict, base: str) -> dict | None: - """The previous stable package manifest, or None for the first bundle release.""" + """The previous stable package manifest, or None when no stable release shipped one. + + A release that skipped bundles never writes the pointer, so its absence + only means there is no OLD package to upgrade from. + """ try: previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json", expected_origin=base) except urllib.error.HTTPError as error: if error.code != 404: raise - if env.get("BASELINE_MANIFEST_URL") or _bundle_release_shipped(): - raise ValueError("No published stable package baseline. Supply baseline-manifest for an actual previous stable release; acceptance cannot be skipped.") from error + if env.get("BASELINE_MANIFEST_URL"): + raise ValueError("The supplied baseline-manifest does not exist") from error return None published = json.loads(output(["gh", "release", "view", previous["tag"], "--repo", env["GITHUB_REPOSITORY"], "--json", "tagName,isDraft,isPrerelease"])) if published["tagName"] != previous["tag"] or published["isDraft"] or published["isPrerelease"]: @@ -714,7 +699,7 @@ def transitions(env: dict) -> None: matrices = {"windows": {"include": []}, "macos": {"include": []}} if previous is None: # Native smokes still gate this release. Its packages become the - # baseline that the next release must upgrade from. + # baseline that the next bundle release must upgrade from. validate_receipt(receipt_manifest, receipt, receipt_manifest.get("tag"), receipt_manifest.get("commit"), base, archive=receipt_manifest.get("archive")) emit({**matrices, "baseline": "none"}, env) diff --git a/tests/scripts/test_stable_release.py b/tests/scripts/test_stable_release.py index e437d111f0..9a8083e8b9 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -937,31 +937,24 @@ def test_transitions_from_the_bundle_receipt_emit_two_windows_rows(tmp_path, r2_ assert macos["include"] == [] -def test_first_bundle_release_plans_no_upgrade_arm_until_a_bundle_release_shipped( +def test_missing_baseline_plans_no_upgrade_arm_but_a_missing_supplied_one_blocks( tmp_path, r2_server, https_origin, monkeypatch): from scripts.releases import stable url, digest = _staged_receipt(tmp_path, r2_server, https_origin, monkeypatch, "darwin-arm64") env = {**_transitions_env(tmp_path, https_origin.base, "darwin-arm64", url, digest), "BASELINE_MANIFEST_URL": ""} - # The latest final tag skipped bundles; an older tag predates the claim pipeline. - tags = {"v1.2.2": json.dumps({"candidateManifestSha256": None}), "v1.2.1": "Weekly release"} - - def git(argv): - return " ".join(tags) if argv[3] == "v*" else tags[argv[3]] - - monkeypatch.setattr(stable, "output", git) stable.main(["transitions"], env) emitted = dict(line.split("=", 1) for line in (tmp_path / "output").read_text(encoding="utf-8").splitlines()) assert emitted["baseline"] == "none" assert json.loads(emitted["macos"]) == json.loads(emitted["windows"]) == {"include": []} - tags["v1.2.2"] = json.dumps({"candidateManifestSha256": "3" * 64}) - with pytest.raises(ValueError, match="No published stable package baseline"): + env["BASELINE_MANIFEST_URL"] = f"{https_origin.base}/baseline.json" + with pytest.raises(ValueError, match="supplied baseline-manifest does not exist"): stable.main(["transitions"], env) -def test_gate_excuses_an_upgrade_arm_only_for_its_planners_first_release_output(): +def test_gate_excuses_an_upgrade_arm_only_for_its_planners_no_baseline_output(): from scripts.releases.stable import PACKAGED_BY, require_gate required = [*PACKAGED_BY, *PACKAGED_BY.values()] diff --git a/website/docs/developer-guide/stable-releases.md b/website/docs/developer-guide/stable-releases.md index 3756a4df44..a2b91581fb 100644 --- a/website/docs/developer-guide/stable-releases.md +++ b/website/docs/developer-guide/stable-releases.md @@ -415,9 +415,9 @@ local helper suite. Never store the bootstrap output as a repo channel list. The last successful stable release that shipped bundles records `releases/stable/release-candidates.json` on the configured R2 public origin. A release that skipped bundles does not replace it. -The first release that ships bundles has no baseline. It still runs every -native smoke but skips the signed-package upgrade arms. A missing baseline -after any final tag records a candidate manifest is a blocker. +When no baseline exists, the release still runs every native smoke but skips +the signed-package upgrade arms. A `baseline-manifest` input that does not +exist is a blocker. It identifies actual Windows universal MSIX bundles, macOS ZIPs and package provenance. The next run combines those records with its candidate manifest and uses the existing native bundled-update drivers.