Merge pull request #138552 from NousResearch/ci/split-windows-store-lanes

Release CI can skip the signed Windows pipeline and still ship the Store bundle
This commit is contained in:
ethernet authored and GitHub committed 2026-10-14 15:30:07 -04:00
commit e8c8e46471
17 files changed
+290 -97

No files matched your search

+47 -22
View File
@@ -11,6 +11,9 @@ name: Desktop Bundled Release
# publication and Store submission require the accepted cross-platform candidate):
#
# build-win32 (win32-x64 + win32-arm64) → stage packages + receipts to R2
# Stable runs pick which Windows packages these build: `skip-signed` builds
# only the unsigned Store variant (no Azure signing), `skip-store` only the
# signed bundled one.
# build-darwin (darwin-arm64 + darwin-x64) → sign + notarize + stage
# dmg/zip/blockmap + per-arch feed inputs to the R2 tag archive
# build-linux → DISABLED for now (dummy skips)
@@ -106,10 +109,14 @@ on:
description: 'Stable candidates only: build and stage without native smokes or in-build test suites (the claim has no smoke lane).'
type: boolean
default: false
store:
description: 'Stable publish phase only: submit the Windows Store bundle (the claim built the windows lane).'
skip-signed:
description: 'Stable candidates only: build no signed Windows package (the claim has no windows lane). Needs no Azure signing.'
type: boolean
default: true
default: false
skip-store:
description: 'Stable only: build no Microsoft Store bundle and submit none (the claim has no windows-store lane).'
type: boolean
default: false
outputs:
darwin-arm64-receipt-url:
value: ${{ jobs.stage-receipt-darwin-arm64.outputs.receipt-url }}
@@ -604,7 +611,7 @@ jobs:
# Signing vars the build only warns and ships UNSIGNED binaries, which
# is right for a fork or a local build and wrong for a release.
- name: Require Azure signing when publishing
if: inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != ''
if: inputs.skip-signed != true && (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
shell: bash
env:
AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }}
@@ -624,7 +631,7 @@ jobs:
fi
- name: Azure login (OIDC)
if: vars.AZURE_CLIENT_ID != ''
if: vars.AZURE_CLIENT_ID != '' && inputs.skip-signed != true
uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
@@ -632,7 +639,7 @@ jobs:
allow-no-subscriptions: true
- name: Mint federated token for the signing dlib
if: vars.AZURE_CLIENT_ID != ''
if: vars.AZURE_CLIENT_ID != '' && inputs.skip-signed != true
shell: bash
run: |
file="$RUNNER_TEMP/azure-federated-token"
@@ -648,20 +655,26 @@ jobs:
- name: Build and package
shell: powershell
timeout-minutes: 900
# A Store-only build gets no signing vars: after-pack and electron-builder sign payload binaries
# whenever they are set, and this job skips the Azure login that would let them.
env:
HERMES_BUNDLE_ENV_JSON: ${{ inputs.bundle_env }}
PYTHONUTF8: '1'
AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }}
AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }}
AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }}
AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_SIGN_ENDPOINT: ${{ inputs.skip-signed != true && vars.AZURE_SIGN_ENDPOINT || '' }}
AZURE_SIGN_ACCOUNT: ${{ inputs.skip-signed != true && vars.AZURE_SIGN_ACCOUNT || '' }}
AZURE_SIGN_PROFILE: ${{ inputs.skip-signed != true && vars.AZURE_SIGN_PROFILE || '' }}
AZURE_SIGN_PUBLISHER: ${{ inputs.skip-signed != true && vars.AZURE_SIGN_PUBLISHER || '' }}
AZURE_CLIENT_ID: ${{ inputs.skip-signed != true && vars.AZURE_CLIENT_ID || '' }}
AZURE_TENANT_ID: ${{ inputs.skip-signed != true && vars.AZURE_TENANT_ID || '' }}
AZURE_TOKEN_CREDENTIALS: prod
SKIP_SIGNED: ${{ inputs.skip-signed }}
SKIP_STORE: ${{ inputs.skip-store }}
run: |
python scripts/bundles/desktop.py --prepared "$env:RUNNER_TEMP/desktop-job/prepared.json" --variant bundled
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $env:CHANNEL_BUILD -and -not $env:HERMES_BUILD_COMMIT -and $env:HERMES_PAYLOAD_TAG -notlike "*+canary.*") {
if ($env:SKIP_SIGNED -ne 'true') {
python scripts/bundles/desktop.py --prepared "$env:RUNNER_TEMP/desktop-job/prepared.json" --variant bundled
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
if ($env:SKIP_STORE -ne 'true' -and -not $env:CHANNEL_BUILD -and -not $env:HERMES_BUILD_COMMIT -and $env:HERMES_PAYLOAD_TAG -notlike "*+canary.*") {
python scripts/bundles/desktop.py --prepared "$env:RUNNER_TEMP/desktop-job/prepared.json" --variant store
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
@@ -720,11 +733,13 @@ jobs:
TARGET: ${{ matrix.target.label }}
RELEASE_PHASE: ${{ inputs.release-phase }}
RELEASE_COMMIT: ${{ needs.validate.outputs.sha }}
SKIP_SIGNED: ${{ inputs.skip-signed }}
# The tag archive is the handoff, never an update feed. The receipt
# appears after the selected packages (including Store on stable) upload.
# A Store-only candidate has no signed package to describe, so no metadata.
run: |
args=(--include '*.msix')
if [ "$RELEASE_PHASE" = candidate ] || { [ "$HERMES_DESKTOP_VARIANT" = bundled ] && [[ "$HERMES_PAYLOAD_TAG" == *+canary.* ]]; }; then
if [ "$SKIP_SIGNED" != true ] && { [ "$RELEASE_PHASE" = candidate ] || { [ "$HERMES_DESKTOP_VARIANT" = bundled ] && [[ "$HERMES_PAYLOAD_TAG" == *+canary.* ]]; }; }; then
python -m scripts.bundles.release_artifacts record --platform windows --arch "${TARGET##*-}" \
--root apps/desktop/release --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \
--out "apps/desktop/release/metadata-windows-${TARGET##*-}.json"
@@ -1417,6 +1432,7 @@ jobs:
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
&& inputs.skip-signed != true
permissions:
contents: read
uses: ./.github/workflows/desktop-bundle-smoke.yml
@@ -1445,6 +1461,7 @@ jobs:
&& (inputs.release-phase == '' || inputs.release-phase == 'candidate')
&& (inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '')
&& inputs.skip-tests != true
&& inputs.skip-signed != true
permissions:
contents: read
uses: ./.github/workflows/desktop-bundle-smoke.yml
@@ -1585,6 +1602,7 @@ jobs:
fi
- name: Azure login (OIDC)
if: inputs.skip-signed != true
uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
@@ -1592,6 +1610,7 @@ jobs:
allow-no-subscriptions: true
- name: Mint federated token for the signing dlib
if: inputs.skip-signed != true
shell: bash
run: |
file="$RUNNER_TEMP/azure-federated-token"
@@ -1608,6 +1627,8 @@ jobs:
shell: bash
env:
RELEASE_PHASE: ${{ inputs.release-phase }}
SKIP_SIGNED: ${{ inputs.skip-signed }}
SKIP_STORE: ${{ inputs.skip-store }}
AZURE_TOKEN_CREDENTIALS: prod
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
@@ -1624,8 +1645,10 @@ jobs:
fi
args=()
if [ "$RELEASE_PHASE" = candidate ]; then args+=(--candidate); fi
node scripts/stage-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --variant bundled "${args[@]}"
if [ "$RELEASE_PHASE" = candidate ]; then
if [ "$SKIP_SIGNED" != true ]; then
node scripts/stage-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG" --variant bundled "${args[@]}"
fi
if [ "$RELEASE_PHASE" = candidate ] && [ "$SKIP_STORE" != true ]; then
node scripts/bundle-store-msixbundle.mjs --tag "$HERMES_PAYLOAD_TAG"
fi
@@ -1649,8 +1672,8 @@ jobs:
- name: Stage the stable win32-bundle receipt
# Same receipt-before-smoke contract as the darwin arches (decision 11):
# the bundle and its per-arch metadata are staged and digest-verified;
# acceptance still blocks publication.
if: inputs.release-phase == 'candidate'
# acceptance still blocks publication. A Store-only candidate has no signed bundle to receipt.
if: inputs.release-phase == 'candidate' && inputs.skip-signed != true
id: receipt
env:
RELEASE_TAG: ${{ inputs.tag }}
@@ -2430,7 +2453,7 @@ jobs:
stable-store:
name: Submit the verified Store package
needs: [validate, stable-publish]
if: inputs.release-phase == 'publish' && inputs.store
if: inputs.release-phase == 'publish' && inputs.skip-store != true
# The Store is not a release gate: a failed submission leaves the release publishable
# and the summary names the Store package for a manual Partner Center upload.
continue-on-error: true
@@ -2510,6 +2533,7 @@ jobs:
RELEASE_NEEDS: ${{ toJSON(needs) }}
RELEASE_PHASE: ${{ inputs.release-phase }}
SKIP_TESTS: ${{ inputs.skip-tests }}
SKIP_SIGNED: ${{ inputs.skip-signed }}
run: |
python - <<'PY'
import json, os
@@ -2519,7 +2543,8 @@ jobs:
outputs = needs['validate'].get('outputs', {})
selected = {group: outputs.get(group) == 'true' for group in JOB_GROUPS}
require_success(needs, phase_jobs(selected, os.environ['RELEASE_PHASE'],
skip_tests=os.environ['SKIP_TESTS'] == 'true'))
skip_tests=os.environ['SKIP_TESTS'] == 'true',
skip_signed=os.environ['SKIP_SIGNED'] == 'true'))
PY
publish-canary:
+7 -1
View File
@@ -214,6 +214,8 @@ jobs:
release-phase: candidate
jobs: win32-arm64
skip-tests: ${{ !fromJSON(needs.admit.outputs.lanes).smoke }}
skip-signed: ${{ !fromJSON(needs.admit.outputs.lanes).windows }}
skip-store: ${{ !fromJSON(needs.admit.outputs.lanes)['windows-store'] }}
candidates-win32-x64:
name: Build signed release candidates (win32-x64)
@@ -235,6 +237,8 @@ jobs:
release-phase: candidate
jobs: win32-x64
skip-tests: ${{ !fromJSON(needs.admit.outputs.lanes).smoke }}
skip-signed: ${{ !fromJSON(needs.admit.outputs.lanes).windows }}
skip-store: ${{ !fromJSON(needs.admit.outputs.lanes)['windows-store'] }}
candidates-win32-bundle:
name: Build signed release candidates (win32-bundle)
@@ -258,6 +262,8 @@ jobs:
release-phase: candidate
jobs: win32-bundle
skip-tests: ${{ !fromJSON(needs.admit.outputs.lanes).smoke }}
skip-signed: ${{ !fromJSON(needs.admit.outputs.lanes).windows }}
skip-store: ${{ !fromJSON(needs.admit.outputs.lanes)['windows-store'] }}
candidates-termux:
name: Build signed release candidates (termux)
@@ -572,7 +578,7 @@ jobs:
claim-object: ${{ needs.admit.outputs.claim-object }}
release-phase: publish
manifest-sha256: ${{ needs.candidate-manifest.outputs.manifest-sha256 }}
store: ${{ fromJSON(needs.admit.outputs.lanes).windows }}
skip-store: ${{ !fromJSON(needs.admit.outputs.lanes)['windows-store'] }}
publication:
name: All artifact publication succeeded
+18 -28
View File
@@ -13,6 +13,7 @@ import urllib.request
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path, PureWindowsPath
from typing import Iterable
from scripts.releases import lanes as lane_set
from scripts.releases.stable import read_admitted_candidate, accepted_smoke_results, validate_candidates
@@ -161,29 +162,26 @@ def validate_windows_bundle(bundle: Path, windows: list[dict]) -> None:
ALL_HANDOFFS = ("win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64", "termux", "windows-universal")
WINDOWS_HANDOFFS = frozenset({"win32-x64", "win32-arm64", "windows-universal"})
def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path,
*, smoke_results: dict, release_epoch: int, archive: str,
handoffs: tuple[str, ...] = ALL_HANDOFFS) -> dict:
lanes: Iterable[str]) -> dict:
"""Bind the native metadata to files already staged by their build jobs.
`tag` stays the plain payload identity; `archive` is the attempt ref the
manifest and every artifact URL are keyed under. `handoffs` names the
groups this release built; a group left out must have no target in the
manifest. The three Windows groups build together or not at all: the
universal and Store bundles are assembled from both architectures."""
manifest and every artifact URL are keyed under. `lanes` are the claim's:
a handoff group a lane did not build must have no target in the manifest.
The Windows groups build together, so the universal bundle (`windows`) and
the Store bundle (`windows-store`) are each present exactly when their lane is."""
from scripts.releases.handoff import receipt_name, validate_receipt
from scripts.releases.r2 import put, staging_key_for
expected = tuple(handoffs)
windows = WINDOWS_HANDOFFS & set(expected)
if (not expected or len(set(expected)) != len(expected) or not set(expected) <= set(ALL_HANDOFFS)
or windows not in (set(), WINDOWS_HANDOFFS)):
raise ValueError(f"Invalid candidate handoff set: {', '.join(expected)}")
absent = frozenset(t for t in lane_set.TARGET_LANE if lane_set.TARGET_LANE[t] not in
{lane_set.HANDOFF_LANE[name] for name in expected})
lanes = frozenset(lanes)
expected = lane_set.handoffs(lanes, ALL_HANDOFFS)
if not expected:
raise ValueError("Invalid candidate handoff set: the lanes built no package")
absent = frozenset(lane_set.TARGET_LANE) - lane_set.targets(lanes)
smoke_results = accepted_smoke_results(smoke_results, absent=absent)
by_name = {}
for name in expected:
@@ -207,10 +205,14 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path,
if name.startswith("metadata-"):
rows.append(json.loads(file.read_text(encoding="utf-8-sig")))
universal_name = None
if windows:
if "windows" in lanes:
universal_name = single(name for name in by_name if name.endswith(".msixbundle") and not name.startswith("Store-"))
single(name for name in by_name if name.endswith(".msixbundle") and name.startswith("Store-"))
validate_windows_bundle(root / universal_name, [r for r in rows if r["platform"] == "windows"])
elif any(name.endswith(".msixbundle") and not name.startswith("Store-") for name in by_name):
raise ValueError("Candidate carries a signed Windows bundle the claim did not build")
stores = [name for name in by_name if name.endswith(".msixbundle") and name.startswith("Store-")]
if len(stores) != ("windows-store" in lanes):
raise ValueError("Candidate Store bundle differs from the claim's windows-store lane")
files = [{**row, "url": f"{public_base.rstrip('/')}/{staging_key_for(archive, name)}"}
for name, row in sorted(by_name.items()) if not name.startswith("metadata-")]
by_name = {item["path"]: item for item in files}
@@ -380,7 +382,7 @@ def promote(manifest: dict, root: Path, public_base: str) -> None:
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["record", "assemble", "publish", "promote", "materialize", "appinstaller", "publish-appinstaller"])
parser.add_argument("command", choices=["record", "publish", "promote", "materialize", "appinstaller", "publish-appinstaller"])
parser.add_argument("--platform", choices=["windows", "macos", "termux"])
parser.add_argument("--arch")
parser.add_argument("--channel-request", type=Path)
@@ -393,7 +395,6 @@ def main(argv: list[str] | None = None) -> None:
# A stable run dispatches from the default branch, so GITHUB_SHA is not the admitted claim commit.
parser.add_argument("--commit", default=os.environ.get("RELEASE_COMMIT") or os.environ.get("GITHUB_SHA"))
parser.add_argument("--public-base", default=os.environ.get("CLOUDFLARE_R2_PUBLIC_URL"))
parser.add_argument("--release-epoch", type=int, default=os.environ.get("HERMES_RELEASE_EPOCH"))
parser.add_argument("--store-only", action="store_true")
for name in ("identity", "publisher", "version", "self-uri", "artifact-uri"):
parser.add_argument(f"--{name}")
@@ -417,17 +418,6 @@ def main(argv: list[str] | None = None) -> None:
if args.command == "record":
request = json.loads(args.channel_request.read_text(encoding="utf-8-sig")) if args.channel_request else None
record(args.platform, args.arch, args.root, args.tag, args.commit, args.out, channel_request=request)
elif args.command == "assemble":
if args.release_epoch is None:
parser.error("assemble requires the admitted --release-epoch")
if not args.archive:
parser.error("assemble requires the admitted --archive ref")
assemble(args.root, args.tag, args.commit, args.public_base, args.out,
smoke_results=json.loads(os.environ.get("RELEASE_NEEDS", "{}")),
release_epoch=args.release_epoch, archive=args.archive)
if os.environ.get("GITHUB_OUTPUT"):
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as file:
file.write(f"manifest-url={args.public_base.rstrip('/')}/releases/tag/{args.archive}/release-candidates.json\nmanifest-sha256={sha256_file(args.out)}\n")
else:
if not args.archive:
parser.error(f"{args.command} requires the admitted --archive ref")
+1
View File
@@ -292,6 +292,7 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
"commit": commit,
"autopublish": autopublish,
"lanes": sorted(lanes),
"lanesRevision": lane_set.LANES_REVISION,
"claimEpoch": claim_epoch,
}, sort_keys=True, separators=(",", ":"))
subprocess.check_output(
+7 -3
View File
@@ -33,6 +33,8 @@ GROUP_SMOKES = {
"win32-arm64": ("smoke-win32-arm64",),
"win32-x64": ("smoke-win32-x64",),
}
# The per-arch groups whose smoke installs the signed MSIX.
SIGNED_WINDOWS_GROUPS = frozenset({"win32-arm64", "win32-x64"})
def parse_jobs(raw: str | None) -> dict[str, bool]:
@@ -63,12 +65,14 @@ def selects_all(raw: str | None) -> bool:
return all(parse_jobs(raw).values())
def phase_jobs(selected: dict[str, bool], phase: str, *, skip_tests: bool = False) -> list[str]:
def phase_jobs(selected: dict[str, bool], phase: str, *, skip_tests: bool = False,
skip_signed: bool = False) -> list[str]:
"""Jobs the stable phase result judges: an unselected group never fails it.
B4 moved candidate-manifest into stable-release.yml, so the desktop
workflow no longer owns it and the phase result never names it. A claim
that skipped tests runs no smoke, so the smokes are not judged.
that skipped tests runs no smoke, so the smokes are not judged. A claim without the signed
Windows packages smokes none of them (the Store bundle is not install-smoked).
"""
required = ["validate"]
if phase == "publish":
@@ -79,7 +83,7 @@ def phase_jobs(selected: dict[str, bool], phase: str, *, skip_tests: bool = Fals
for group, jobs in GROUP_JOBS.items():
if selected.get(group):
required.extend(jobs)
if not skip_tests:
if not skip_tests and not (skip_signed and group in SIGNED_WINDOWS_GROUPS):
required.extend(GROUP_SMOKES.get(group, ()))
return required
+26 -12
View File
@@ -27,7 +27,8 @@ class Lane:
LANES = (
Lane("docker", True, "build and publish the Docker image"),
Lane("windows", True, "Windows x64 + arm64 MSIX, the universal bundle and the Microsoft Store bundle"),
Lane("windows", True, "signed Windows x64 + arm64 MSIX and the universal bundle (the update channel's package)"),
Lane("windows-store", True, "unsigned Microsoft Store bundle, submitted to Partner Center (needs no signing)"),
Lane("macos-arm64", True, "macOS Apple Silicon DMG + ZIP"),
Lane("macos-intel", False, "macOS Intel DMG + ZIP (unsupported on main; its runner adds over an hour)"),
Lane("termux", False, "Termux (Android aarch64) APT package"),
@@ -46,17 +47,19 @@ NAMES = tuple(lane.name for lane in LANES)
DEFAULT = frozenset(lane.name for lane in LANES if lane.default)
DESKTOP = ("windows", "macos-arm64", "macos-intel")
BUNDLES = (*DESKTOP, "termux")
WINDOWS = ("windows", "windows-store")
BUNDLES = (*DESKTOP, "windows-store", "termux")
# The protected desktop channel, the update feeds and the Termux index move only for a release that
# built every desktop package the channel serves. A partial set still stages, verifies and (for
# Windows) submits its packages, but leaves the channels on the previous release.
CHANNEL_DESKTOP = ("windows", "macos-arm64")
DERIVED = ("bundles", "desktop-channel")
DERIVED = ("bundles", "desktop-channel", "windows-build")
# Each stable-release.yml job and the lanes that must all be on for it to run. A job named here
# reports `skipped` when one is off and `success` otherwise; a job not named never skips. DERIVED
# names are computed from the lanes: "bundles" is any build lane except docker, "desktop-channel"
# is the full desktop set the protected channel serves.
# is the full desktop set the protected channel serves, "windows-build" is either Windows lane (the
# per-arch builds and the bundle assembly are shared, each lane only picks which package they make).
JOB_LANES: dict[str, frozenset[str]] = {name: frozenset(needs) for name, needs in {
"ci": ("ci",),
"nix": ("nix",),
@@ -69,9 +72,9 @@ JOB_LANES: dict[str, frozenset[str]] = {name: frozenset(needs) for name, needs i
"bootstrap-version": ("bootstrap-version",),
"candidates-darwin-arm64": ("macos-arm64",),
"candidates-darwin-x64": ("macos-intel",),
"candidates-win32-arm64": ("windows",),
"candidates-win32-x64": ("windows",),
"candidates-win32-bundle": ("windows",),
"candidates-win32-arm64": ("windows-build",),
"candidates-win32-x64": ("windows-build",),
"candidates-win32-bundle": ("windows-build",),
"candidates-termux": ("termux",),
"candidate-manifest": ("bundles",),
"publish-bundles": ("bundles",),
@@ -86,13 +89,14 @@ JOB_LANES: dict[str, frozenset[str]] = {name: frozenset(needs) for name, needs i
CANDIDATE_CALLS = tuple(job for job in JOB_LANES if job.startswith("candidates-"))
# Candidate handoff group and manifest target, each with the lane that builds it.
HANDOFF_LANE = {"win32-x64": "windows", "win32-arm64": "windows", "windows-universal": "windows",
HANDOFF_LANE = {"win32-x64": "windows-build", "win32-arm64": "windows-build", "windows-universal": "windows-build",
"darwin-arm64": "macos-arm64", "darwin-x64": "macos-intel", "termux": "termux"}
TARGET_LANE = {"windows/x64": "windows", "windows/arm64": "windows", "macos/arm64": "macos-arm64",
"macos/x64": "macos-intel", "termux/aarch64": "termux"}
LEFT_OUT = {
"docker": "the Docker image keeps its previous stable tag",
"windows": "no Windows packages and no Microsoft Store submission",
"windows": "no signed Windows packages: the Windows update channel stays on its previous release",
"windows-store": "no Microsoft Store submission",
"macos-arm64": "no macOS Apple Silicon package",
"macos-intel": "Intel Macs get no update: the shared macOS stable feed lists arm64 only",
"termux": "the Termux APT channel stays on its previous release",
@@ -126,7 +130,8 @@ def parse_list(values: Iterable[str] | None) -> list[str] | None:
def effective(lanes: Iterable[str]) -> frozenset[str]:
lanes = frozenset(lanes)
return lanes | {name for name, derived in (("bundles", has_bundles(lanes)),
("desktop-channel", advances_desktop(lanes))) if derived}
("desktop-channel", advances_desktop(lanes)),
("windows-build", bool(lanes & set(WINDOWS)))) if derived}
def flags(lanes: Iterable[str]) -> dict[str, bool]:
@@ -151,7 +156,7 @@ def jobs_to_run(lanes: Iterable[str]) -> dict[str, bool]:
def handoffs(lanes: Iterable[str], names: Iterable[str]) -> tuple[str, ...]:
"""``names`` without the candidate handoff groups the lanes did not build."""
on = frozenset(lanes)
on = effective(lanes)
return tuple(name for name in names if HANDOFF_LANE[name] in on)
@@ -181,6 +186,10 @@ def moves_channels(lanes: Iterable[str]) -> bool:
return "docker" in on or advances_desktop(on)
# Claims record `lanesRevision` next to `lanes`. A claim without it was cut when "windows" also meant
# the Store bundle, so it reads as windows plus windows-store; the two lanes cannot otherwise be told
# apart from a revision-2 claim that chose "windows" alone.
LANES_REVISION = 2
_LEGACY_OPTIONAL = ("includeMacosIntel", "includeTermux")
_LEGACY_BUNDLE_LANES = frozenset({*BUNDLES, "pm-bundle", "upgrade", "smoke"})
_TEST_LANES = frozenset({"ci", "nix", "docker-tests", "pm-bundle", "windows-live", "install-e2e",
@@ -200,10 +209,15 @@ def claim_lanes(metadata: Mapping) -> frozenset[str]:
if (not isinstance(lanes, list) or any(not isinstance(name, str) for name in lanes)
or lanes != sorted(set(lanes))):
raise ValueError("Stable claim metadata is invalid")
if metadata.get("lanesRevision", 1) not in (1, LANES_REVISION):
raise ValueError("Stable claim metadata is invalid")
try:
return _known(lanes)
known = _known(lanes)
except ValueError as error:
raise ValueError("Stable claim metadata is invalid") from error
if "lanesRevision" not in metadata and "windows" in known:
known |= {"windows-store"}
return known
if any(not isinstance(metadata.get(key), bool)
for key in ("skipBundles", "skipTests", *(k for k in _LEGACY_OPTIONAL if k in metadata))):
raise ValueError("Stable claim metadata is invalid")
+1 -1
View File
@@ -456,7 +456,7 @@ def reconcile(env: dict, *, run=output, read_head=None, advance_head=None,
# build that pushed the image under the attempt ref.
docker.promote_stable(record["claim_tag"], record["docker_manifest_digest"], record["tag"])
log("Docker aliases moved")
if "windows" in record["lanes"]:
if "windows-store" in record["lanes"]:
log("Checking the Microsoft Store submission")
# The Store check joins the pass here (after the feeds and aliases
# move). It never releases the held submission: the API cannot,
+7 -4
View File
@@ -227,7 +227,8 @@ def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str,
never is.
"""
rows = _validated_rows(manifest, tag, commit, public_base, release_epoch, archive=archive)
if not rows:
# A Store-only release has no package rows: the Store bundle is a file, never an update target.
if not rows and not manifest.get("files"):
raise ValueError("Candidate manifest has no packages")
# Which targets a release must carry is its claim's business (require_targets_match_claim),
# not the manifest's: a release may build any subset of the lanes.
@@ -427,7 +428,7 @@ def validate_claim(metadata: object, *, version: str, attempt: int, commit: str)
raise ValueError("Stable claim metadata is invalid")
base = {*expected, "autopublish", "claimEpoch"}
legacy = {"skipBundles", "skipTests"}
shapes = ({*base, "lanes"}, {*base, *legacy}, {*base, *legacy, "includeMacosIntel"},
shapes = ({*base, "lanes"}, {*base, "lanes", "lanesRevision"}, {*base, *legacy}, {*base, *legacy, "includeMacosIntel"},
{*base, *legacy, "includeTermux"}, {*base, *legacy, "includeMacosIntel", "includeTermux"})
if set(metadata) not in shapes:
raise ValueError("Stable claim metadata is invalid")
@@ -795,7 +796,9 @@ def candidate_manifest(env: dict) -> None:
handoffs = lane_set.handoffs(lanes, CANDIDATE_HANDOFFS)
# A call the claim did not build never ran, so it has no smoke to record.
runs = lane_set.jobs_to_run(lanes)
calls = {call: job for call, job in CALL_SMOKE_JOBS.items() if runs[call]}
# A call whose package is not a target (the Store variant is not install-smoked) has no smoke.
built = lane_set.targets(lanes)
calls = {call: job for call, job in CALL_SMOKE_JOBS.items() if runs[call] and SMOKE_TARGETS[job] in built}
smoke = {job: {"result": "skipped"} for job in CALL_SMOKE_JOBS.values()}
if lane_set.runs_smokes(lanes):
ran = {job: {"result": (needs.get(call) or {}).get("result")} for call, job in calls.items()}
@@ -813,7 +816,7 @@ def candidate_manifest(env: dict) -> None:
includes=list(CANDIDATE_INCLUDES))
out = root / "release-candidates.json"
assemble(root, tag, commit, base, out, smoke_results=smoke,
release_epoch=claim["claim_epoch"], archive=archive, handoffs=handoffs)
release_epoch=claim["claim_epoch"], archive=archive, lanes=lanes)
digest = hashlib.sha256(out.read_bytes()).hexdigest()
emit({"manifest-url": f"{base}/releases/tag/{archive}/release-candidates.json",
"manifest-sha256": digest}, env)
+10 -2
View File
@@ -132,7 +132,7 @@ def test_stable_phase_result_requires_smoke_but_preserves_other_phases(tmp_path,
and not set(needs_of(jobs[name])) - {'validate'}]
(store,) = [name for name in needs_of(jobs[verdict]) if publish in needs_of(jobs[name])]
def run_phase(phase, selected, failed=None, skip_tests=False):
def run_phase(phase, selected, failed=None, skip_tests=False, skip_signed=False):
needs = {name: {'result': 'success'} for name in needs_of(jobs[verdict])}
needs['validate']['outputs'] = {group: ('true' if group in selected else 'false')
for group in group_jobs}
@@ -143,18 +143,26 @@ def test_stable_phase_result_requires_smoke_but_preserves_other_phases(tmp_path,
if skip_tests:
for smoke in smokes.values():
needs[smoke]['result'] = 'skipped'
if skip_signed:
for target in ('win32-arm64', 'win32-x64'):
needs[smokes[target]]['result'] = 'skipped'
if failed:
needs[failed]['result'] = 'cancelled'
(step,) = [step for step in jobs[verdict]['steps'] if 'SKIP_TESTS' in (step.get('env') or {})]
return shell_step(tmp_path, r2_server, '', '',
{'RELEASE_NEEDS': json.dumps(needs), 'RELEASE_PHASE': phase,
'SKIP_TESTS': 'true' if skip_tests else 'false'}, script=step['run'])
'SKIP_TESTS': 'true' if skip_tests else 'false',
'SKIP_SIGNED': 'true' if skip_signed else 'false'}, script=step['run'])
every = set(group_jobs)
assert run_phase('candidate', every).returncode == 0
# A claim that skipped tests runs no smoke, and every build still counts.
assert run_phase('candidate', every, skip_tests=True).returncode == 0
assert run_phase('candidate', every, failed=gates['win32-x64'], skip_tests=True).returncode != 0
# A Store-only Windows claim smokes no Windows package, but its builds and the Mac smoke still count.
assert run_phase('candidate', every, skip_signed=True).returncode == 0
assert run_phase('candidate', every, failed=gates['win32-x64'], skip_signed=True).returncode != 0
assert run_phase('candidate', every, failed=smokes['darwin-arm64'], skip_signed=True).returncode != 0
assert run_phase('publish', every).returncode == 0
assert run_phase('publish', every - {'termux'}).returncode == 0
for group in group_jobs:
+20 -3
View File
@@ -149,10 +149,15 @@ def test_lane_inputs_reach_the_workflows_they_configure():
for name in ("candidates-darwin-arm64", "candidates-darwin-x64", "candidates-win32-arm64",
"candidates-win32-x64", "candidates-win32-bundle", "candidates-termux"):
assert jobs[name]["with"]["skip-tests"] == "${{ !fromJSON(needs.admit.outputs.lanes).smoke }}", name
# The Store submission follows the windows lane.
assert jobs["publish-bundles"]["with"]["store"] == "${{ fromJSON(needs.admit.outputs.lanes).windows }}"
# Which Windows packages the shared builds make follows the two Windows lanes, and the Store
# submission follows the Store lane.
store_off = "${{ !fromJSON(needs.admit.outputs.lanes)['windows-store'] }}"
for name in ("candidates-win32-arm64", "candidates-win32-x64", "candidates-win32-bundle"):
assert jobs[name]["with"]["skip-signed"] == "${{ !fromJSON(needs.admit.outputs.lanes).windows }}", name
assert jobs[name]["with"]["skip-store"] == store_off, name
assert jobs["publish-bundles"]["with"]["skip-store"] == store_off
desktop = workflow("desktop-bundled-release.yml")["jobs"]
assert "inputs.store" in desktop["stable-store"]["if"]
assert "inputs.skip-store" in desktop["stable-store"]["if"]
assert {"lanes", "jobs"} <= set(jobs["admit"]["outputs"])
for name in ("acceptance", "publication", "complete"):
gate_step = next(step for step in jobs[name]["steps"]
@@ -163,6 +168,18 @@ def test_lane_inputs_reach_the_workflows_they_configure():
assert set(gated) <= set(direct_needs(jobs, name)), name
def test_a_store_only_build_is_handed_no_signing_variables():
"""after-pack and electron-builder sign whenever AZURE_SIGN_* are set; the Azure login is skipped
with the signed lane, so the build step must see them empty."""
jobs = workflow("desktop-bundled-release.yml")["jobs"]
step = next(step for step in jobs["build-win32-x64-release"]["steps"] if step.get("name") == "Build and package")
azure = {key: value for key, value in step["env"].items() if key.startswith("AZURE_") and key != "AZURE_TOKEN_CREDENTIALS"}
assert set(azure) == {"AZURE_SIGN_ENDPOINT", "AZURE_SIGN_ACCOUNT", "AZURE_SIGN_PROFILE", "AZURE_SIGN_PUBLISHER",
"AZURE_CLIENT_ID", "AZURE_TENANT_ID"}
for key, value in azure.items():
assert value == f"${{{{ inputs.skip-signed != true && vars.{key} || '' }}}}", key
def test_admit_emits_the_same_lanes_and_jobs_the_gate_reads(tmp_path):
from scripts.releases import stable
+4
View File
@@ -60,6 +60,10 @@ def test_phase_jobs_judge_only_the_selected_groups():
# A claim that skipped tests still judges every build, and no smoke.
untested = phase_jobs(every, "candidate", skip_tests=True)
assert untested == [job for job in candidate if not job.startswith("smoke-")]
# A claim without the signed Windows lane builds the Store packages but smokes only the other groups.
unsigned = phase_jobs(every, "candidate", skip_signed=True)
assert unsigned == [job for job in candidate if not job.startswith("smoke-win32-")]
assert "build-win32-x64" in unsigned and "assemble-win32-bundle" in unsigned
assert phase_jobs(every, "publish") == ["validate", "stable-publish"]
with pytest.raises(ValueError, match="Unknown release phase"):
phase_jobs(every, "promote")
+7 -3
View File
@@ -19,6 +19,7 @@ from tests.scripts.test_release_r2 import r2_server
from tests.scripts.test_stable_release import https_origin
from tests.scripts.test_release_darwin import _inputs
from scripts.bundles import release_artifacts as artifacts
from scripts.releases import lanes as lane_set
ROOT = Path(__file__).resolve().parents[2]
SMOKE_RESULTS = {name: {'result': 'success'} for name in (
@@ -121,7 +122,8 @@ def staged_candidate(tmp_path, r2_server, https_origin):
handoff.fetch(attempt, commit, names, fetched, ['metadata-*.json', '*.msixbundle'])
r2_server.requests.clear()
manifest = assemble(fetched, tag, commit, base, tmp_path / 'release-candidates.json',
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=attempt)
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=attempt,
lanes=lane_set.NAMES)
assert manifest['archive'] == attempt and manifest['tag'] == tag
assert {row['platform'] + '/' + row['arch'] for row in manifest['packages']} == {
'windows/x64', 'windows/arm64', 'macos/x64', 'macos/arm64', 'termux/aarch64'}
@@ -172,12 +174,14 @@ def test_assemble_rejects_missing_and_changed_receipts(tmp_path, staged_candidat
receipt.unlink()
with pytest.raises(ValueError, match='handoff'):
assemble(fetched, tag, commit, base, tmp_path / 'missing.json',
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive'])
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive'],
lanes=lane_set.NAMES)
receipt.write_bytes(original)
(fetched / 'metadata-windows-x64.json').write_text('{}', encoding='utf-8')
with pytest.raises(ValueError, match='digest'):
assemble(fetched, tag, commit, base, tmp_path / 'changed.json',
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive'])
smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive'],
lanes=lane_set.NAMES)
def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_server, staged_candidate):
+2 -1
View File
@@ -118,6 +118,7 @@ def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source)
"commit": commit,
"schema": 1,
"lanes": ["ci", "docker", "windows"],
"lanesRevision": lane_set.LANES_REVISION,
"version": "0.21.5",
}
assert result["lanes"] == frozenset({"ci", "docker", "windows"})
@@ -166,7 +167,7 @@ def test_release_output_names_the_ci_run_and_the_publish_step():
# --only windows,docker: no tests, no macOS, and a partial desktop set never moves the channel.
windows = next_steps({**result, "lanes": lane_set.select(only=["windows", "docker"])})
assert "Not built: macos-arm64, macos-intel, termux." in windows
assert "Not built: windows-store, macos-arm64, macos-intel, termux." in windows
assert "Not tested: ci, nix, docker-tests, pm-bundle, windows-live, install-e2e, bootstrap-version, smoke, upgrade" in windows
assert "partial desktop release" in windows
assert "\033[" not in windows
+30 -1
View File
@@ -39,7 +39,7 @@ def test_every_job_names_real_lanes():
for target, lane in lane_set.TARGET_LANE.items():
assert lane in lane_set.NAMES, target
for handoff, lane in lane_set.HANDOFF_LANE.items():
assert lane in lane_set.NAMES, handoff
assert lane in {*lane_set.NAMES, *lane_set.DERIVED}, handoff
def test_a_job_runs_only_when_every_lane_it_needs_is_on():
@@ -56,6 +56,24 @@ def test_a_job_runs_only_when_every_lane_it_needs_is_on():
assert not any(lane_set.jobs_to_run([]).values())
def test_each_windows_lane_alone_builds_its_own_package_with_the_shared_jobs():
shared = {"candidates-win32-arm64", "candidates-win32-x64", "candidates-win32-bundle",
"candidate-manifest", "publish-bundles"}
for lanes in (["windows"], ["windows-store"]):
assert {job for job, runs in lane_set.jobs_to_run(lanes).items() if runs} == shared
# The signed pipeline's test arms need the signed lane, never the Store one.
store_only = lane_set.jobs_to_run(["windows-store", "smoke", "upgrade"])
assert not store_only["transitions-win32"] and not store_only["windows-packaged"]
assert lane_set.jobs_to_run(["windows", "upgrade"])["transitions-win32"]
# A Store-only release has a manifest and smokes nothing: its bundle is not an install target.
assert lane_set.has_bundles(["windows-store"])
assert lane_set.targets(["windows-store"]) == frozenset()
assert not lane_set.runs_smokes(["windows-store", "smoke"])
assert not lane_set.advances_desktop(["windows-store", "macos-arm64"])
assert lane_set.handoffs(["windows-store"], ("win32-x64", "windows-universal", "termux")) == \
("win32-x64", "windows-universal")
def test_flags_round_trip_and_refuse_anything_loose():
flags = lane_set.flags(["windows", "docker"])
assert flags["bundles"] is True and flags["macos-arm64"] is False
@@ -69,6 +87,17 @@ def test_flags_round_trip_and_refuse_anything_loose():
lane_set.from_flags(bad)
def test_a_claim_without_a_lanes_revision_keeps_the_store_bundle_it_was_cut_with():
"""rc.2-v0.22.1 recorded ["docker","windows"] when `windows` also built and submitted the Store bundle."""
legacy = {"lanes": ["docker", "windows"]}
assert lane_set.claim_lanes(legacy) == {"docker", "windows", "windows-store"}
current = {**legacy, "lanesRevision": lane_set.LANES_REVISION}
assert lane_set.claim_lanes(current) == {"docker", "windows"}
assert lane_set.claim_lanes({"lanes": ["docker"]}) == {"docker"}
with pytest.raises(ValueError, match="invalid"):
lane_set.claim_lanes({**legacy, "lanesRevision": 3})
def test_targets_and_handoffs_follow_the_build_lanes():
assert lane_set.targets(["windows", "termux"]) == {"windows/x64", "windows/arm64", "termux/aarch64"}
assert lane_set.handoffs(["windows"], ("win32-x64", "darwin-arm64", "windows-universal", "termux")) == \
+1 -1
View File
@@ -77,4 +77,4 @@ def test_a_signed_claim_tag_is_read_through_the_jobs_own_git_read(tmp_path, monk
json.loads(raw)
# The record predates lanes: skipTests=true reads as the build lanes alone.
assert _claim_metadata(raw, version="0.21.5", attempt=19, commit="3" * 40) == {
**record, "lanes": frozenset({"docker", "windows", "macos-arm64"})}
**record, "lanes": frozenset({"docker", "windows", "windows-store", "macos-arm64"})}
+91 -11
View File
@@ -514,15 +514,18 @@ def test_a_claim_cut_before_lanes_existed_reads_as_the_lane_set_that_gated_the_s
assert validate_claim(both, version="0.21.7", attempt=11, commit=commit)["lanes"] == frozenset(lane_set.NAMES)
untested = {**old, "skipTests": True}
assert validate_claim(untested, version="0.21.7", attempt=11, commit=commit)["lanes"] == frozenset(
{"docker", "windows", "macos-arm64"})
{"docker", "windows", "windows-store", "macos-arm64"})
# skipBundles built the image and nothing else a bundle touches, tests included.
bundleless = {**old, "skipBundles": True}
assert validate_claim(bundleless, version="0.21.7", attempt=11, commit=commit)["lanes"] == frozenset(
{"docker", "ci", "nix", "docker-tests", "windows-live", "install-e2e", "bootstrap-version", "termux-checks"})
# The keys stay exact, never loose: a non-boolean, an unknown key or a mix of shapes is refused.
new = {k: v for k, v in old.items() if not k.startswith("skip")}
# A lanes claim without a revision was cut when `windows` also meant the Store bundle (rc.2-v0.22.1).
assert validate_claim({**new, "lanes": ["docker", "windows"]}, version="0.21.7", attempt=11,
commit=commit)["lanes"] == frozenset({"docker", "windows"})
commit=commit)["lanes"] == frozenset({"docker", "windows", "windows-store"})
assert validate_claim({**new, "lanes": ["docker", "windows"], "lanesRevision": lane_set.LANES_REVISION},
version="0.21.7", attempt=11, commit=commit)["lanes"] == frozenset({"docker", "windows"})
for wrong in ({**old, "includeTermux": "yes"}, {**old, "includeLinux": True},
{**new, "lanes": ["windows", "docker"]}, {**new, "lanes": ["linux"]},
{**new, "lanes": "windows"}, {**old, "lanes": ["docker"]}):
@@ -620,7 +623,7 @@ def test_a_windows_only_claim_assembles_a_windows_only_manifest(
built.mkdir()
_stage_windows_handoff(built, "x64")
_stage_windows_handoff(built, "arm64")
_stage_universal_bundle(built)
_stage_universal_bundle(built, store=False)
lanes = lane_set.select(only=["windows", "docker"])
monkeypatch.setattr(stable, "stable_context", _fake_stable_context(lanes=lanes))
calls = _calls(lanes)
@@ -634,6 +637,81 @@ def test_a_windows_only_claim_assembles_a_windows_only_manifest(
stable.require_targets_match_claim(manifest, lanes)
def test_a_store_only_claim_assembles_a_manifest_with_the_store_bundle_and_no_packages(
tmp_path, r2_server, https_origin, monkeypatch):
"""--only windows-store: the per-arch handoffs carry unsigned Store MSIX and no metadata."""
from scripts.releases import stable
https_origin.store = r2_server.store
built = tmp_path / "built"
built.mkdir()
for arch in ("x64", "arm64"):
(built / f"Store-Product-1.2.3-win-{arch}.msix").write_bytes(f"store msix: {arch}".encode())
from scripts.releases import handoff
handoff.stage(ATTEMPT, RECEIPT_COMMIT, f"win32-{arch}", built, [f"Store-Product-1.2.3-win-{arch}.msix"])
_stage_universal_bundle(built, signed=False)
lanes = lane_set.select(only=["windows-store"])
monkeypatch.setattr(stable, "stable_context", _fake_stable_context(lanes=lanes))
env = {**_receipt_env(tmp_path, https_origin.base), "RELEASE_NEEDS": json.dumps(_calls(lanes))}
stable.main(["candidate-manifest"], env)
manifest = json.loads(r2_server.store[f"releases/tag/{ATTEMPT}/release-candidates.json"][0])
assert manifest["packages"] == []
assert [file["path"] for file in manifest["files"] if file["path"].endswith(".msixbundle")] == [
"Store-Product-1.2.3-win.msixbundle"]
stable.validate_candidates(manifest, "v1.2.3", RECEIPT_COMMIT, https_origin.base, RELEASE_EPOCH, archive=ATTEMPT)
stable.require_smokes_match_claim(manifest, lanes)
stable.require_targets_match_claim(manifest, lanes)
def test_a_store_plus_macos_claim_records_only_the_macos_smoke(
tmp_path, r2_server, https_origin, monkeypatch):
"""--without windows: the Windows candidate calls run for the Store bundle and succeed, but no
Windows package is smoked, so their success is not a smoke result."""
from scripts.releases import handoff, stable
https_origin.store = r2_server.store
built = tmp_path / "built"
built.mkdir()
_stage_darwin_handoff(built, "arm64")
for arch in ("x64", "arm64"):
(built / f"Store-Product-1.2.3-win-{arch}.msix").write_bytes(f"store msix: {arch}".encode())
handoff.stage(ATTEMPT, RECEIPT_COMMIT, f"win32-{arch}", built, [f"Store-Product-1.2.3-win-{arch}.msix"])
_stage_universal_bundle(built, signed=False)
lanes = lane_set.select(without=["windows"])
assert lane_set.runs_smokes(lanes) and "windows-store" in lanes
monkeypatch.setattr(stable, "stable_context", _fake_stable_context(lanes=lanes))
calls = _calls(lanes)
assert calls["candidates-win32-x64"] == {"result": "success"}
env = {**_receipt_env(tmp_path, https_origin.base), "RELEASE_NEEDS": json.dumps(calls)}
stable.main(["candidate-manifest"], env)
manifest = json.loads(r2_server.store[f"releases/tag/{ATTEMPT}/release-candidates.json"][0])
assert {f"{row['platform']}/{row['arch']}" for row in manifest["packages"]} == {"macos/arm64"}
assert manifest["smoke_results"]["smoke-darwin-arm64"] == {"result": "success"}
assert manifest["smoke_results"]["smoke-win32-x64"] == {"result": "skipped"}
stable.require_smokes_match_claim(manifest, lanes)
stable.require_targets_match_claim(manifest, lanes)
def test_the_manifest_refuses_a_windows_bundle_the_claims_lanes_did_not_build(
tmp_path, r2_server, https_origin, monkeypatch):
"""A signed bundle under a Store-only claim, or a missing Store bundle under `windows` + `windows-store`."""
from scripts.releases import stable
https_origin.store = r2_server.store
built = tmp_path / "built"
built.mkdir()
_stage_windows_handoff(built, "x64")
_stage_windows_handoff(built, "arm64")
_stage_universal_bundle(built, store=False)
for lanes, message in ((["windows", "windows-store"], "Store bundle"), (["windows-store"], "signed Windows bundle")):
lanes = lane_set.select(only=lanes)
monkeypatch.setattr(stable, "stable_context", _fake_stable_context(lanes=lanes))
env = {**_receipt_env(tmp_path, https_origin.base), "RELEASE_NEEDS": json.dumps(_calls(lanes))}
with pytest.raises(ValueError, match=message):
stable.main(["candidate-manifest"], env)
assert f"releases/tag/{ATTEMPT}/release-candidates.json" not in r2_server.store
def _claim_fixture(tmp_path, *, tag, version, lanes=lane_set.DEFAULT):
"""A real checkout + bare remote carrying one annotated attempt claim."""
from scripts.releases.versioning import parse_attempt_ref
@@ -1003,16 +1081,18 @@ def _stage_windows_handoff(built, arch, *, with_metadata=True):
handoff.stage(ATTEMPT, RECEIPT_COMMIT, f"win32-{arch}", built, includes)
def _stage_universal_bundle(built):
def _stage_universal_bundle(built, *, signed=True, store=True):
from scripts.releases import handoff
bundle = built / "Product-1.2.3-win.msixbundle"
with zipfile.ZipFile(bundle, "w") as archive:
archive.writestr("AppxMetadata/AppxBundleManifest.xml",
f'<Bundle><Identity Name="test.application" Publisher="CN=Test" Version="{WINDOWS_VERSION}"/>'
'<Packages><Package Type="application" Architecture="arm64"/>'
'<Package Type="application" Architecture="x64"/></Packages></Bundle>')
(built / "Store-Product-1.2.3-win.msixbundle").write_bytes(b"Store bundle transport fixture")
if signed:
bundle = built / "Product-1.2.3-win.msixbundle"
with zipfile.ZipFile(bundle, "w") as archive:
archive.writestr("AppxMetadata/AppxBundleManifest.xml",
f'<Bundle><Identity Name="test.application" Publisher="CN=Test" Version="{WINDOWS_VERSION}"/>'
'<Packages><Package Type="application" Architecture="arm64"/>'
'<Package Type="application" Architecture="x64"/></Packages></Bundle>')
if store:
(built / "Store-Product-1.2.3-win.msixbundle").write_bytes(b"Store bundle transport fixture")
handoff.stage(ATTEMPT, RECEIPT_COMMIT, "windows-universal", built, ["*.msixbundle"])
@@ -113,8 +113,12 @@ default set and edits it:
```sh
# The default: everything except macOS Intel and Termux
python scripts/release.py release --commit "$(git rev-parse origin/main)" --remote origin
# Only the Windows bundles (and the Store bundle) and the Docker image, no tests
# Only the signed Windows bundles and the Docker image, no tests
python scripts/release.py release --commit "$(git rev-parse origin/main)" --only windows,docker --remote origin
# Everything except the signed Windows pipeline (the Store bundle still builds and is submitted)
python scripts/release.py release --commit "$(git rev-parse origin/main)" --without windows --remote origin
# Only the Store bundle: no Azure signing anywhere
python scripts/release.py release --commit "$(git rev-parse origin/main)" --only windows-store --remote origin
# Everything, plus macOS Intel, without the install/update E2E
python scripts/release.py release --commit "$(git rev-parse origin/main)" --with macos-intel --without install-e2e --remote origin
```
@@ -126,7 +130,8 @@ lists every lane.
| Lane | Default | What it controls |
|---|---|---|
| `docker` | on | build and publish the Docker image |
| `windows` | on | Windows x64 and arm64 MSIX, the universal bundle and the Store bundle (they build together) |
| `windows` | on | signed Windows x64 and arm64 MSIX and the universal bundle, which feed the update channel |
| `windows-store` | on | the unsigned Microsoft Store bundle, submitted to Partner Center (needs no Azure signing) |
| `macos-arm64` | on | macOS Apple Silicon DMG and ZIP |
| `macos-intel` | off | macOS Intel DMG and ZIP: unsupported on `main`, and its runner adds over an hour |
| `termux` | off | Termux (Android aarch64) APT package |
@@ -152,7 +157,8 @@ requires each job they remove to report `skipped`, and every other gated job to
report `success`. A job that ran although a lane removes it also blocks the
release. A claim cut before lanes existed (`skipBundles`, `skipTests`,
`includeMacosIntel`, `includeTermux`) reads as the lane set that gated the same
jobs.
jobs. A claim without `lanesRevision` was cut when `windows` also meant the Store
bundle, so its `windows` reads as `windows` plus `windows-store`.
What a release publishes follows what it built:
@@ -163,7 +169,8 @@ What a release publishes follows what it built:
- **The desktop channel moves only for the full desktop set** (`windows` and
`macos-arm64`). A partial set still stages its packages, records a candidate
manifest with exactly the targets it built, and submits the Store bundle when
`windows` is on. The protected R2 stable head, the App Installer and macOS
`windows-store` is on. A Store-only release has no package rows, only the Store
bundle file, and moves no channel. The protected R2 stable head, the App Installer and macOS
feeds, the APT channel, downloads page,
`releases/stable/release-candidates.json` and the signed-package baseline stay
on the previous full release. Source checkouts on the official repository