feat(release): write the stable archive under the attempt ref
The archive readers already keyed every object on releases/tag/<attempt>; the writers still keyed them on the plain payload tag, so a stable run wrote releases/tag/vX.Y.Z/ while publish read releases/tag/rc.N-vX.Y.Z/ and found nothing. Derive one archive ref at admission (validate emits archive-tag, jobs export HERMES_ARCHIVE_TAG) and use it at every writer and reader of a releases/tag/<x>/ key in the stable path. The plain vX.Y.Z keeps naming the payload identity: build stamps, package versions, feed versions, and GitHub release bodies. Canary and channel builds keep archive == payload tag, so their keys are unchanged.
This commit is contained in:
7
.github/workflows/desktop-bundle-smoke.yml
vendored
7
.github/workflows/desktop-bundle-smoke.yml
vendored
@@ -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")
|
||||
|
||||
36
.github/workflows/desktop-bundled-release.yml
vendored
36
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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/<ref>/ 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 }}
|
||||
|
||||
1
.github/workflows/stable-release.yml
vendored
1
.github/workflows/stable-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/<ref>/
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'<Bundle><Identity Name="Product" Publisher="CN=Test" Version="{WINDOWS_VERSION}"/><Packages><Package Type="application" Architecture="arm64"/><Package Type="application" Architecture="x64"/></Packages></Bundle>')
|
||||
(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'),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user