diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index e8cbcd24b4..b7e6a8ac8c 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 + # Without a published stable baseline there is 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 + # 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 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 + # 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 matrix: ${{ fromJSON(needs.transitions-darwin-x64.outputs.macos) }} diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index 9df21127c0..b5c34687eb 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(): + # 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"): + 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,21 @@ 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 _published_baseline(env: dict, base: str) -> dict | None: + """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 ValueError("No published stable package baseline. Supply baseline-manifest for an actual previous stable release; acceptance cannot be skipped.") from error - raise + if error.code != 404: + raise + 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"]: raise ValueError("Upgrade baseline must be a published stable release") @@ -677,10 +697,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 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) + 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..9a8083e8b9 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -937,6 +937,38 @@ def test_transitions_from_the_bundle_receipt_emit_two_windows_rows(tmp_path, r2_ assert macos["include"] == [] +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": ""} + 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": []} + 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_no_baseline_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..a2b91581fb 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. +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.