diff --git a/.github/chainguard/trajectory.public-release-publication.sts.yaml b/.github/chainguard/trajectory.public-release-publication.sts.yaml index a060d1b..9986848 100644 --- a/.github/chainguard/trajectory.public-release-publication.sts.yaml +++ b/.github/chainguard/trajectory.public-release-publication.sts.yaml @@ -6,9 +6,9 @@ subject: repo:DataDog/trajectory:environment:public-release-publication claim_pattern: event_name: workflow_dispatch - ref: refs/heads/main + ref: refs/tags/release-ci-v[0-9]+\.[0-9]+\.[0-9]+(-beta)?-[0-9a-f]{9,40} repository: DataDog/trajectory - job_workflow_ref: DataDog/trajectory/\.github/workflows/public-release-publication\.yml@refs/heads/main + job_workflow_ref: DataDog/trajectory/\.github/workflows/public-release-publication\.yml@refs/tags/release-ci-v[0-9]+\.[0-9]+\.[0-9]+(-beta)?-[0-9a-f]{9,40} permissions: actions: write diff --git a/.github/scripts/public_release_from_source.py b/.github/scripts/public_release_from_source.py index 06f8b78..5b359fa 100644 --- a/.github/scripts/public_release_from_source.py +++ b/.github/scripts/public_release_from_source.py @@ -53,16 +53,24 @@ def build_request( target_sha: str, ) -> tuple[dict[str, object], dict[str, object]]: if not mirror.VERSION_RE.fullmatch(version): - raise mirror.MirrorError("version must be canonical X.Y.Z") + raise mirror.MirrorError("version must be canonical X.Y.Z or X.Y.Z-beta") + release_mode = mirror.release_mode_for_version(version) + prerelease = release_mode == "beta" + make_latest = release_mode == "full" tag = f"v{version}" release = source_client.get_release_by_tag(tag) if release is None: raise mirror.MirrorError("source release does not exist") - if release.get("draft") is not False or release.get("prerelease") is not False: - raise mirror.MirrorError("source release must be published and stable") + if ( + release.get("draft") is not False + or release.get("prerelease") is not prerelease + ): + raise mirror.MirrorError("source release state does not match its version channel") latest = source_client.get_latest_release() - if latest.get("id") != release.get("id") or latest.get("tag_name") != tag: - raise mirror.MirrorError("source release must be GitHub latest") + is_latest = latest.get("id") == release.get("id") and latest.get("tag_name") == tag + if is_latest is not make_latest: + state = "latest" if make_latest else "non-latest" + raise mirror.MirrorError(f"source release must be GitHub {state}") name = mirror.require_string(release.get("name"), "source release name") body = mirror.require_string(release.get("body"), "source release body", nonempty=False) @@ -91,6 +99,7 @@ def build_request( raise mirror.MirrorError("source release exceeds the bounded mirror size") request: dict[str, object] = { + "release_mode": release_mode, "version": version, "tag": tag, "release": { @@ -100,6 +109,8 @@ def build_request( "name": name, "body": body, "target_commitish": target_sha, + "prerelease": prerelease, + "make_latest": make_latest, }, "asset_manifest": {"assets": assets}, "published_at": published_at, @@ -113,8 +124,24 @@ def validate_metadata(metadata: dict[str, object], request: dict[str, object]) - "tag": request["tag"], "released_at": request["published_at"], } - if metadata.get("stable") != expected or metadata.get("beta") != expected: - raise mirror.MirrorError("RELEASES.json must match the mirrored stable release") + if request["release_mode"] == "full": + if metadata.get("stable") != expected or metadata.get("beta") != expected: + raise mirror.MirrorError( + "RELEASES.json stable and beta rings must match the stable release" + ) + return + stable = metadata.get("stable") + if not isinstance(stable, dict): + raise mirror.MirrorError("RELEASES.json must preserve stable metadata") + stable_version = stable.get("version") + if ( + not isinstance(stable_version, str) + or mirror.release_mode_for_version(stable_version) != "full" + or stable.get("tag") != f"v{stable_version}" + ): + raise mirror.MirrorError("RELEASES.json stable metadata must remain stable") + if metadata.get("beta") != expected: + raise mirror.MirrorError("RELEASES.json beta metadata must match the beta release") def apply_release(args: argparse.Namespace) -> dict[str, object]: @@ -152,7 +179,7 @@ def apply_release(args: argparse.Namespace) -> dict[str, object]: "target_sha": args.expected_target_sha, "workflow_run_id": args.workflow_run_id, "assets": request["asset_manifest"]["assets"], - "latest": True, + "latest": request["release"]["make_latest"], } args.receipt_out.parent.mkdir(parents=True, exist_ok=True) args.receipt_out.write_bytes(mirror.canonical_json(receipt) + b"\n") diff --git a/.github/scripts/public_release_mirror.py b/.github/scripts/public_release_mirror.py index cb80f15..d7930c6 100755 --- a/.github/scripts/public_release_mirror.py +++ b/.github/scripts/public_release_mirror.py @@ -31,7 +31,9 @@ OCTO_STS_DOMAIN = "webhooks.build.datadoghq.com" OCTO_STS_AUDIENCE = "dd-octo-sts" OCTO_STS_POOL_NAME = "dd-octo-sts" -VERSION_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +VERSION_RE = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta)?$" +) SHA256_RE = re.compile(r"^[0-9a-f]{64}$") SOURCE_SHA_RE = re.compile(r"^[0-9a-f]{40}$") TIMESTAMP_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") @@ -149,6 +151,19 @@ def require_nonnegative_int(value: Any, label: str) -> int: return value +def release_mode_for_version(version: str) -> str: + if not VERSION_RE.fullmatch(version): + raise MirrorError("version must be canonical X.Y.Z or X.Y.Z-beta") + return "beta" if version.endswith("-beta") else "full" + + +def release_policy(contract: dict[str, Any], mode: str) -> dict[str, bool]: + modes = contract.get("release_modes") + if not isinstance(modes, dict) or mode not in modes: + raise MirrorError(f"contract has no release policy for mode {mode}") + return modes[mode] + + def validate_contract(contract: dict[str, Any]) -> None: exact_keys( contract, @@ -159,7 +174,7 @@ def validate_contract(contract: dict[str, Any]) -> None: "target", "accepted_release_modes", "required_assets", - "release", + "release_modes", "limits", }, "contract", @@ -173,10 +188,10 @@ def validate_contract(contract: dict[str, Any]) -> None: contract["source_identity"], { "repository", - "workflow_ref", + "workflow_path", + "default_branch", "environment", "event_name", - "ref", "read_policy", "publication_artifact_prefix", }, @@ -184,13 +199,17 @@ def validate_contract(contract: dict[str, Any]) -> None: ) for field in source: require_string(source[field], f"contract.source_identity.{field}") + if source["workflow_path"] != ".github/workflows/public-release-publication.yml": + raise MirrorError("contract.source_identity.workflow_path is not supported") + if source["default_branch"] != "main": + raise MirrorError("contract.source_identity.default_branch must be main") target = exact_keys(contract["target"], {"repository", "environment", "ref"}, "contract.target") for field in target: require_string(target[field], f"contract.target.{field}") - if contract["accepted_release_modes"] != ["full"]: - raise MirrorError("contract must accept only full releases") + if contract["accepted_release_modes"] != ["full", "beta"]: + raise MirrorError("contract must accept full and beta releases") assets = contract["required_assets"] if not isinstance(assets, list) or len(assets) < 2 or len(set(assets)) != len(assets): raise MirrorError("contract.required_assets must contain unique binary names and a checksum") @@ -201,9 +220,21 @@ def validate_contract(contract: dict[str, Any]) -> None: if assets[-1] != "checksums.sha256": raise MirrorError("checksums.sha256 must be the final required asset") - release = exact_keys(contract["release"], {"prerelease", "make_latest"}, "contract.release") - if release != {"prerelease": False, "make_latest": True}: - raise MirrorError("contract.release must require non-prerelease and latest") + release_modes = exact_keys( + contract["release_modes"], {"full", "beta"}, "contract.release_modes" + ) + expected_release_modes = { + "full": {"prerelease": False, "make_latest": True}, + "beta": {"prerelease": True, "make_latest": False}, + } + for mode, expected in expected_release_modes.items(): + release = exact_keys( + release_modes[mode], + {"prerelease", "make_latest"}, + f"contract.release_modes.{mode}", + ) + if release != expected: + raise MirrorError(f"contract.release_modes.{mode} is invalid") limits = exact_keys( contract["limits"], @@ -349,12 +380,15 @@ def validate_request( ) if request["schema_version"] != 1 or request["kind"] != REQUEST_KIND: raise MirrorError("request schema or kind is not supported") - if request["release_mode"] not in contract["accepted_release_modes"]: - raise MirrorError("only full releases may be published publicly") + mode = require_string(request["release_mode"], "request.release_mode") + if mode not in contract["accepted_release_modes"]: + raise MirrorError("only full and beta releases may be published publicly") version = require_string(request["version"], "request.version") if not VERSION_RE.fullmatch(version): - raise MirrorError("request.version must be X.Y.Z without a prerelease suffix") + raise MirrorError("request.version must be canonical X.Y.Z or X.Y.Z-beta") + if release_mode_for_version(version) != mode: + raise MirrorError("request.release_mode does not match request.version") tag = require_string(request["tag"], "request.tag") if tag != f"v{version}": raise MirrorError("request.tag must be v followed by request.version") @@ -373,7 +407,7 @@ def validate_request( }, "request.source", ) - for field in ("repository", "workflow_ref", "environment", "event_name", "ref"): + for field in ("repository", "environment", "event_name"): if source[field] != contract["source_identity"][field]: raise MirrorError(f"request.source.{field} does not match the trusted source") source_workflow_sha = require_string(source["sha"], "request.source.sha") @@ -389,6 +423,20 @@ def validate_request( raise MirrorError( "request.source.candidate_sha must be a lowercase 40-character Git SHA" ) + validation_tag = f"release-ci-v{version}-{candidate_source_sha[:9]}" + expected_ref = f"refs/tags/{validation_tag}" + expected_workflow_ref = ( + f"{contract['source_identity']['repository']}/" + f"{contract['source_identity']['workflow_path']}@{expected_ref}" + ) + if ( + source_workflow_sha != candidate_source_sha + or source["ref"] != expected_ref + or source["workflow_ref"] != expected_workflow_ref + ): + raise MirrorError( + "request source must be the exact immutable release validation tag" + ) if require_positive_int(source["run_id"], "request.source.run_id") != source_run_id: raise MirrorError("request.source.run_id does not match the authenticated source run") @@ -417,10 +465,12 @@ def validate_request( require_string(release["body"], "request.release.body", nonempty=False) if release["target_commitish"] != expected_target_sha: raise MirrorError("request.release.target_commitish must match the workflow checkout") - if release["prerelease"] is not False: - raise MirrorError("prerelease requests must not publish publicly") - if release["make_latest"] is not True: - raise MirrorError("full public releases must set latest") + policy = release_policy(contract, mode) + if { + "prerelease": release["prerelease"], + "make_latest": release["make_latest"], + } != policy: + raise MirrorError("request release metadata does not match its release mode") pilot = validate_pilot_binding(request["pilot"], "request.pilot") @@ -468,8 +518,10 @@ def validate_request( ) if receipt["schema_version"] != 1 or receipt["kind"] != PUBLICATION_RECEIPT_KIND: raise MirrorError("request.publication_receipt schema or kind is not supported") - if receipt["status"] != "published" or receipt["release_mode"] != "full": - raise MirrorError("request.publication_receipt must prove a completed full publication") + if receipt["status"] != "published" or receipt["release_mode"] != mode: + raise MirrorError( + "request.publication_receipt must prove the requested publication mode" + ) expected_identity = ( version, tag, @@ -528,32 +580,37 @@ def validate_source_publication_receipt( contract: dict[str, Any], source_run: dict[str, Any], ) -> None: + mode = request["release_mode"] + receipt_keys = { + "schema_version", + "kind", + "terminal_status", + "mode", + "binding", + "publication", + "guarantees", + "lifecycle", + "blockers", + "outcome", + "mutation_summary", + } + if mode == "full": + receipt_keys.add("candidate_publication_receipt_sha256") exact_keys( receipt, - { - "schema_version", - "kind", - "terminal_status", - "mode", - "binding", - "publication", - "guarantees", - "lifecycle", - "blockers", - "outcome", - "candidate_publication_receipt_sha256", - "mutation_summary", - }, + receipt_keys, "source publication receipt", ) if ( receipt["schema_version"] != 1 or receipt["kind"] != SOURCE_PUBLICATION_RECEIPT_KIND or receipt["terminal_status"] != "success" - or receipt["mode"] != "full" + or receipt["mode"] != mode or receipt["blockers"] != [] ): - raise MirrorError("source publication receipt must prove a successful full publication") + raise MirrorError( + "source publication receipt must prove the requested successful publication" + ) binding = exact_keys( receipt["binding"], @@ -564,21 +621,37 @@ def validate_source_publication_receipt( "workflow", "readiness", "candidate_build", + "public_mirror", "pilot", "signed_tag", "candidate_publication", }, "source publication receipt.binding", ) - for field in ( - "readiness", - "candidate_build", - "pilot", - "signed_tag", - "candidate_publication", - ): + for field in ("readiness", "candidate_build", "pilot", "signed_tag"): if not isinstance(binding[field], dict): raise MirrorError(f"source publication receipt.binding.{field} must be an object") + candidate_publication = binding["candidate_publication"] + if mode == "full" and not isinstance(candidate_publication, dict): + raise MirrorError( + "source publication receipt.binding.candidate_publication must be an object" + ) + if mode == "beta" and candidate_publication is not None: + raise MirrorError( + "beta publication must not claim an intermediate candidate publication" + ) + public_mirror = exact_keys( + binding["public_mirror"], + {"repository", "target_sha"}, + "source publication receipt.binding.public_mirror", + ) + if public_mirror != { + "repository": request["target"]["repository"], + "target_sha": request["target"]["sha"], + }: + raise MirrorError( + "source publication receipt public mirror binding does not match" + ) if ( binding["repository"] != contract["source_identity"]["repository"] or binding["repository"] != request["source"]["repository"] @@ -601,7 +674,7 @@ def validate_source_publication_receipt( }, "source publication receipt.binding.workflow", ) - expected_branch = contract["source_identity"]["ref"].removeprefix("refs/heads/") + expected_branch = contract["source_identity"]["default_branch"] expected_workflow = { "name": source_run["name"], "ref": request["source"]["workflow_ref"], @@ -643,10 +716,12 @@ def validate_source_publication_receipt( "latest", "assets", "url", + "release_branch", }, "source publication receipt.publication", ) release = request["release"] + policy = release_policy(contract, mode) if ( publication["tag"] != request["tag"] or publication["tag_target"] != request["source"]["candidate_sha"] @@ -655,11 +730,11 @@ def validate_source_publication_receipt( or publication["body"] != release["body"] or publication["body_sha256"] != f"sha256:{sha256_bytes(release['body'].encode())}" - or publication["prerelease"] is not False - or publication["latest"] is not True + or publication["prerelease"] is not policy["prerelease"] + or publication["latest"] is not policy["make_latest"] or publication["tag_signature_verified"] is not True ): - raise MirrorError("source publication receipt stable release metadata does not match") + raise MirrorError("source publication receipt release metadata does not match") for field in ( "tag_object_sha", "tag_object_sha256", @@ -671,6 +746,19 @@ def validate_source_publication_receipt( ): require_string(publication[field], f"source publication receipt.publication.{field}") require_positive_int(publication["release_id"], "source publication receipt.publication.release_id") + release_branch = exact_keys( + publication["release_branch"], + {"final", "final_target", "prep", "prep_removed"}, + "source publication receipt.publication.release_branch", + ) + expected_branch = { + "final": f"release/v{request['version']}", + "final_target": request["source"]["candidate_sha"], + "prep": f"release/v{request['version']}-prep", + "prep_removed": True, + } + if release_branch != expected_branch: + raise MirrorError("source publication receipt release branch does not match") raw_assets = publication["assets"] required_names = contract["required_assets"] @@ -719,13 +807,22 @@ def validate_source_publication_receipt( }, "source publication receipt.guarantees", ) - if guarantees != { - "rebuild_performed": False, - "asset_uploads": [], - "asset_reuploads": [], - "metadata_only_promotion": True, - }: - raise MirrorError("source publication receipt does not prove metadata-only full promotion") + if guarantees["rebuild_performed"] is not False: + raise MirrorError("source publication receipt must prove no rebuild") + if guarantees["asset_reuploads"] != []: + raise MirrorError("source publication receipt must prove no asset reuploads") + uploads = guarantees["asset_uploads"] + if not isinstance(uploads, list) or len(set(uploads)) != len(uploads): + raise MirrorError("source publication receipt asset uploads are malformed") + if not set(uploads).issubset(set(contract["required_assets"])): + raise MirrorError("source publication receipt contains an unexpected asset upload") + if mode == "full": + if uploads or guarantees["metadata_only_promotion"] is not True: + raise MirrorError( + "source publication receipt does not prove metadata-only full promotion" + ) + elif guarantees["metadata_only_promotion"] is not False: + raise MirrorError("beta publication cannot claim metadata-only promotion") lifecycle = exact_keys( receipt["lifecycle"], @@ -738,14 +835,19 @@ def validate_source_publication_receipt( if lifecycle["issued_at"] != request["publication_receipt"]["published_at"]: raise MirrorError("source publication receipt timestamp does not match the request") - candidate_receipt_sha = require_string( - receipt["candidate_publication_receipt_sha256"], - "source publication receipt.candidate_publication_receipt_sha256", - ) - if not re.fullmatch(r"sha256:[0-9a-f]{64}", candidate_receipt_sha): - raise MirrorError("source publication receipt candidate digest is not canonical") - if receipt["outcome"] not in ("promoted", "idempotent"): - raise MirrorError("source publication receipt outcome is not a successful full outcome") + if mode == "full": + candidate_receipt_sha = require_string( + receipt["candidate_publication_receipt_sha256"], + "source publication receipt.candidate_publication_receipt_sha256", + ) + if not re.fullmatch(r"sha256:[0-9a-f]{64}", candidate_receipt_sha): + raise MirrorError("source publication receipt candidate digest is not canonical") + if receipt["outcome"] not in ("promoted", "idempotent"): + raise MirrorError( + "source publication receipt outcome is not a successful full outcome" + ) + elif receipt["outcome"] not in ("published", "idempotent"): + raise MirrorError("source publication receipt outcome is not a successful beta outcome") mutation = exact_keys( receipt["mutation_summary"], {"count", "metadata_updates"}, @@ -759,16 +861,20 @@ def validate_source_publication_receipt( def validate_repository_metadata(metadata: dict[str, Any], request: dict[str, Any]) -> None: - stable = metadata.get("stable") - if not isinstance(stable, dict): - raise MirrorError("RELEASES.json must contain stable metadata") + mode = request["release_mode"] + ring = "beta" if mode == "beta" else "stable" + record = metadata.get(ring) + if not isinstance(record, dict): + raise MirrorError(f"RELEASES.json must contain {ring} metadata") expected = { "version": request["version"], "tag": request["tag"], "released_at": request["publication_receipt"]["published_at"], } - if stable != expected: - raise MirrorError("RELEASES.json stable metadata does not match the publication receipt") + if record != expected: + raise MirrorError( + f"RELEASES.json {ring} metadata does not match the publication receipt" + ) def validate_public_download_url(url: str) -> None: @@ -934,7 +1040,7 @@ def create_draft_release(self, request: dict[str, Any]) -> dict[str, Any]: "name": request["release"]["name"], "body": request["release"]["body"], "draft": True, - "prerelease": False, + "prerelease": request["release"]["prerelease"], "generate_release_notes": False, }, ) @@ -977,11 +1083,17 @@ def upload_asset(self, release_id: int, name: str, source: Path) -> dict[str, An raise MirrorError("GitHub asset upload returned a non-object") return value - def publish_release(self, release_id: int) -> dict[str, Any]: + def publish_release( + self, release_id: int, *, prerelease: bool, make_latest: bool + ) -> dict[str, Any]: return self._json( "PATCH", f"/repos/{self.repository}/releases/{release_id}", - payload={"draft": False, "prerelease": False, "make_latest": "true"}, + payload={ + "draft": False, + "prerelease": prerelease, + "make_latest": "true" if make_latest else "false", + }, ) @@ -1089,13 +1201,11 @@ def validate_source_run( repository = run.get("repository") if not isinstance(repository, dict) or repository.get("full_name") != source["repository"]: raise MirrorError("source workflow run repository does not match the trusted source") - expected_path = source["workflow_ref"].split("@", 1)[0].split(source["repository"] + "/", 1)[1] - expected_branch = source["ref"].removeprefix("refs/heads/") + expected_path = source["workflow_path"] expected = { "id": source_run_id, "event": source["event_name"], "path": expected_path, - "head_branch": expected_branch, "status": "completed", "conclusion": "success", } @@ -1105,6 +1215,16 @@ def validate_source_run( head_sha = run.get("head_sha") if not isinstance(head_sha, str) or not SOURCE_SHA_RE.fullmatch(head_sha): raise MirrorError("source workflow run head SHA is invalid") + head_branch = run.get("head_branch") + branch_match = re.fullmatch( + r"release-ci-v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\." + r"(?:0|[1-9][0-9]*)(?:-beta)?-([0-9a-f]{9})", + str(head_branch or ""), + ) + if branch_match is None or branch_match.group(1) != head_sha[:9]: + raise MirrorError( + "source workflow run must use its exact immutable release validation tag" + ) require_string(run.get("name"), "source workflow run name") require_positive_int(run.get("run_attempt"), "source workflow run attempt") return run @@ -1198,7 +1318,7 @@ def validate_release_metadata( "tag_name": request["tag"], "name": request["release"]["name"], "body": request["release"]["body"], - "prerelease": False, + "prerelease": request["release"]["prerelease"], "target_commitish": request["release"]["target_commitish"], } for field, value in expected.items(): @@ -1255,8 +1375,11 @@ def materialize_source_assets( raise MirrorError("source release ID does not match the request") if release.get("tag_name") != request["tag"]: raise MirrorError("source release tag does not match the request") - if release.get("draft") is not False or release.get("prerelease") is not False: - raise MirrorError("source release must already be a published full release") + if ( + release.get("draft") is not False + or release.get("prerelease") is not request["release"]["prerelease"] + ): + raise MirrorError("source release state does not match the requested release mode") by_name = release_assets_by_name(release) required_names = contract["required_assets"] if set(by_name) != set(required_names): @@ -1341,19 +1464,35 @@ def publish_target_release( scratch, ) if release["draft"]: - target_client.publish_release(release["id"]) + target_client.publish_release( + release["id"], + prerelease=request["release"]["prerelease"], + make_latest=request["release"]["make_latest"], + ) action = "published" latest = target_client.get_latest_release() - if latest.get("id") != release["id"] or latest.get("tag_name") != request["tag"]: - target_client.publish_release(release["id"]) + expects_latest = request["release"]["make_latest"] + is_latest = latest.get("id") == release["id"] and latest.get("tag_name") == request["tag"] + if expects_latest and not is_latest: + target_client.publish_release( + release["id"], prerelease=False, make_latest=True + ) latest = target_client.get_latest_release() - if latest.get("id") != release["id"] or latest.get("tag_name") != request["tag"]: - raise MirrorError("published release is not GitHub latest") + is_latest = ( + latest.get("id") == release["id"] + and latest.get("tag_name") == request["tag"] + ) + if is_latest is not expects_latest: + state = "latest" if expects_latest else "non-latest" + raise MirrorError(f"published release is not GitHub {state}") final = target_client.get_release(release["id"]) validate_release_metadata(final, request) - if final.get("draft") is not False or final.get("prerelease") is not False: - raise MirrorError("published release state is not a full public release") + if ( + final.get("draft") is not False + or final.get("prerelease") is not request["release"]["prerelease"] + ): + raise MirrorError("published release state does not match its release mode") final_assets = release_assets_by_name(final) if set(final_assets) != set(contract["required_assets"]): raise MirrorError("published release asset set changed during publication") @@ -1435,7 +1574,7 @@ def apply_release( "asset_manifest_sha256": request["asset_manifest_sha256"], "publication_receipt_sha256": request["publication_receipt_sha256"], "assets": request["asset_manifest"]["assets"], - "latest": True, + "latest": request["release"]["make_latest"], } receipt_out.parent.mkdir(parents=True, exist_ok=True) receipt_out.write_bytes(canonical_json(receipt) + b"\n") diff --git a/.github/workflows/public-release-mirror.yml b/.github/workflows/public-release-mirror.yml index 2522ada..0c9a0db 100644 --- a/.github/workflows/public-release-mirror.yml +++ b/.github/workflows/public-release-mirror.yml @@ -15,7 +15,7 @@ on: default: '' type: string version: - description: Canonical stable version for an approved source-release mirror + description: Canonical X.Y.Z or X.Y.Z-beta version for an approved source-release mirror required: false default: '' type: string diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d7d8744..e5f9948 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,6 +19,7 @@ jobs: python3 - <<'PY' import json import pathlib + import re root = pathlib.Path(".") json_paths = [ @@ -46,6 +47,29 @@ jobs: unexpected = set(releases) - {"stable", "beta"} if unexpected: raise SystemExit(f"unexpected release channels: {sorted(unexpected)}") + if set(releases) != {"stable", "beta"}: + raise SystemExit("RELEASES.json must define stable and beta channels") + version_pattern = re.compile( + r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta)?$" + ) + timestamp_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + for channel in ("stable", "beta"): + record = releases[channel] + if not isinstance(record, dict) or set(record) != { + "version", "tag", "released_at" + }: + raise SystemExit(f"RELEASES.json {channel} metadata has unexpected fields") + version = record["version"] + if not isinstance(version, str) or not version_pattern.fullmatch(version): + raise SystemExit(f"RELEASES.json {channel} version is not canonical") + if record["tag"] != f"v{version}": + raise SystemExit(f"RELEASES.json {channel} tag/version mismatch") + if not isinstance(record["released_at"], str) or not timestamp_pattern.fullmatch( + record["released_at"] + ): + raise SystemExit(f"RELEASES.json {channel} release timestamp is not UTC") + if channel == "stable" and version.endswith("-beta"): + raise SystemExit("RELEASES.json stable channel cannot target a beta release") installer = (root / "install.sh").read_text(encoding="utf-8") latest_asset_url = "https://github.com/$REPO/releases/latest/download/$asset" diff --git a/contracts/public-release-mirror-v1.json b/contracts/public-release-mirror-v1.json index 314df5d..5872f6c 100644 --- a/contracts/public-release-mirror-v1.json +++ b/contracts/public-release-mirror-v1.json @@ -3,10 +3,10 @@ "kind": "trajectory-public-release-mirror-contract", "source_identity": { "repository": "DataDog/trajectory", - "workflow_ref": "DataDog/trajectory/.github/workflows/public-release-publication.yml@refs/heads/main", + "workflow_path": ".github/workflows/public-release-publication.yml", + "default_branch": "main", "environment": "public-release-publication", "event_name": "workflow_dispatch", - "ref": "refs/heads/main", "read_policy": "trajectory-labs.public-release-read", "publication_artifact_prefix": "public-release-publication-" }, @@ -16,7 +16,8 @@ "ref": "refs/heads/main" }, "accepted_release_modes": [ - "full" + "full", + "beta" ], "required_assets": [ "trajectory-darwin-amd64", @@ -34,9 +35,15 @@ "trajectory-mdm-windows-amd64.exe", "checksums.sha256" ], - "release": { - "prerelease": false, - "make_latest": true + "release_modes": { + "full": { + "prerelease": false, + "make_latest": true + }, + "beta": { + "prerelease": true, + "make_latest": false + } }, "limits": { "max_request_bytes": 49152, diff --git a/docs/PUBLIC-RELEASE-AUTOMATION.md b/docs/PUBLIC-RELEASE-AUTOMATION.md index f9a36da..c07b500 100644 --- a/docs/PUBLIC-RELEASE-AUTOMATION.md +++ b/docs/PUBLIC-RELEASE-AUTOMATION.md @@ -1,9 +1,9 @@ # Protected Public Release Publication Public GitHub Releases are published by -`.github/workflows/public-release-mirror.yml`. The workflow is a protected, -full-release-only gate. It does not build, sign, transform, or rename release -artifacts. +`.github/workflows/public-release-mirror.yml`. The workflow is a protected gate +for stable and beta releases. It does not build, sign, transform, or rename +release artifacts. The machine-readable interface is [`contracts/public-release-mirror-v1.json`](../contracts/public-release-mirror-v1.json). @@ -12,8 +12,9 @@ release mode, and canonical asset set. ## Publication Sequence -1. The stable entry in `RELEASES.json` lands on `main` with the exact version, - tag, and source publication timestamp. +1. A release PR lands the exact version, tag, and source publication timestamp + in `RELEASES.json`. Stable releases advance both `stable` and `beta`; beta + releases advance only `beta` and preserve `stable`. 2. The trusted source workflow exchanges its protected GitHub OIDC identity for a short-lived `actions:write` token under the checked-in Octo STS policy. That token can dispatch this workflow but cannot mutate repository @@ -36,9 +37,10 @@ release mode, and canonical asset set. sizes, and checksum contents. 6. Using only its target-scoped job token, the target job creates or resumes a draft release, uploads missing exact assets without overwriting existing - ones, publishes the normal GitHub Release, marks it latest, and revalidates - the final metadata and asset identities. Replaying the same request verifies - the existing release idempotently. + ones, publishes the GitHub Release, and revalidates the final metadata and + asset identities. Stable releases are normal/latest; `vX.Y.Z-beta` releases + are prerelease/non-latest. Replaying the same request verifies the existing + release idempotently. The target environment must allow deployments only from `main`, require maintainer approval, and provide the Octo STS domain and audience as protected @@ -73,6 +75,11 @@ object: Both `asset_manifest.source_sha` and `publication_receipt.source_sha` must equal this value. +Protected publication runs from +`release-ci-vX.Y.Z[-beta]-`, so the authenticated workflow SHA +and candidate SHA must be equal even though both fields remain explicit in the +receipt. A `main`-ref source run is rejected before target mutation. + The manifest and publication receipt SHA256 fields cover their canonical JSON objects after these candidate bindings are populated. The request artifact SHA256 covers the exact request, including both source identities. The retained @@ -80,16 +87,19 @@ target receipt reports these values separately as `source_workflow_sha` and `candidate_source_sha`, along with the authenticated source run attempt; it does not emit an ambiguous `source_sha` field. -The separate terminal source receipt must use the successful `full` schema and -bind the same authenticated run ID, run attempt, workflow SHA, candidate source -SHA, version, tag, publication timestamp, stable release ID/title/body, and -the same manifest assets as the request. It must also prove a metadata-only full -promotion with no rebuild or asset upload. +The separate terminal source receipt must use the successful `full` or `beta` +schema and bind the same authenticated run ID, run attempt, workflow SHA, +candidate source SHA, version, tag, publication timestamp, release +ID/title/body, and the same manifest assets as the request. A full receipt must +also prove metadata-only promotion with no rebuild or asset upload. A beta +receipt proves direct publication of the exact qualified assets without +reuploading an existing asset. ## Fail-Closed Rules -- Only `full` release requests are accepted. Candidate, prerelease, or - suffixed-version requests stop before any target repository mutation. +- Only coherent `full` `X.Y.Z` and `beta` `X.Y.Z-beta` requests are accepted. + Candidate, mismatched mode/version, and other suffixed versions stop before + any target repository mutation. - The release must contain exactly the contract-defined assets. Missing, extra, duplicate, incomplete, renamed, or reordered assets are rejected. - Source evidence must come from exactly one non-expired artifact named from @@ -108,8 +118,9 @@ promotion with no rebuild or asset upload. GitHub API authorization header. - Final release metadata and asset identities are checked again after publication before a success receipt is written. -- `RELEASES.json` must already contain matching stable metadata. The workflow - does not commit repository metadata. +- `RELEASES.json` must already contain matching channel metadata. The workflow + does not commit repository metadata; the reviewed release PR is the metadata + authority. Successful runs retain a content-bound publication receipt as a GitHub Actions artifact for 90 days. diff --git a/tests/test_public_release_from_source.py b/tests/test_public_release_from_source.py index f2d2787..f7e1e89 100644 --- a/tests/test_public_release_from_source.py +++ b/tests/test_public_release_from_source.py @@ -17,14 +17,19 @@ class FakeSourceClient: - def __init__(self, release: dict[str, object]) -> None: + def __init__( + self, + release: dict[str, object], + latest: dict[str, object] | None = None, + ) -> None: self.release = release + self.latest = latest or release def get_release_by_tag(self, tag: str) -> dict[str, object] | None: return self.release if self.release["tag_name"] == tag else None def get_latest_release(self) -> dict[str, object]: - return self.release + return self.latest def release_fixture() -> dict[str, object]: @@ -50,6 +55,19 @@ def release_fixture() -> dict[str, object]: } +def beta_release_fixture() -> dict[str, object]: + release = release_fixture() + release.update( + { + "tag_name": "v0.6.0-beta", + "name": "Trajectory 0.6.0-beta", + "published_at": "2026-09-03T12:34:56Z", + "prerelease": True, + } + ) + return release + + class PublicReleaseFromSourceTests(unittest.TestCase): def test_build_request_uses_only_public_release_facts(self) -> None: release = release_fixture() @@ -91,8 +109,30 @@ def test_build_request_rejects_asset_set_drift(self) -> None: target_sha="a" * 40, ) + def test_build_request_accepts_beta_prerelease_only_when_nonlatest(self) -> None: + release = beta_release_fixture() + request, _ = SOURCE_MIRROR.build_request( + FakeSourceClient( + release, + latest={"id": 9999, "tag_name": "v0.5.37"}, + ), + version="0.6.0-beta", + target_sha="a" * 40, + ) + + self.assertEqual(request["release_mode"], "beta") + self.assertTrue(request["release"]["prerelease"]) + self.assertFalse(request["release"]["make_latest"]) + with self.assertRaisesRegex(SOURCE_MIRROR.mirror.MirrorError, "non-latest"): + SOURCE_MIRROR.build_request( + FakeSourceClient(release), + version="0.6.0-beta", + target_sha="a" * 40, + ) + def test_metadata_must_advance_stable_and_beta_together(self) -> None: request = { + "release_mode": "full", "version": "0.5.37", "tag": "v0.5.37", "published_at": "2026-08-20T23:31:54Z", @@ -111,6 +151,31 @@ def test_metadata_must_advance_stable_and_beta_together(self) -> None: request, ) + def test_beta_metadata_advances_only_beta_ring(self) -> None: + request = { + "release_mode": "beta", + "version": "0.6.0-beta", + "tag": "v0.6.0-beta", + "published_at": "2026-09-03T12:34:56Z", + } + stable = { + "version": "0.5.37", + "tag": "v0.5.37", + "released_at": "2026-08-20T23:31:54Z", + } + beta = { + "version": "0.6.0-beta", + "tag": "v0.6.0-beta", + "released_at": "2026-09-03T12:34:56Z", + } + + SOURCE_MIRROR.validate_metadata({"stable": stable, "beta": beta}, request) + with self.assertRaisesRegex(SOURCE_MIRROR.mirror.MirrorError, "beta metadata"): + SOURCE_MIRROR.validate_metadata( + {"stable": stable, "beta": {**beta, "version": "0.5.37"}}, + request, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_public_release_mirror.py b/tests/test_public_release_mirror.py index 7d35a75..610b529 100644 --- a/tests/test_public_release_mirror.py +++ b/tests/test_public_release_mirror.py @@ -24,8 +24,8 @@ MIRROR = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MIRROR) -SOURCE_WORKFLOW_SHA = "a" * 40 CANDIDATE_SOURCE_SHA = "c" * 40 +SOURCE_WORKFLOW_SHA = CANDIDATE_SOURCE_SHA TARGET_SHA = "b" * 40 SOURCE_RUN_ID = 9001 SOURCE_RUN_ATTEMPT = 3 @@ -60,7 +60,8 @@ def refresh_request(request: dict[str, Any]) -> bytes: def build_fixture( *, release_mode: str = "full", - prerelease: bool = False, + prerelease: bool | None = None, + make_latest: bool | None = None, valid_checksum_manifest: bool = True, ) -> tuple[dict[str, Any], dict[int, bytes], bytes]: contract = json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) @@ -77,22 +78,27 @@ def build_fixture( {"name": name, "size": len(contents[name]), "sha256": digest(contents[name])} for name in contract["required_assets"] ] + version = "0.5.28-beta" if release_mode == "beta" else "0.5.28" + if prerelease is None: + prerelease = release_mode == "beta" + if make_latest is None: + make_latest = release_mode == "full" manifest = { "schema_version": 1, "kind": "trajectory-release-asset-manifest", - "version": "0.5.28", - "tag": "v0.5.28", + "version": version, + "tag": f"v{version}", "source_sha": CANDIDATE_SOURCE_SHA, "assets": assets, } manifest_sha = canonical_digest(manifest) release = { "source_release_id": 6001, - "name": "Trajectory 0.5.28", - "body": "Trajectory 0.5.28 public binary release.", + "name": f"Trajectory {version}", + "body": f"Trajectory {version} public binary release.", "target_commitish": TARGET_SHA, "prerelease": prerelease, - "make_latest": True, + "make_latest": make_latest, } pilot = pilot_fixture() publication_receipt = { @@ -100,8 +106,8 @@ def build_fixture( "kind": "trajectory-publication-receipt", "status": "published", "release_mode": release_mode, - "version": "0.5.28", - "tag": "v0.5.28", + "version": version, + "tag": f"v{version}", "source_sha": CANDIDATE_SOURCE_SHA, "source_run_id": SOURCE_RUN_ID, "published_at": "2026-07-25T12:34:56Z", @@ -117,17 +123,20 @@ def build_fixture( "schema_version": 1, "kind": "trajectory-public-release-mirror-request", "release_mode": release_mode, - "version": "0.5.28", - "tag": "v0.5.28", + "version": version, + "tag": f"v{version}", "source": { "repository": "DataDog/trajectory", "workflow_ref": ( "DataDog/trajectory/.github/workflows/" - "public-release-publication.yml@refs/heads/main" + f"public-release-publication.yml@refs/tags/release-ci-v{version}-" + f"{CANDIDATE_SOURCE_SHA[:9]}" ), "environment": "public-release-publication", "event_name": "workflow_dispatch", - "ref": "refs/heads/main", + "ref": ( + f"refs/tags/release-ci-v{version}-{CANDIDATE_SOURCE_SHA[:9]}" + ), "sha": SOURCE_WORKFLOW_SHA, "candidate_sha": CANDIDATE_SOURCE_SHA, "run_id": SOURCE_RUN_ID, @@ -150,11 +159,11 @@ def build_fixture( def metadata_for(request: dict[str, Any]) -> dict[str, Any]: - return { + metadata = { "stable": { - "version": request["version"], - "tag": request["tag"], - "released_at": request["publication_receipt"]["published_at"], + "version": "0.5.27", + "tag": "v0.5.27", + "released_at": "2026-07-24T19:23:32Z", }, "beta": { "version": "0.5.27", @@ -162,6 +171,13 @@ def metadata_for(request: dict[str, Any]) -> dict[str, Any]: "released_at": "2026-07-24T19:23:32Z", }, } + ring = "beta" if request["release_mode"] == "beta" else "stable" + metadata[ring] = { + "version": request["version"], + "tag": request["tag"], + "released_at": request["publication_receipt"]["published_at"], + } + return metadata def release_assets(request: dict[str, Any]) -> list[dict[str, Any]]: @@ -179,11 +195,12 @@ def release_assets(request: dict[str, Any]) -> list[dict[str, Any]]: def source_publication_receipt(request: dict[str, Any]) -> dict[str, Any]: publication_receipt = request["publication_receipt"] - return { + mode = request["release_mode"] + receipt = { "schema_version": 1, "kind": "trajectory.public_release_publication.receipt", "terminal_status": "success", - "mode": "full", + "mode": mode, "binding": { "repository": request["source"]["repository"], "source_sha": request["source"]["candidate_sha"], @@ -202,13 +219,17 @@ def source_publication_receipt(request: dict[str, Any]) -> dict[str, Any]: "run_id": "7001", "run_attempt": "1", "receipt_sha256": "sha256:" + "1" * 64, - "validation_tag": "release-ci-v0.5.28-ccccccccc", + "validation_tag": f"release-ci-v{request['version']}-ccccccccc", }, "candidate_build": { "run_id": 7000, "run_attempt": 1, "receipt_sha256": "sha256:" + "2" * 64, - "validation_tag": "release-ci-v0.5.28-ccccccccc", + "validation_tag": f"release-ci-v{request['version']}-ccccccccc", + }, + "public_mirror": { + "repository": request["target"]["repository"], + "target_sha": request["target"]["sha"], }, "pilot": { "status": request["pilot"]["status"], @@ -233,11 +254,15 @@ def source_publication_receipt(request: dict[str, Any]) -> dict[str, Any]: "signer_identity": "release-signer", "trust_sha256": "sha256:" + "6" * 64, }, - "candidate_publication": { - "run_id": 8001, - "run_attempt": 1, - "receipt_sha256": "sha256:" + "7" * 64, - }, + "candidate_publication": ( + { + "run_id": 8001, + "run_attempt": 1, + "receipt_sha256": "sha256:" + "7" * 64, + } + if mode == "full" + else None + ), }, "publication": { "tag": request["tag"], @@ -252,9 +277,9 @@ def source_publication_receipt(request: dict[str, Any]) -> dict[str, Any]: "title": request["release"]["name"], "body": request["release"]["body"], "body_sha256": "sha256:" + digest(request["release"]["body"].encode()), - "changelog_path": "docs/changelog/v0.5.28.md", - "prerelease": False, - "latest": True, + "changelog_path": f"docs/release-notes/v{request['version']}.md", + "prerelease": request["release"]["prerelease"], + "latest": request["release"]["make_latest"], "assets": sorted( [ { @@ -266,23 +291,38 @@ def source_publication_receipt(request: dict[str, Any]) -> dict[str, Any]: ], key=lambda asset: asset["name"], ), - "url": "https://github.com/DataDog/trajectory/releases/tag/v0.5.28", + "url": f"https://github.com/DataDog/trajectory/releases/tag/{request['tag']}", + "release_branch": { + "final": f"release/v{request['version']}", + "final_target": request["source"]["candidate_sha"], + "prep": f"release/v{request['version']}-prep", + "prep_removed": True, + }, }, "guarantees": { "rebuild_performed": False, - "asset_uploads": [], + "asset_uploads": ( + [] + if mode == "full" + else [asset["name"] for asset in request["asset_manifest"]["assets"]] + ), "asset_reuploads": [], - "metadata_only_promotion": True, + "metadata_only_promotion": mode == "full", }, "lifecycle": { "issued_at": publication_receipt["published_at"], "expires_at": "2026-07-26T12:34:56Z", }, "blockers": [], - "outcome": "promoted", - "candidate_publication_receipt_sha256": "sha256:" + "7" * 64, - "mutation_summary": {"count": 1, "metadata_updates": 1}, + "outcome": "promoted" if mode == "full" else "published", + "mutation_summary": { + "count": 1, + "metadata_updates": 1 if mode == "full" else 0, + }, } + if mode == "full": + receipt["candidate_publication_receipt_sha256"] = "sha256:" + "7" * 64 + return receipt class FakeSource: @@ -304,7 +344,9 @@ def __init__( "event": "workflow_dispatch", "name": "Public Release Publication", "path": ".github/workflows/public-release-publication.yml", - "head_branch": "main", + "head_branch": ( + f"release-ci-v{request['version']}-{CANDIDATE_SOURCE_SHA[:9]}" + ), "head_sha": SOURCE_WORKFLOW_SHA, "run_attempt": SOURCE_RUN_ATTEMPT, "status": "completed", @@ -317,7 +359,7 @@ def __init__( "body": request["release"]["body"], "target_commitish": CANDIDATE_SOURCE_SHA, "draft": False, - "prerelease": False, + "prerelease": request["release"]["prerelease"], "assets": release_assets(request), } self.artifact_name = ( @@ -395,7 +437,7 @@ def _release(self, *, draft: bool) -> dict[str, Any]: "body": self.request["release"]["body"], "target_commitish": TARGET_SHA, "draft": draft, - "prerelease": False, + "prerelease": self.request["release"]["prerelease"], "assets": [ { **asset, @@ -419,7 +461,7 @@ def create_draft_release(self, request: dict[str, Any]) -> dict[str, Any]: "body": request["release"]["body"], "target_commitish": request["release"]["target_commitish"], "draft": True, - "prerelease": False, + "prerelease": request["release"]["prerelease"], "assets": [], } return copy.deepcopy(self.release) @@ -448,16 +490,19 @@ def get_release(self, release_id: int) -> dict[str, Any]: assert self.release is not None and release_id == self.release["id"] return copy.deepcopy(self.release) - def publish_release(self, release_id: int) -> dict[str, Any]: + def publish_release( + self, release_id: int, *, prerelease: bool, make_latest: bool + ) -> dict[str, Any]: assert self.release is not None and release_id == self.release["id"] self.publish_calls += 1 self.release["draft"] = False - self.release["prerelease"] = False + self.release["prerelease"] = prerelease if self.mutate_after_publish == "asset": self.release["assets"][0]["id"] += 1000 elif self.mutate_after_publish == "metadata": self.release["body"] = "changed after validation" - self.latest = copy.deepcopy(self.release) + if make_latest: + self.latest = copy.deepcopy(self.release) return copy.deepcopy(self.release) def get_latest_release(self) -> dict[str, Any]: @@ -500,9 +545,16 @@ def apply( receipt_out=Path(directory) / "receipt.json", ) - def test_contract_accepts_only_full_and_exact_canonical_assets(self) -> None: + def test_contract_accepts_full_beta_and_exact_canonical_assets(self) -> None: MIRROR.validate_contract(self.contract) - self.assertEqual(self.contract["accepted_release_modes"], ["full"]) + self.assertEqual(self.contract["accepted_release_modes"], ["full", "beta"]) + self.assertEqual( + self.contract["release_modes"], + { + "full": {"prerelease": False, "make_latest": True}, + "beta": {"prerelease": True, "make_latest": False}, + }, + ) self.assertEqual( self.contract["required_assets"], [ @@ -531,6 +583,11 @@ def test_contract_accepts_only_full_and_exact_canonical_assets(self) -> None: self.contract["source_identity"]["publication_artifact_prefix"], "public-release-publication-", ) + self.assertEqual( + self.contract["source_identity"]["workflow_path"], + ".github/workflows/public-release-publication.yml", + ) + self.assertEqual(self.contract["source_identity"]["default_branch"], "main") def test_pass_and_waived_pilot_contracts_publish(self) -> None: for status in ("pass", "waived"): @@ -653,10 +710,11 @@ def test_source_receipt_pilot_must_match_handoff(self) -> None: self.assertEqual(target.create_calls, 0) self.assertEqual(target.publish_calls, 0) - def test_candidate_and_prerelease_requests_fail_before_target_mutation(self) -> None: + def test_candidate_and_mismatched_channel_requests_fail_before_target_mutation(self) -> None: cases = ( build_fixture(release_mode="candidate"), build_fixture(prerelease=True), + build_fixture(release_mode="beta", make_latest=True), ) for request, payloads, raw in cases: with self.subTest(mode=request["release_mode"], prerelease=request["release"]["prerelease"]): @@ -747,6 +805,7 @@ def test_terminal_source_receipt_binds_full_publication_request(self) -> None: "workflow_sha", "workflow_run_id", "workflow_run_attempt", + "public_mirror_target", "candidate_sha", "version", "tag", @@ -775,6 +834,8 @@ def test_terminal_source_receipt_binds_full_publication_request(self) -> None: receipt["binding"]["workflow"]["run_id"] = SOURCE_RUN_ID + 1 elif case == "workflow_run_attempt": receipt["binding"]["workflow"]["run_attempt"] = SOURCE_RUN_ATTEMPT + 1 + elif case == "public_mirror_target": + receipt["binding"]["public_mirror"]["target_sha"] = "f" * 40 elif case == "candidate_sha": receipt["binding"]["source_sha"] = "f" * 40 elif case == "version": @@ -907,7 +968,15 @@ def test_candidate_source_identity_changes_require_fresh_canonical_digests(self) } for record, error in cases.items(): request, payloads, _ = build_fixture() - request["source"]["candidate_sha"] = "f" * 40 + changed_sha = "f" * 40 + validation_tag = f"release-ci-v{request['version']}-{changed_sha[:9]}" + request["source"]["sha"] = changed_sha + request["source"]["candidate_sha"] = changed_sha + request["source"]["ref"] = f"refs/tags/{validation_tag}" + request["source"]["workflow_ref"] = ( + "DataDog/trajectory/.github/workflows/" + f"public-release-publication.yml@refs/tags/{validation_tag}" + ) request["asset_manifest"]["source_sha"] = "f" * 40 request["publication_receipt"]["source_sha"] = "f" * 40 if record == "publication_receipt": @@ -919,11 +988,14 @@ def test_candidate_source_identity_changes_require_fresh_canonical_digests(self) ] raw = json.dumps(request, sort_keys=True, separators=(",", ":")).encode() target = FakeTarget(request, payloads, state="absent") + source = FakeSource(request, payloads, raw) + source.run["head_sha"] = changed_sha + source.run["head_branch"] = validation_tag with self.subTest(record=record), self.assertRaisesRegex( MIRROR.MirrorError, error, ): - self.apply(request, payloads, raw, target) + self.apply(request, payloads, raw, target, source=source) self.assertEqual(target.create_calls, 0) def test_absent_target_release_is_created_uploaded_and_published(self) -> None: @@ -960,6 +1032,22 @@ def test_absent_target_release_is_created_uploaded_and_published(self) -> None: self.assertEqual(target.upload_calls, self.contract["required_assets"]) self.assertEqual(target.publish_calls, 1) + def test_beta_release_is_prerelease_nonlatest_and_preserves_stable_metadata(self) -> None: + request, payloads, raw = build_fixture(release_mode="beta") + target = FakeTarget(request, payloads, state="absent") + metadata = metadata_for(request) + stable_before = copy.deepcopy(metadata["stable"]) + + receipt = self.apply(request, payloads, raw, target, metadata=metadata) + + self.assertEqual(receipt["status"], "published") + self.assertFalse(receipt["latest"]) + self.assertEqual(metadata["stable"], stable_before) + self.assertEqual(metadata["beta"]["version"], "0.5.28-beta") + assert target.release is not None + self.assertTrue(target.release["prerelease"]) + self.assertEqual(target.latest["tag_name"], "v0.5.27") + def test_target_receipt_binds_exact_correlated_workflow_run(self) -> None: request, payloads, raw = build_fixture() target = FakeTarget(request, payloads, state="absent") @@ -1064,6 +1152,15 @@ def test_repository_metadata_must_match_stable_receipt(self) -> None: self.apply(request, payloads, raw, target, metadata=metadata) self.assertEqual(target.create_calls, 0) + def test_repository_metadata_must_match_beta_receipt_without_changing_stable(self) -> None: + request, payloads, raw = build_fixture(release_mode="beta") + target = FakeTarget(request, payloads, state="absent") + metadata = metadata_for(request) + metadata["beta"]["version"] = "0.5.27" + with self.assertRaisesRegex(MIRROR.MirrorError, "beta metadata"): + self.apply(request, payloads, raw, target, metadata=metadata) + self.assertEqual(target.create_calls, 0) + def test_redirects_strip_authorization_and_reject_untrusted_hosts(self) -> None: request = urllib.request.Request( "https://api.github.com/repos/example/release/assets/1", @@ -1184,7 +1281,7 @@ def fake_json( with mock.patch.object(client, "_json", side_effect=fake_json): client.create_draft_release(request) - client.publish_release(7001) + client.publish_release(7001, prerelease=False, make_latest=True) self.assertEqual(calls[0][0:2], ("POST", "/repos/datadog-labs/trajectory/releases")) self.assertEqual( @@ -1297,11 +1394,16 @@ def test_workflow_and_sts_policy_are_narrow_and_pinned(self) -> None: dispatch_policy, ) self.assertIn("event_name: workflow_dispatch", dispatch_policy) - self.assertIn("ref: refs/heads/main", dispatch_policy) + self.assertIn( + r"ref: refs/tags/release-ci-v[0-9]+\.[0-9]+\.[0-9]+" + r"(-beta)?-[0-9a-f]{9,40}", + dispatch_policy, + ) self.assertIn("repository: DataDog/trajectory", dispatch_policy) self.assertIn( r"job_workflow_ref: DataDog/trajectory/\.github/workflows/" - r"public-release-publication\.yml@refs/heads/main", + r"public-release-publication\.yml@refs/tags/release-ci-v[0-9]+\." + r"[0-9]+\.[0-9]+(-beta)?-[0-9a-f]{9,40}", dispatch_policy, ) dispatch_permissions = dispatch_policy.split("permissions:", 1)[1]