mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
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:
17 files changed
+290
-97
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")) == \
|
||||
|
||||
@@ -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"})}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user