diff --git a/.github/workflows/desktop-bundle-smoke.yml b/.github/workflows/desktop-bundle-smoke.yml index 92cd2a7753..ea9c04b0ba 100644 --- a/.github/workflows/desktop-bundle-smoke.yml +++ b/.github/workflows/desktop-bundle-smoke.yml @@ -18,6 +18,10 @@ on: tag: default: '' type: string + archive-tag: + description: Archive ref the handoffs are staged under (attempt ref for stable phases). + default: '' + type: string commit-build: default: false type: boolean @@ -70,6 +74,7 @@ jobs: env: &smoke-env RELEASE_COMMIT: ${{ inputs.sha }} RELEASE_TAG: ${{ inputs.tag }} + ARCHIVE_TAG: ${{ inputs.archive-tag }} COMMIT_BUILD: ${{ inputs.commit-build }} CHANNEL_BUILD: ${{ inputs.channel-build }} CHANNEL_REQUEST_SHA256: ${{ inputs.channel-request-sha256 }} @@ -128,7 +133,7 @@ jobs: name=windows-universal; pattern="$product-*-win.msixbundle" ;; *) echo '::error::unsupported native smoke target'; exit 1 ;; esac - args=(--tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT") + args=(--tag "${ARCHIVE_TAG:-$RELEASE_TAG}" --commit "$RELEASE_COMMIT") if [ "$COMMIT_BUILD" = true ]; then test -z "$RELEASE_TAG" args=(--commit-build "$RELEASE_COMMIT") diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 3b920f8369..9594705ebc 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -197,6 +197,9 @@ jobs: receiver-candidate: ${{ steps.admission.outputs.receiver-candidate }} public-root: ${{ steps.admission.outputs.public-root }} public-base: ${{ steps.admission.outputs.public-base }} + # The archive ref: the attempt ref for stable phases, the payload tag + # for canary and commit builds. Every releases/tag// write reads it. + archive-tag: ${{ steps.admission.outputs.archive-tag }} channel-build: ${{ ((inputs.disposable_channel != '' || inputs.channel != '') && steps.allocate.outputs.channel_build) || '' }} channel-request-sha256: ${{ ((inputs.disposable_channel != '' || inputs.channel != '') && steps.allocate.outputs.channel_request_sha256) || '' }} disposable-run: ${{ (inputs.disposable_channel != '' && github.run_id) || inputs.disposable_run || '' }} @@ -277,6 +280,7 @@ jobs: echo '::error::disposable_run requires a pinned channel build'; exit 1 fi printf 'public-root=%s\npublic-base=%s\n' "$CLOUDFLARE_R2_PUBLIC_URL" "$CLOUDFLARE_R2_PUBLIC_URL" >> "$GITHUB_OUTPUT" + printf 'archive-tag=%s\n' "${RELEASE_CLAIM_TAG:-$RELEASE_TAG}" >> "$GITHUB_OUTPUT" if [ -n "${CHANNEL_BUILD:-}" ]; then if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ] || [ -z "$DEFAULT_BRANCH" ] || [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ] || [ "$GITHUB_WORKFLOW_REF" != "$GITHUB_REPOSITORY/.github/workflows/desktop-bundled-release.yml@refs/heads/$DEFAULT_BRANCH" ]; then echo '::error::channel builds require the default-branch dispatch workflow'; exit 1 @@ -391,6 +395,8 @@ jobs: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} RELEASE_CLAIM_TAG: ${{ inputs.claim-tag }} + # Archive ref for handoff stage/fetch: attempt ref for stable phases. + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }} @@ -660,7 +666,7 @@ jobs: --commit "$RELEASE_COMMIT" \ --name "$TARGET" --root apps/desktop/release "${args[@]}" else - python -m scripts.releases.handoff stage --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff stage --tag "${HERMES_ARCHIVE_TAG:-$HERMES_PAYLOAD_TAG}" --commit "$RELEASE_COMMIT" \ --name "$TARGET" --root apps/desktop/release "${args[@]}" fi @@ -737,6 +743,8 @@ jobs: HERMES_DESKTOP_VARIANT: bundled HERMES_PAYLOAD_TAG: ${{ inputs.tag }} RELEASE_CLAIM_TAG: ${{ inputs.claim-tag }} + # Archive ref for handoff stage/fetch: attempt ref for stable phases. + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }} @@ -994,7 +1002,7 @@ jobs: --name "$TARGET" --root apps/desktop/release \ --include '*.dmg' --include '*.zip' --include '*.blockmap' else - python -m scripts.releases.handoff stage --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff stage --tag "${HERMES_ARCHIVE_TAG:-$HERMES_PAYLOAD_TAG}" --commit "$RELEASE_COMMIT" \ --name "$TARGET" --root apps/desktop/release "${args[@]}" fi @@ -1069,6 +1077,8 @@ jobs: format: ${{ matrix.format }} sha: ${{ needs.validate.outputs.sha }} tag: ${{ inputs.tag }} + # The archive ref the handoff was staged under (attempt ref for stable). + archive-tag: ${{ needs.validate.outputs.archive-tag }} commit-build: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' }} channel-build: ${{ needs.validate.outputs.channel-build }} channel-request-sha256: ${{ needs.validate.outputs.channel-request-sha256 }} @@ -1098,6 +1108,8 @@ jobs: format: msix sha: ${{ needs.validate.outputs.sha }} tag: ${{ inputs.tag }} + # The archive ref the handoff was staged under (attempt ref for stable). + archive-tag: ${{ needs.validate.outputs.archive-tag }} commit-build: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' }} channel-build: ${{ needs.validate.outputs.channel-build }} channel-request-sha256: ${{ needs.validate.outputs.channel-request-sha256 }} @@ -1127,6 +1139,8 @@ jobs: format: msixbundle sha: ${{ needs.validate.outputs.sha }} tag: ${{ inputs.tag }} + # The archive ref the handoff was staged under (attempt ref for stable). + archive-tag: ${{ needs.validate.outputs.archive-tag }} commit-build: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' }} channel-build: ${{ needs.validate.outputs.channel-build }} channel-request-sha256: ${{ needs.validate.outputs.channel-request-sha256 }} @@ -1152,6 +1166,8 @@ jobs: CHANNEL_BUILD: ${{ needs.validate.outputs.channel-build }} CHANNEL_REQUEST_SHA256: ${{ needs.validate.outputs.channel-request-sha256 }} HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + # Archive ref for the universal-bundle handoff: attempt ref for stable. + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} HERMES_PAYLOAD_VERSION: ${{ needs.validate.outputs.payload-version }} ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder @@ -1240,7 +1256,7 @@ jobs: --commit "$RELEASE_COMMIT" \ --name win32-x64 --name win32-arm64 --root apps/desktop/release --include '*.msix' else - python -m scripts.releases.handoff fetch --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff fetch --tag "${HERMES_ARCHIVE_TAG:-$HERMES_PAYLOAD_TAG}" --commit "$RELEASE_COMMIT" \ --name win32-x64 --name win32-arm64 --root apps/desktop/release --include '*.msix' fi @@ -1302,7 +1318,7 @@ jobs: --commit "$RELEASE_COMMIT" \ --name windows-universal --root apps/desktop/release --include '*.msixbundle' else - python -m scripts.releases.handoff stage --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff stage --tag "${HERMES_ARCHIVE_TAG:-$HERMES_PAYLOAD_TAG}" --commit "$RELEASE_COMMIT" \ --name windows-universal --root apps/desktop/release --include '*.msixbundle' fi @@ -1507,6 +1523,8 @@ jobs: timeout-minutes: 90 env: HERMES_PAYLOAD_TAG: ${{ inputs.tag }} + # Archive ref for the termux handoff: attempt ref for stable phases. + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} HERMES_BUILD_COMMIT: ${{ inputs.build_commit != '' && needs.validate.outputs.channel-build == '' && needs.validate.outputs.sha || '' }} HERMES_RELEASE_COMMIT: ${{ inputs.release-phase != '' && needs.validate.outputs.sha || '' }} HERMES_RELEASE_EPOCH: ${{ needs.validate.outputs.release-epoch }} @@ -1825,7 +1843,7 @@ jobs: --out termux-build/metadata-termux-aarch64.json args+=(--include 'metadata-*.json') fi - python -m scripts.releases.handoff stage --tag "$HERMES_PAYLOAD_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff stage --tag "${HERMES_ARCHIVE_TAG:-$HERMES_PAYLOAD_TAG}" --commit "$RELEASE_COMMIT" \ --name termux --root termux-build "${args[@]}" if [ "$RELEASE_PHASE" = candidate ]; then exit 0; fi # --key-is-full is MANDATORY on every feed-dir upload: without it @@ -2046,6 +2064,7 @@ jobs: manifest-sha256: ${{ steps.manifest.outputs.manifest-sha256 }} env: RELEASE_TAG: ${{ inputs.tag }} + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} HERMES_RELEASE_EPOCH: ${{ needs.validate.outputs.release-epoch }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} @@ -2063,14 +2082,15 @@ jobs: env: RELEASE_COMMIT: ${{ needs.validate.outputs.sha }} run: | - python -m scripts.releases.handoff fetch --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" \ + python -m scripts.releases.handoff fetch --tag "${HERMES_ARCHIVE_TAG:-$RELEASE_TAG}" --commit "$RELEASE_COMMIT" \ --name win32-x64 --name win32-arm64 --name darwin-x64 --name darwin-arm64 \ --name termux --name windows-universal --root candidates \ --include 'metadata-*.json' --include '*.msixbundle' - id: manifest env: RELEASE_NEEDS: ${{ toJSON(needs) }} - run: python -m scripts.bundles.release_artifacts assemble --root candidates --out release-candidates.json + run: python -m scripts.bundles.release_artifacts assemble --root candidates --out release-candidates.json \ + --archive "${HERMES_ARCHIVE_TAG:?stable candidate assembly needs HERMES_ARCHIVE_TAG}" stable-publish: name: Verify published candidate files @@ -2081,6 +2101,7 @@ jobs: timeout-minutes: 90 env: RELEASE_TAG: ${{ inputs.tag }} + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} CANDIDATE_MANIFEST_SHA256: ${{ inputs.manifest-sha256 }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} @@ -2105,6 +2126,7 @@ jobs: timeout-minutes: 60 env: RELEASE_TAG: ${{ inputs.tag }} + HERMES_ARCHIVE_TAG: ${{ needs.validate.outputs.archive-tag }} CANDIDATE_MANIFEST_SHA256: ${{ inputs.manifest-sha256 }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 2f89cbab3c..9ed7470fce 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -363,6 +363,7 @@ jobs: CANDIDATE_MANIFEST_SHA256: ${{ needs.candidates.outputs.manifest-sha256 }} run: | python scripts/render-builds-table.py --tag "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --archive "$RELEASE_CLAIM_TAG" \ --candidate-manifest-sha256 "$CANDIDATE_MANIFEST_SHA256" --candidate-commit "$RELEASE_COMMIT" - name: Set up Docker Buildx for ordered alias promotion uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 diff --git a/scripts/bundles/desktop_prepare.py b/scripts/bundles/desktop_prepare.py index eda9d10bdf..908513e268 100644 --- a/scripts/bundles/desktop_prepare.py +++ b/scripts/bundles/desktop_prepare.py @@ -99,7 +99,6 @@ class BuildRequest: bundle_env: dict[str, str | None] channel_request: dict | None = None release_epoch: int | None = None - archive_tag: str | None = None @classmethod def create(cls, source: Path, *, tag: str | None, commit: str | None, variant: str, @@ -147,7 +146,6 @@ class BuildRequest: commit = require_commit(release_commit) if release_commit else \ git(source, "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}") release_epoch = None - archive_tag = None if tag: canary = re.fullmatch(r"v\d+\.\d+\.\d+\+canary\.(20\d{6}T\d{6}Z)", tag) if canary: @@ -155,12 +153,11 @@ class BuildRequest: .replace(tzinfo=timezone.utc).timestamp()) else: claim_tag = os.environ.get("RELEASE_CLAIM_TAG", "") - # The archive is keyed by the attempt ref; the payload version - # stays plain. Both come from the claim, never from the checkout. + # The payload version stays plain; the claim must name the same + # version as an attempt ref, never the checkout. parsed = parse_attempt_ref(claim_tag) if parsed is None or parsed[0] != version: raise ValueError("stable preparation requires its exact claim tag") - archive_tag = claim_tag claim_object = os.environ.get("RELEASE_CLAIM_OBJECT", "") if not re.fullmatch(r"[a-f0-9]{40}", claim_object) or \ git(source, "rev-parse", f"refs/tags/{claim_tag}") != claim_object: @@ -176,7 +173,7 @@ class BuildRequest: raise ValueError("channel sourceVersion differs from checkout project version") version = channel_request["version"] return cls(source, work, cache, commit, tag, version, variant, current_target(), bundle_env, - channel_request, release_epoch, archive_tag) + channel_request, release_epoch) def data(self) -> dict: return {**asdict(self), "source": str(self.source), "work": str(self.work), "cache": str(self.cache)} diff --git a/scripts/bundles/release_artifacts.py b/scripts/bundles/release_artifacts.py index 63838d2535..e166fbf5a6 100644 --- a/scripts/bundles/release_artifacts.py +++ b/scripts/bundles/release_artifacts.py @@ -157,8 +157,11 @@ def validate_windows_bundle(bundle: Path, windows: list[dict]) -> None: def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path, - *, smoke_results: dict, release_epoch: int) -> dict: - """Bind the native metadata to files already staged by their build jobs.""" + *, smoke_results: dict, release_epoch: int, archive: 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.""" from scripts.releases.handoff import receipt_name, validate_receipt from scripts.releases.r2 import put, staging_key_for @@ -170,7 +173,9 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path, if not file.is_file(): raise ValueError(f"Missing candidate handoff: {name}") receipt = json.loads(file.read_text(encoding="utf-8-sig")) - for row in validate_receipt(receipt, tag, commit, name): + # Handoffs are staged under the archive ref; their bound tag is the + # archive, not the plain payload tag. + for row in validate_receipt(receipt, archive, commit, name): prior = by_name.get(row["path"]) if prior is not None and prior != row: raise ValueError("Candidate handoffs disagree on file receipts") @@ -187,7 +192,7 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path, single(name for name in by_name if name.endswith(".msixbundle") and name.startswith("Store-")) windows = [r for r in rows if r["platform"] == "windows"] validate_windows_bundle(root / universal_name, windows) - files = [{**row, "url": f"{public_base.rstrip('/')}/{staging_key_for(tag, name)}"} + 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} packages = [] @@ -197,17 +202,19 @@ def assemble(root: Path, tag: str, commit: str, public_base: str, out: Path, item = by_name[filename] packages.append({k: v for k, v in {**row, "artifact": {"url": item["url"], "sha256": item["sha256"]}}.items() if k != "filename"}) result = {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch, + "archive": archive, "packages": packages, "files": files, "smoke_results": smoke_results} - validate_candidates(result, tag, commit, public_base, release_epoch) + validate_candidates(result, tag, commit, public_base, release_epoch, archive=archive) if not any(row["platform"] == "termux" for row in packages): raise ValueError("Missing Termux candidate") out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") - put(tag=tag, key="release-candidates.json", file=out, immutable=True) + put(tag=archive, key="release-candidates.json", file=out, immutable=True) return result def materialize(manifest: dict, root: Path, *, public_base: str, store_only: bool = False) -> None: - validate_candidates(manifest, manifest["tag"], manifest["commit"], public_base) + validate_candidates(manifest, manifest["tag"], manifest["commit"], public_base, + archive=manifest["archive"]) files = manifest.get("files", []) if not files or len({item["path"] for item in files}) != len(files): raise ValueError("Missing or duplicate candidate file receipts") @@ -221,7 +228,7 @@ def materialize(manifest: dict, root: Path, *, public_base: str, store_only: boo relative = Path(item["path"]) if relative.is_absolute() or ".." in relative.parts or any(c in item["path"] for c in "\\:%?#") or item["path"].startswith("/") or "//" in item["path"]: raise ValueError("Invalid artifact path") - expected = f"{public_base.rstrip('/')}/releases/tag/{manifest['tag']}/{relative.as_posix()}" + expected = f"{public_base.rstrip('/')}/releases/tag/{manifest['archive']}/{relative.as_posix()}" if item["url"] != expected or not re.fullmatch(r"[a-f0-9]{64}", item["sha256"]): raise ValueError("Invalid artifact URL or digest") target = root / relative @@ -311,7 +318,8 @@ def promote(manifest: dict, root: Path, public_base: str) -> None: if not any(p.name == "InRelease" for p in indexes): raise ValueError("Missing signed APT index") # Every package and index must exist before the first channel write. - finalize(tag=manifest["tag"], dir=root) + # The merged feed points at the attempt archive; the version stays plain. + finalize(tag=manifest["tag"], dir=root, archive=manifest["archive"]) def publish_pointer(key, file): put(tag=manifest["tag"], key=key, file=file, key_is_full=True) digest = hashlib.sha256() @@ -336,6 +344,8 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--root", type=Path, required=True) parser.add_argument("--out", type=Path) parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG")) + parser.add_argument("--archive", default=os.environ.get("HERMES_ARCHIVE_TAG"), + help="Attempt ref the release archive is keyed under when the payload tag is plain vX.Y.Z") parser.add_argument("--commit", default=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")) @@ -365,15 +375,20 @@ def main(argv: list[str] | None = None) -> None: 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) + 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.tag}/release-candidates.json\nmanifest-sha256={sha256_file(args.out)}\n") + 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") manifest = read_admitted_candidate(args.tag, args.commit, args.public_base, - os.environ.get("CANDIDATE_MANIFEST_SHA256", "")) + os.environ.get("CANDIDATE_MANIFEST_SHA256", ""), + archive=args.archive) if args.command == "materialize": materialize(manifest, args.root, public_base=args.public_base, store_only=args.store_only) else: diff --git a/scripts/releases/channel_releases.py b/scripts/releases/channel_releases.py index b455289690..967ea25da9 100644 --- a/scripts/releases/channel_releases.py +++ b/scripts/releases/channel_releases.py @@ -190,7 +190,8 @@ def accepted_stable(publisher: ChannelPublisher, env: dict, tag: str, commit: st if env.get("CANDIDATE_MANIFEST_URL") != publisher.public_base + "/" + key: raise ChannelError("Accepted candidate URL differs from release archive") candidate = decode_json(publisher.reader.read_bytes(key, digest)) - stable.validate_candidates(candidate, payload_tag, commit, publisher.public_base, release_epoch) + stable.validate_candidates(candidate, payload_tag, commit, publisher.public_base, release_epoch, + archive=tag) return candidate @@ -263,7 +264,10 @@ def verify_bootstrap(request: dict, manifest: dict, base: str, repository: str) if not archive.startswith("releases/tag/") or not archive.endswith("/"): raise ChannelError("Bootstrap archive key is invalid") candidate = decode_json(reader.read_bytes(archive + "release-candidates.json")) - stable.validate_candidates(candidate, candidate["tag"], request["commit"], base) + # The manifest names its own archive ref; the URL prefix it was read + # from must be the one it claims. + stable.validate_candidates(candidate, candidate["tag"], request["commit"], base, + archive=archive[len("releases/tag/"):-1]) if decode_json(reader.read_bytes("releases/stable/release-candidates.json")) != candidate: raise ChannelError("Bootstrap must use the current accepted stable transaction") match_accepted_packages(manifest, candidate) @@ -335,9 +339,11 @@ def publish_release(policy: str, env: dict, root: Path) -> dict: if policy == "stable-release" else None accepted = accepted_stable(publisher, env, tag, commit, release_epoch) \ if release_epoch is not None else None - handoff.fetch(payload_tag, commit, list(NATIVE_LEGS), root, + # Native handoffs were staged under the attempt ref for stable attempts + # (identical for canary, where tag == payload_tag). + handoff.fetch(tag, commit, list(NATIVE_LEGS), root, ["metadata-*.json", "*.zip", "*.dmg", "*.blockmap", "*.msixbundle"], public_base=publisher.public_base) - native = read_native_receipts(root, payload_tag, commit) + native = read_native_receipts(root, tag, commit) windows = next(row for row in native["packages"] if row["platform"] == "windows") if policy == "canary-release": expected_windows = canary_windows_version(tag) diff --git a/scripts/releases/darwin.py b/scripts/releases/darwin.py index 0bfd4b9aaa..81f639689b 100644 --- a/scripts/releases/darwin.py +++ b/scripts/releases/darwin.py @@ -57,7 +57,9 @@ def parse_mac_feed(text: str) -> dict[str, Any]: _MAC_URL_PATTERN = re.compile( - r"^/releases/tag/v[0-9A-Za-z.+-]+/[0-9A-Za-z._+-]+\.(zip|dmg)$" + # Attempt refs (rc.N-vX.Y.Z) key the stable archive; plain and canary + # vX.Y.Z[+canary...] tags key the rest. + r"^/releases/tag/(?:v|rc\.[1-9]\d*-v)[0-9A-Za-z.+-]+/[0-9A-Za-z._+-]+\.(zip|dmg)$" ) @@ -75,9 +77,11 @@ def mac_feed_references(text: str) -> list[str]: return references -def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False) -> dict[str, Any]: +def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False, + archive: str | None = None) -> dict[str, Any]: """Validate both native legs, merge them, and rewrite artifact URLs into - the immutable per-release tag namespace.""" + the immutable per-release tag namespace. `archive` keys that namespace + (the attempt ref for stable attempts); `tag` stays the version identity.""" import hermes_yaml as yaml # lazy version = tag[1:] if isinstance(tag, str) and tag.startswith("v") else "" @@ -99,7 +103,7 @@ def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False) -> dict for file_entry in leg["files"]: if file_entry.get("url") not in (f"{prefix}.zip", f"{prefix}.dmg"): raise ValueError(f"Wrong variant or architecture: {file_entry.get('url')}") - rewritten = {**file_entry, "url": f"/releases/tag/{tag}/{file_entry['url']}"} + rewritten = {**file_entry, "url": f"/releases/tag/{archive or tag}/{file_entry['url']}"} prior = files.get(file_entry["url"]) if prior is not None and prior != rewritten: raise ValueError(f"Conflicting artifact: {file_entry['url']}") @@ -109,7 +113,7 @@ def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False) -> dict assert first is not None merged = {**first, "files": list(files.values())} if merged.get("path"): - merged["path"] = f"/releases/tag/{tag}/{merged['path']}" + merged["path"] = f"/releases/tag/{archive or tag}/{merged['path']}" text = yaml.safe_dump(merged, width=100000, default_flow_style=False, sort_keys=False) mac_feed_references(text) # publish only a feed that parses back clean return { @@ -165,7 +169,7 @@ def publish_mac_feed( # finalize (real signed transport) # --------------------------------------------------------------------------- -def finalize(tag: str, dir: str, variant: str | None = None) -> None: +def finalize(tag: str, dir: str, variant: str | None = None, archive: str | None = None) -> None: """Validate both native legs, verify their streamed bytes, then replace the feed pointer (conditional write, then readback).""" if variant and variant != "light": @@ -179,7 +183,7 @@ def finalize(tag: str, dir: str, variant: str | None = None) -> None: for name in sorted(os.listdir(dir)) if name.endswith("-mac.yml") } - plan = merge_mac_feeds(legs, tag, variant == "light") + plan = merge_mac_feeds(legs, tag, variant == "light", archive=archive) def read(key: str) -> dict[str, str] | None: try: diff --git a/scripts/releases/handoff.py b/scripts/releases/handoff.py index 0bd396dcd1..b5c65d22b1 100644 --- a/scripts/releases/handoff.py +++ b/scripts/releases/handoff.py @@ -18,7 +18,12 @@ class MissingReceipt(ValueError): def validate_identity(tag: str, commit: str, name: str) -> None: - if (not isinstance(tag, str) or not tag.startswith("v") or not is_release_version(tag[1:]) + # Stable attempts stage and fetch under their attempt ref, so the receipt + # identity admits rc.N-vX.Y.Z beside plain (and canary) vX.Y.Z tags. + from scripts.releases.versioning import parse_attempt_ref + + if (not isinstance(tag, str) + or not ((tag.startswith("v") and is_release_version(tag[1:])) or parse_attempt_ref(tag)) or not re.fullmatch(r"[a-f0-9]{40}", commit or "") or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name or "")): raise ValueError("Invalid release handoff identity") diff --git a/scripts/releases/r2.py b/scripts/releases/r2.py index 35ab16fb1a..319922299f 100644 --- a/scripts/releases/r2.py +++ b/scripts/releases/r2.py @@ -690,12 +690,12 @@ def put_object( print(f"OK r2: {key} ({size} bytes)") -def finalize(tag: str, dir: str, variant: str | None = None) -> None: +def finalize(tag: str, dir: str, variant: str | None = None, archive: str | None = None) -> None: """Lazy re-export of the Darwin finalize so callers can treat scripts.releases.r2 as the single transport surface.""" from . import darwin as darwin_module - darwin_module.finalize(tag=tag, dir=dir, variant=variant) + darwin_module.finalize(tag=tag, dir=dir, variant=variant, archive=archive) def put( diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index a7e421773b..7accd000f6 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -82,16 +82,21 @@ def stable_windows_version(epoch: object) -> str: def validate_candidates(manifest: dict, tag: str, commit: str, public_base: str, - release_epoch: int | None = None) -> dict: + release_epoch: int | None = None, *, archive: str) -> dict: + """`tag` is the plain payload identity; `archive` is the releases/tag// + prefix every artifact URL must live under. Stable attempts name the attempt + ref as their archive; the two are separate fields and never overloaded.""" require_stable_identity(tag, commit) if manifest.get("schema") != 2 or manifest.get("tag") != tag or manifest.get("commit") != commit or not isinstance(manifest.get("packages"), list): raise ValueError("Candidate manifest does not match release identity") + if manifest.get("archive") != archive: + raise ValueError("Candidate manifest names a different release archive") successful_smoke_results(manifest.get("smoke_results")) admitted_epoch = manifest.get("releaseEpoch") expected_windows_version = stable_windows_version(admitted_epoch) if release_epoch is not None and admitted_epoch != release_epoch: raise ValueError("Candidate release epoch differs from the admitted claim") - prefix = urlsplit(f"{public_base.rstrip('/')}/releases/tag/{tag}/") + prefix = urlsplit(f"{public_base.rstrip('/')}/releases/tag/{archive}/") if prefix.scheme != "https" or prefix.username or prefix.password or not prefix.netloc: raise ValueError("Public release origin must use HTTPS") rows = {} @@ -136,8 +141,10 @@ def windows_version(value: str) -> tuple[int, ...]: def plan_transitions(previous: dict, candidate: dict, public_base: str) -> list[dict]: - old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base) - new = validate_candidates(candidate, candidate.get("tag"), candidate.get("commit"), public_base) + old = validate_candidates(previous, previous.get("tag"), previous.get("commit"), public_base, + archive=previous.get("archive")) + new = validate_candidates(candidate, candidate.get("tag"), candidate.get("commit"), public_base, + archive=candidate.get("archive")) result = [] for target in DESKTOP_TARGETS: left, right = old[target], new[target] @@ -345,14 +352,15 @@ def read_candidate(env: dict) -> dict: return read_manifest(env["CANDIDATE_MANIFEST_URL"], digest) -def read_admitted_candidate(tag: str, commit: str, public_base: str, digest: str) -> dict: +def read_admitted_candidate(tag: str, commit: str, public_base: str, digest: str, *, + archive: str) -> dict: """The page and package promoter consume the same pinned admission.""" if not DIGEST.fullmatch(digest or ""): raise ValueError("Pinned candidate manifest digest is required") require_stable_identity(tag, commit) - manifest = read_manifest(f"{public_base.rstrip('/')}/releases/tag/{tag}/release-candidates.json", + manifest = read_manifest(f"{public_base.rstrip('/')}/releases/tag/{archive}/release-candidates.json", digest, expected_origin=public_base) - validate_candidates(manifest, tag, commit, public_base) + validate_candidates(manifest, tag, commit, public_base, archive=archive) return manifest @@ -396,8 +404,9 @@ def transitions(env: dict) -> None: tag, commit, claim = stable_context(env) base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/") + archive = claim["claim_tag"] candidate = read_candidate(env) - validate_candidates(candidate, tag, commit, base, claim["claim_epoch"]) + validate_candidates(candidate, tag, commit, base, claim["claim_epoch"], archive=archive) try: previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json", expected_origin=base) @@ -414,8 +423,8 @@ def transitions(env: dict) -> None: name = f"acceptance-{row['target']}.json" file = Path(env["RUNNER_TEMP"]) / name file.write_text(json.dumps(transition), encoding="utf-8") - put(tag=tag, key=name, file=file, immutable=True) - url = f"{base}/releases/tag/{tag}/{name}" + put(tag=archive, key=name, file=file, immutable=True) + url = f"{base}/releases/tag/{archive}/{name}" if read_manifest(url) != transition: raise ValueError("Transition manifest read-back mismatch") matrices[transition["platform"]]["include"].append({"arch": transition["arch"], "manifest": url, "old": transition["old"]["tag"], "id": row["target"], "manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()}) @@ -508,7 +517,7 @@ def complete(env: dict) -> None: tag, commit, claim = stable_context(env) base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/") candidate = read_candidate(env) - validate_candidates(candidate, tag, commit, base, claim["claim_epoch"]) + validate_candidates(candidate, tag, commit, base, claim["claim_epoch"], archive=claim["claim_tag"]) def main(argv: list[str] | None = None, env: dict | None = None) -> None: diff --git a/scripts/render-builds-table.py b/scripts/render-builds-table.py index f75df96e2d..526dccd354 100644 --- a/scripts/render-builds-table.py +++ b/scripts/render-builds-table.py @@ -595,6 +595,8 @@ def main() -> int: parser.add_argument("--channel-build") parser.add_argument("--channel-request-sha256") parser.add_argument("--tag", required=False, help="Release tag to render the release-body table for") + parser.add_argument("--archive", default=None, + help="Attempt ref of the release archive when --tag is the plain payload tag") parser.add_argument("--candidate-manifest-sha256", default=None, help="Stable promotion: render smoke admission from this pinned candidate, not RELEASE_NEEDS") parser.add_argument("--candidate-commit", default=None, @@ -632,6 +634,9 @@ def main() -> int: if args.summary_commit and (args.tag or args.pending_run_url): parser.error("--summary-commit cannot be combined with release-body arguments") + # The archive ref (attempt ref for stable attempts) keys every object the + # page lists and writes; the payload tag names the GitHub release body. + archive = args.archive or args.tag if args.channel_build: from hermes_cli.release_channels import ChannelReader @@ -652,7 +657,7 @@ def main() -> int: or not args.tag or not args.r2_base_url or args.summary_commit or args.pending_run_url): parser.error("Candidate rendering requires tag, base URL, manifest SHA256 and commit; no summary or pending mode") candidate = stable.read_admitted_candidate(args.tag, args.candidate_commit, args.r2_base_url, - args.candidate_manifest_sha256) + args.candidate_manifest_sha256, archive=archive) smoke_results = candidate["smoke_results"] if args.summary_commit: @@ -700,9 +705,9 @@ def main() -> int: if not args.r2_base_url: print("::error::--r2-base-url (or CLOUDFLARE_R2_PUBLIC_URL) is required to render the tables") return 1 - names = r2_object_names(args.tag) + names = r2_object_names(archive) if candidate is not None: - admitted = {r2.staging_key_for(args.tag, item["path"]) for item in candidate["files"]} + admitted = {r2.staging_key_for(archive, item["path"]) for item in candidate["files"]} names = [name for name in names if name in admitted] assets = parse_assets(names) incomplete = [] if candidate is not None else incomplete_release_jobs(os.environ.get("RELEASE_NEEDS")) @@ -710,8 +715,8 @@ def main() -> int: # A failed run still owns its tag page, never the channel pointer # consumed by source updates. Missing artifacts never become downloads. if not args.dry_run: - write_page(r2.staging_key_for(args.tag, "index.html"), - render_page(args.tag, assets, args.r2_base_url, incomplete, args.run_url, + write_page(r2.staging_key_for(archive, "index.html"), + render_page(archive, assets, args.r2_base_url, incomplete, args.run_url, repo=args.repo, smoke_results=smoke_results), args.r2_base_url) # Keep the per-tag diagnostic page even when GitHub cannot supply a draft. diff --git a/tests/scripts/test_desktop_preparation.py b/tests/scripts/test_desktop_preparation.py index 3f97d2613b..2caf3c9538 100644 --- a/tests/scripts/test_desktop_preparation.py +++ b/tests/scripts/test_desktop_preparation.py @@ -51,7 +51,8 @@ def test_stable_build_accepts_the_admitted_commit_before_the_final_tag_exists(tm assert request.commit == commit assert request.version == "1.2.4" - assert request.archive_tag == "rc.1-v1.2.4" + # The payload identity stays plain; the attempt ref lives only in the claim env. + assert request.tag == "v1.2.4" assert request.release_epoch == 1787965323 env = identity_environment(request, "bundled", {"HERMES_RELEASE_EPOCH": "1"}) assert env["HERMES_RELEASE_EPOCH"] == "1787965323" diff --git a/tests/scripts/test_release_artifacts.py b/tests/scripts/test_release_artifacts.py index 5582b4d45f..6a9349d97d 100644 --- a/tests/scripts/test_release_artifacts.py +++ b/tests/scripts/test_release_artifacts.py @@ -69,6 +69,7 @@ def staged_candidate(tmp_path, r2_server, https_origin): from scripts.releases import handoff tag, commit, base = 'v1.2.3', 'a' * 40, https_origin.base + attempt = 'rc.2-v1.2.3' https_origin.store = r2_server.store legs, mac_bytes = _inputs('1.2.3') built = tmp_path / 'built' @@ -108,24 +109,27 @@ def staged_candidate(tmp_path, r2_server, https_origin): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(f'index transport fixture: {name}'.encode()) includes.append('apt/**/*') - handoff.stage(tag, commit, handoff_name, built, includes) + handoff.stage(attempt, commit, handoff_name, built, includes) bundle = built / 'Product-win.msixbundle' with zipfile.ZipFile(bundle, 'w') as archive: archive.writestr('AppxMetadata/AppxBundleManifest.xml', f'') (built / 'Store-Product-win.msixbundle').write_bytes(b'Store bundle transport fixture') - handoff.stage(tag, commit, 'windows-universal', built, ['*.msixbundle']) + handoff.stage(attempt, commit, 'windows-universal', built, ['*.msixbundle']) fetched = tmp_path / 'fetched' names = ['win32-x64', 'win32-arm64', 'darwin-x64', 'darwin-arm64', 'termux', 'windows-universal'] - handoff.fetch(tag, commit, names, fetched, ['metadata-*.json', '*.msixbundle']) + 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) + smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=attempt) + 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'} assert all(not file['path'].startswith(('handoff-', 'metadata-')) for file in manifest['files']) + assert all(file['url'].startswith(f'{base}/releases/tag/{attempt}/') for file in manifest['files']) puts = [path for method, path, _ in r2_server.requests if method == 'PUT'] - assert puts == [f'/hermes-releases/releases/tag/{tag}/release-candidates.json'] - assert all(key.startswith(f'releases/tag/{tag}/') for key in r2_server.store) + assert puts == [f'/hermes-releases/releases/tag/{attempt}/release-candidates.json'] + assert all(key.startswith(f'releases/tag/{attempt}/') for key in r2_server.store) + assert not any(key.startswith(f'releases/tag/{tag}/') for key in r2_server.store) return manifest, fetched, base @@ -146,7 +150,7 @@ def test_bootstrap_reuses_published_candidate_and_rejects_substitution(staged_ca published = {'draft': False, 'prerelease': False, 'published_at': 'fixture-published'} monkeypatch.setattr(channel_releases.stable, 'output', lambda args: candidate['commit'] if '/commits/' in args[2] else json.dumps(published)) - r2_server.store['releases/stable/release-candidates.json'] = r2_server.store[f"releases/tag/{candidate['tag']}/release-candidates.json"] + r2_server.store['releases/stable/release-candidates.json'] = r2_server.store[f"releases/tag/{candidate['archive']}/release-candidates.json"] assert channel_releases.verify_bootstrap(request, manifest, base, 'fixture/repo') substituted = copy.deepcopy(manifest) substituted['packages'][0]['artifact']['sha256'] = 'f' * 64 @@ -167,19 +171,19 @@ 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) + smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive']) 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) + smoke_results=SMOKE_RESULTS, release_epoch=RELEASE_EPOCH, archive=manifest['archive']) def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_server, staged_candidate): manifest, _, base = staged_candidate tag, commit = manifest['tag'], manifest['commit'] - raw = r2_server.store[f'releases/tag/{tag}/release-candidates.json'][0] - args = ['--tag', tag, '--commit', commit, '--public-base', base] + raw = r2_server.store[f"releases/tag/{manifest['archive']}/release-candidates.json"][0] + args = ['--tag', tag, '--archive', manifest['archive'], '--commit', commit, '--public-base', base] monkeypatch.setenv('CANDIDATE_MANIFEST_SHA256', hashlib.sha256(raw).hexdigest()) artifacts.main(['materialize', *args, '--root', str(tmp_path / 'store'), '--store-only']) assert [p.name for p in (tmp_path / 'store').iterdir()] == ['Store-Product-win.msixbundle'] @@ -208,7 +212,7 @@ def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_ser 'releases/darwin/stable/stable-mac.yml', 'releases/win32/stable/stable.appinstaller', 'releases/termux/stable/dists/hermes-stable/Release', 'releases/termux/stable/dists/hermes-stable/InRelease')] for item in manifest['files']: - assert (tmp_path / 'promote' / item['path']).read_bytes() == r2_server.store[f'releases/tag/{tag}/{item["path"]}'][0] + assert (tmp_path / 'promote' / item['path']).read_bytes() == r2_server.store[f"releases/tag/{manifest['archive']}/{item['path']}"][0] descriptor = ET.fromstring(r2_server.store['releases/win32/stable/stable.appinstaller'][0]) assert descriptor.attrib == { 'Uri': base + '/releases/win32/stable/stable.appinstaller', @@ -216,7 +220,7 @@ def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_ser } assert descriptor.find('{*}MainBundle').attrib == { 'Name': 'Product', 'Publisher': 'CN=Test', 'Version': WINDOWS_VERSION, - 'Uri': base + '/releases/tag/v1.2.3/Product-win.msixbundle'} + 'Uri': base + f"/releases/tag/{manifest['archive']}/Product-win.msixbundle"} pointer = 'releases/win32/stable/stable.appinstaller' original = r2_server.store[pointer] r2_server.corrupt_put = pointer @@ -227,7 +231,7 @@ def test_candidate_publication_and_store_selection(tmp_path, monkeypatch, r2_ser r2_server.corrupt_put = None r2_server.store[pointer] = original before = dict(r2_server.store) - r2_server.store[f'releases/tag/{tag}/Product-win.msixbundle'] = (b'corrupt', '"e"') + r2_server.store[f"releases/tag/{manifest['archive']}/Product-win.msixbundle"] = (b'corrupt', '"e"') r2_server.requests.clear() with pytest.raises(ValueError, match='digest mismatch'): artifacts.promote(manifest, tmp_path / 'broken', base) @@ -290,11 +294,14 @@ def candidate_workflow_step(tmp_path, r2_server, staged_candidate): def run(job_name, step, needs, *, pinned=digest, ambient_needs=None): expressions = { - '${{ inputs.tag }}': manifest['tag'], '${{ inputs.manifest-sha256 }}': pinned, + '${{ inputs.tag }}': manifest['tag'], '${{ inputs.claim-tag }}': manifest['archive'], + '${{ inputs.manifest-sha256 }}': pinned, '${{ needs.validate.outputs.sha }}': manifest['commit'], '${{ github.token }}': 'inert', '${{ needs.validate.outputs.release-epoch }}': str(manifest['releaseEpoch']), '${{ needs.admit.outputs.tag }}': manifest['tag'], + '${{ needs.admit.outputs.claim-tag }}': manifest['archive'], '${{ needs.admit.outputs.commit }}': manifest['commit'], + '${{ needs.validate.outputs.archive-tag }}': manifest['archive'], '${{ needs.candidates.outputs.manifest-sha256 }}': pinned, '${{ vars.CLOUDFLARE_R2_PUBLIC_URL }}': base, '${{ toJSON(needs) }}': json.dumps(needs), } @@ -324,11 +331,11 @@ def test_candidate_smoke_survives_real_promotion_and_renderer(tmp_path, r2_serve needs = {name: {'result': 'success'} for name in jobs['candidate-manifest']['needs']} result = run('candidate-manifest', candidate, needs) assert result.returncode == 0, result.stdout + result.stderr - stored = json.loads(r2_server.store[f"releases/tag/{manifest['tag']}/release-candidates.json"][0]) + stored = json.loads(r2_server.store[f"releases/tag/{manifest['archive']}/release-candidates.json"][0]) assert stored['smoke_results'] == SMOKE_RESULTS assert f'manifest-sha256={artifacts.sha256_file(tmp_path / "release-candidates.json")}' in (tmp_path / 'outputs').read_text(encoding='utf-8-sig') # An unrelated orphan object must not acquire the candidate's Passed label. - orphan = f"releases/tag/{manifest['tag']}/HermesBundled-1.2.3-linux-x64.AppImage" + orphan = f"releases/tag/{manifest['archive']}/HermesBundled-1.2.3-linux-x64.AppImage" r2_server.store[orphan] = (b'orphan transport fixture', '"e"') for step in jobs['controller-promote']['steps']: if 'run' in step: @@ -341,7 +348,7 @@ def test_candidate_smoke_survives_real_promotion_and_renderer(tmp_path, r2_serve assert output.count('Passed') == len(SMOKE_RESULTS) assert 'Not run' not in output and 'Build incomplete' not in output assert orphan not in output - assert base + f"/releases/tag/{manifest['tag']}/HermesBundled-1.2.3-win-x64.msix" in output + assert base + f"/releases/tag/{manifest['archive']}/HermesBundled-1.2.3-win-x64.msix" in output with https_origin.opener(base + '/releases/stable/index.html', timeout=5) as response: assert response.read().decode() == page @@ -363,7 +370,7 @@ def test_candidate_smoke_admission_fails_before_publication(tmp_path, r2_server, result = run('candidate-manifest', candidate, failed) assert result.returncode != 0 and name in result.stderr, result.stdout + result.stderr assert not any(method == 'PUT' for method, _, _ in r2_server.requests) - key = f"releases/tag/{manifest['tag']}/release-candidates.json" + key = f"releases/tag/{manifest['archive']}/release-candidates.json" raw = r2_server.store[key][0] for fault, message in [('legacy', 'Candidate manifest'), ('missing', 'Candidate smoke results'), ('failed', 'smoke-win32=failure'), ('identity', 'release identity'), diff --git a/tests/scripts/test_release_channels.py b/tests/scripts/test_release_channels.py index 51cca36ae9..8ef4529fc9 100644 --- a/tests/scripts/test_release_channels.py +++ b/tests/scripts/test_release_channels.py @@ -535,12 +535,14 @@ def test_accepted_stable_reads_the_release_archive_by_tag(monkeypatch): from scripts.releases import channel_releases from hermes_cli.release_channels import ChannelError, canonical_json tag, commit = "v2.0.0", "c" * 40 + attempt = "rc.1-v2.0.0" with object_server() as (url, objects, headers, requests, faults): pub = publisher(url) # Exercise HTTPS authority validation through the loopback transport. pub.public_base = "https://releases.example" release_epoch = 1_787_965_323 candidate = {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch, + "archive": attempt, "smoke_results": {job: {"result": "success"} for job in channel_releases.stable.SMOKE_JOBS}, "packages": []} for platform in ("macos", "windows"): @@ -549,15 +551,15 @@ def test_accepted_stable_reads_the_release_archive_by_tag(monkeypatch): "version": "2.0.0" if platform == "macos" else "2026.5761.123.0", "identity": "fixture.identity", **({"executableVersion": "2026.5761.123.0"} if platform == "windows" else {}), "teamId": "ABCDEFGHIJ", "publisher": "CN=Fixture", "applicationId": "Fixture", - "artifact": {"url": f"{pub.public_base}/releases/tag/{tag}/fixture-{arch}." + ("zip" if platform == "macos" else "msixbundle"), "sha256": "d" * 64}}) + "artifact": {"url": f"{pub.public_base}/releases/tag/{attempt}/fixture-{arch}." + ("zip" if platform == "macos" else "msixbundle"), "sha256": "d" * 64}}) raw = canonical_json(candidate) - key = f"releases/tag/{tag}/release-candidates.json" + key = f"releases/tag/{attempt}/release-candidates.json" objects[key] = raw candidate_env = {"CANDIDATE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CANDIDATE_MANIFEST_URL": pub.public_base + "/" + key} - assert channel_releases.accepted_stable(pub, candidate_env, tag, commit, release_epoch) == candidate + assert channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch) == candidate faults["stale_public"] = b"{}" with pytest.raises(ChannelError): - channel_releases.accepted_stable(pub, candidate_env, tag, commit, release_epoch) + channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch) def test_request_inputs_are_rejected_before_allocating(): diff --git a/tests/scripts/test_release_handoff.py b/tests/scripts/test_release_handoff.py index 00897716ee..a8f31b1404 100644 --- a/tests/scripts/test_release_handoff.py +++ b/tests/scripts/test_release_handoff.py @@ -9,7 +9,7 @@ from scripts.releases import handoff, r2 from tests.scripts.test_release_r2 import r2_server # noqa: F401 -@pytest.mark.parametrize('tag', ['v1.2.3', 'v1.2.3+canary.20260908T232538Z', None]) +@pytest.mark.parametrize('tag', ['v1.2.3', 'v1.2.3+canary.20260908T232538Z', 'rc.1-v1.2.3', None]) def test_stage_and_fetch_bind_tag_commit_and_files_without_feed_writes(tmp_path, monkeypatch, r2_server, tag): commit = "a" * 40 identity = ['--tag', tag, '--commit', commit] if tag else ['--commit-build', commit] diff --git a/tests/scripts/test_stable_release.py b/tests/scripts/test_stable_release.py index ddabfd1e36..3845d26461 100644 --- a/tests/scripts/test_stable_release.py +++ b/tests/scripts/test_stable_release.py @@ -19,12 +19,15 @@ BASE = "https://releases.example" ROOT = Path(__file__).resolve().parents[2] -def candidates(tag, commit, digest): +def candidates(tag, commit, digest, archive=None): + """A desktop candidate manifest. `archive` is the R2 prefix ref the + manifest itself names; the payload `tag` stays plain vX.Y.Z.""" packages = [] second = 100 + int(tag.rsplit('.', 1)[1]) release_epoch = int((datetime(2026, 8, 29, 1, 0, tzinfo=timezone.utc) + timedelta(seconds=second)).timestamp()) native_version = f"2026.5761.{second}.0" + ref = archive or tag for platform in ("windows", "macos"): for arch in ("x64", "arm64"): packages.append({ @@ -34,9 +37,10 @@ def candidates(tag, commit, digest): **({"executableVersion": native_version} if platform == "windows" else {}), **({"publisher": "CN=Test", "applicationId": "App"} if platform == "windows" else {"teamId": "ABCDEFGHIJ"}), "artifact": {"sha256": digest, - "url": f"{BASE}/releases/tag/{tag}/{arch}" + (".msixbundle" if platform == "windows" else ".zip")}, + "url": f"{BASE}/releases/tag/{ref}/{arch}" + (".msixbundle" if platform == "windows" else ".zip")}, }) return {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch, + "archive": ref, "packages": packages, "smoke_results": {name: {"result": "success"} for name in ( "smoke-darwin", "smoke-win32", "smoke-win32-universal")}} @@ -70,14 +74,35 @@ def test_gate_requires_every_success_including_real_cli(tmp_path): assert "publication=cancelled" in result.stderr +def test_validate_candidates_keys_the_archive_by_the_attempt_ref(): + commit = "b" * 40 + manifest = candidates("v1.2.4", commit, "2" * 64, archive="rc.2-v1.2.4") + assert validate_candidates(manifest, manifest["tag"], commit, BASE, archive="rc.2-v1.2.4") + # The archive ref is the URL prefix; the payload tag stays the identity. + assert manifest["packages"][0]["artifact"]["url"].startswith(f"{BASE}/releases/tag/rc.2-v1.2.4/") + with pytest.raises(ValueError, match="archive"): + validate_candidates(manifest, manifest["tag"], commit, BASE, archive="rc.1-v1.2.4") + misnamed = copy.deepcopy(manifest) + misnamed["archive"] = "rc.1-v1.2.4" + with pytest.raises(ValueError, match="archive"): + validate_candidates(misnamed, manifest["tag"], commit, BASE, archive="rc.2-v1.2.4") + missing = copy.deepcopy(manifest) + del missing["archive"] + with pytest.raises(ValueError, match="archive"): + validate_candidates(missing, manifest["tag"], commit, BASE, archive="rc.2-v1.2.4") + # A canary-shaped archive ref (the payload tag itself) still validates. + assert validate_candidates(candidates("v1.2.4", commit, "2" * 64), + "v1.2.4", commit, BASE, archive="v1.2.4") + + def test_transitions_bind_all_arches_identity_version_and_archive(): old = candidates("v1.2.3", "a" * 40, "1" * 64) old["schema"] = 1 del old["smoke_results"] with pytest.raises(ValueError, match="does not match release identity"): - validate_candidates(old, old["tag"], old["commit"], BASE) + validate_candidates(old, old["tag"], old["commit"], BASE, archive=old["archive"]) old = candidates("v1.2.3", "a" * 40, "1" * 64) - new = candidates("v1.2.4", "b" * 40, "2" * 64) + new = candidates("v1.2.4", "b" * 40, "2" * 64, archive="rc.1-v1.2.4") require_stable_identity(new["tag"], new["commit"]) for tag in ("v1.2.4+canary.20260907T143420Z", "v1.2.4-rc", "rc.1-v1.2.4"): with pytest.raises(ValueError): @@ -85,12 +110,14 @@ def test_transitions_bind_all_arches_identity_version_and_archive(): transitions = plan_transitions(old, new, BASE) assert {row["target"] for row in transitions} == {"windows-x64", "windows-arm64", "macos-x64", "macos-arm64"} assert all(row["transition"]["new"]["commit"] == new["commit"] for row in transitions) + assert all(row["transition"]["new"]["artifact"]["url"].startswith(f"{BASE}/releases/tag/{new['archive']}/") + for row in transitions) missing = copy.deepcopy(new) missing["packages"].pop() with pytest.raises(ValueError, match="both architectures"): plan_transitions(old, missing, BASE) with pytest.raises(ValueError, match="identity"): - validate_candidates(new, new["tag"], old["commit"], BASE) + validate_candidates(new, new["tag"], old["commit"], BASE, archive=new["archive"]) for key, value in [("commit", old["commit"]), ("identity", "different"), ("publisher", "CN=Other"), ("version", "9.9.9.0")]: changed = copy.deepcopy(new) changed["packages"][0][key] = value @@ -308,7 +335,7 @@ def test_complete_writes_no_final_tag_and_leaves_the_draft_on_the_attempt_ref(tm from scripts.releases import stable commit, tag_object = _claim_fixture(tmp_path, tag="rc.1-v1.2.3", version="1.2.3") - candidate = candidates("v1.2.3", commit, "c" * 64) + candidate = candidates("v1.2.3", commit, "c" * 64, archive="rc.1-v1.2.3") calls = [] def record(argv):