diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 7578113b..4f71d248 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: 8314fc36-33f4-4d81-b33a-1f7efdec7458 -openapi_spec_hash: f4dd8b5345df235222a76ea4fe7b510f -openapi_transformed_spec_hash: e5618b2ec5119638b8abf10a9c5fc1eb -config_hash: ede660377ae979358e8d734790e79806 -codegen_sha: 29c015263e4a23ca49529ed196953096a77fcceb -codegen_hash: e8e8dd20078cceabcd0ab91a8b13b1e3e115fd7356fc52469aa1c6198e7c518b -public_codegen_sha: 312ec3dde2f9d4134373a79a265dc9b92e4fe70b +generation_id: f3cd348f-fbd5-4de1-a3a9-3a49675eb465 +openapi_spec_hash: d86cc8cb3ea20c869ba4b7425b58a783 +openapi_transformed_spec_hash: d7d3a757a532f61b81e4c952cc77bb49 +config_hash: a90097ffc79e2dc1d16edb1f00e97f38 +codegen_sha: 47a8490453a3af8b669f2b762a0a99e7adbe965e +codegen_hash: 8098749d305630d0adf30fe80b40ba7af553a91a1fce35a12c3803a5c2882f59 +public_codegen_sha: 2b0fa06df24063e7a98cbbe06a4e5966abddf991 diff --git a/.github/workflows/castiron-custom-code-comment.yml b/.github/workflows/castiron-custom-code-comment.yml index 362b0966..43f3a1da 100644 --- a/.github/workflows/castiron-custom-code-comment.yml +++ b/.github/workflows/castiron-custom-code-comment.yml @@ -176,6 +176,9 @@ jobs: if (run.event !== 'merge_group' || !match) return; branch = match[1]; } + // These required contexts belong to main. Next reports are advisory: + // GitHub does not rebuild its queue when main's policy changes. + if (branch !== 'main') return; fresh = fresh && evaluatedBranch === branch; const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; // Concurrency serializes writes, but does not order runs or retries. @@ -218,9 +221,7 @@ jobs: cursor = next; } while (true); const {data: target} = await github.rest.git.getRef({...context.repo, ref: `heads/${branch}`}); - const {data: main} = branch === 'main' ? {data: target} - : await github.rest.git.getRef({...context.repo, ref: 'heads/main'}); - fresh = fresh && base === target.object.sha && policy === main.object.sha; + fresh = fresh && base === target.object.sha && policy === target.object.sha; fresh = fresh && member; } for (const [name, result] of [ diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index 3b1da2e1..9d4e82f5 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -3573,6 +3573,9 @@ paths: application/json: schema: $ref: '#/components/schemas/CreateCompletionResponse' + text/event-stream: + schema: + $ref: '#/components/schemas/CreateCompletionResponse' '400': description: Invalid completion request or incompatible request parameters. content: @@ -21290,6 +21293,9 @@ paths: application/json: schema: $ref: '#/components/schemas/Response' + text/event-stream: + schema: + $ref: '#/components/schemas/ResponseStreamEvent' '404': description: The response was not found. content: @@ -21783,6 +21789,9 @@ paths: application/json: schema: $ref: '#/components/schemas/RunObject' + text/event-stream: + schema: + $ref: '#/components/schemas/AssistantStreamEvent' '429': $ref: '#/components/responses/TooManyRequests' x-oaiMeta: @@ -23067,6 +23076,9 @@ paths: application/json: schema: $ref: '#/components/schemas/RunObject' + text/event-stream: + schema: + $ref: '#/components/schemas/AssistantStreamEvent' '429': $ref: '#/components/responses/TooManyRequests' x-oaiMeta: @@ -24007,6 +24019,9 @@ paths: application/json: schema: $ref: '#/components/schemas/RunObject' + text/event-stream: + schema: + $ref: '#/components/schemas/AssistantStreamEvent' '429': $ref: '#/components/responses/TooManyRequests' x-oaiMeta: @@ -35887,6 +35902,9 @@ paths: application/json: schema: $ref: '#/components/schemas/BetaResponse' + text/event-stream: + schema: + $ref: '#/components/schemas/BetaResponseStreamEvent' '404': description: The response was not found. content: diff --git a/scripts/castiron/CUSTOM_CODE.md b/scripts/castiron/CUSTOM_CODE.md index 4974cbcf..7ef3e63c 100644 --- a/scripts/castiron/CUSTOM_CODE.md +++ b/scripts/castiron/CUSTOM_CODE.md @@ -59,6 +59,8 @@ or manifest edits cannot exempt a generated file. Generated workflow/action hashes are covered by the manifest even though the legacy patch counter and public snapshot publication continue to exclude those subtrees. Handwritten files and repository-owned governance remain outside generated ownership. +Private checkpoints retain SDK-owned CI workflows and actions from their parent; +the generated ownership inventory does not claim those inherited files. The existing required `Castiron / custom-code budget` status fails on any strict violation. Its trusted-main execution and merge-queue checks remain mandatory. @@ -88,12 +90,17 @@ The custom-code workflow pair separates candidate execution from trusted checks: per constituent PR and budgets the complete candidate using trusted main policy. A policy increase in next or the same group cannot grant a higher limit. -A separate publisher with no checkout attaches these statuses to the exact PR -head or merge-group SHA, after rechecking the PR head/target or queue base freshness: +A separate publisher with no checkout attaches these statuses only to PRs and +merge groups targeting `main`, after rechecking the PR head/target or queue base freshness: - `Castiron / budget-only change` - `Castiron / custom-code budget` +These contexts are reserved for `main`. Reports for `next` are advisory and +never write required statuses, even when both targets use the same commit. +GitHub does not rebuild the `next` queue when policy on `main` changes, so a +captured `next` result cannot safely authorize a merge against current policy. + The policy is read from the captured main commit, not next, the PR, or its merge base. Target baseline, trusted policy, and candidate identities are recorded separately in the report and budget result. Reusing a report requires matching @@ -166,7 +173,10 @@ added later. Land shared tooling in Castiron first, then generate and review its SDK update onto main. Keep required status activation separate until hosted tests have exercised main and next PRs, forks, retargeting, policy changes, and both merge -queues. Add the two exact trusted statuses to next only after this validation; -preserve its reviews, CODEOWNERS, merge queue, and all existing main protections. +queues. Do not require the `main` status contexts on `next`. Authoritative next +checks need separate contexts and a mechanism that keeps queue authorization +bound to current main policy, including changes after a result is published. +Until that mechanism is implemented and reviewed, next reports remain advisory. +Preserve its reviews, CODEOWNERS, merge queue, and all existing main protections. This checker support does not enable generation or promotion to next, or releases from next. Those require separate reviewed implementation. diff --git a/scripts/castiron/custom_code_budget.py b/scripts/castiron/custom_code_budget.py index ac1711df..8a7ff04f 100644 --- a/scripts/castiron/custom_code_budget.py +++ b/scripts/castiron/custom_code_budget.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import ast import hashlib import html import importlib.util @@ -75,14 +76,74 @@ def read_policy(repo: Path, revision: str) -> dict[str, Any]: def read_budget(repo: Path, revision: str) -> int: - return read_policy(repo, revision)["max_custom_patch_lines"] + return cast(int, read_policy(repo, revision)["max_custom_patch_lines"]) def strict_policy(repo: Path, revision: str) -> bool: - return read_policy(repo, revision)["schema_version"] == 2 + return cast(int, read_policy(repo, revision)["schema_version"]) == 2 MANIFEST = b"scripts/castiron/generated-files.json" +# Derived from the renderer's repository-file inventory, also used by checkpoint +# publication to distinguish generated tooling from protected SDK-owned CI. +GENERATED_GITHUB_PATHS = frozenset( + { + b".github/workflows/castiron-custom-code.yml", + b".github/workflows/castiron-custom-code-comment.yml", + } +) + + + +# Audited inventory of the authenticated pre-inventory checker format. These +# historical snapshots bind the checker bytes and manifest through their codegen +# hash. Never infer their inventory from the running compiler or candidate tree. +LEGACY_CHECKPOINT_INVENTORIES = { + "e504abba2701f4733eb4b8ce26856cca596e13feab1e7e323adc8e7d2ccb64bc": frozenset( + { + b".github/workflows/castiron-custom-code-comment.yml", + b".github/workflows/castiron-custom-code.yml", + } + ), +} + + +def checkpoint_github_inventory(source: bytes) -> frozenset[bytes]: + """Read literal compiler inventory from verified checkpoint data, without execution.""" + values = [ + node.value + for node in ast.parse(source).body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "GENERATED_GITHUB_PATHS" + for target in node.targets + ) + ] + if not values: + historical = LEGACY_CHECKPOINT_INVENTORIES.get(hashlib.sha256(source).hexdigest()) + if historical is not None: + return historical + if len(values) != 1: + raise ValueError("checkpoint must contain one generated GitHub inventory") + value = values[0] + if ( + not isinstance(value, ast.Call) + or not isinstance(value.func, ast.Name) + or value.func.id != "frozenset" + or len(value.args) != 1 + or value.keywords + ): + raise ValueError("checkpoint generated GitHub inventory must be literal") + literal_paths: object = ast.literal_eval(value.args[0]) + # An empty renderer inventory is written with an empty pair of braces. + if isinstance(literal_paths, dict) and not literal_paths: + return frozenset() + if not isinstance(literal_paths, set) or any( + not isinstance(path, bytes) or not path.startswith(b".github/") + for path in cast(set[object], literal_paths) + ): + raise ValueError("invalid checkpoint generated GitHub inventory") + return frozenset(cast(set[bytes], literal_paths)) def generated_manifest(repo: Path, generated: str) -> dict[bytes, dict[str, str]]: @@ -94,17 +155,22 @@ def generated_manifest(repo: Path, generated: str) -> dict[bytes, dict[str, str] "strict mode requires a generated ownership manifest; regenerate with Castiron" ) blobs = report.read_blobs(repo, entries) + inventory_entry = entries.get(b"scripts/castiron/custom_code_budget.py") + if inventory_entry is None or inventory_entry.mode not in (b"100644", b"100755"): + raise ValueError("checkpoint is missing its generated GitHub inventory") + github_paths = checkpoint_github_inventory(blobs[inventory_entry.oid]) value = json.loads(blobs[entry.oid], object_pairs_hook=unique_keys) + if not isinstance(value, dict) or set(cast(dict[str, Any], value)) != {"schema_version", "files"}: + raise ValueError("invalid generated ownership manifest") + value = cast(dict[str, Any], value) if ( - not isinstance(value, dict) - or set(value) != {"schema_version", "files"} - or type(value["schema_version"]) is not int + type(value["schema_version"]) is not int or value["schema_version"] != 1 or not isinstance(value["files"], dict) ): raise ValueError("invalid generated ownership manifest") files: dict[bytes, dict[str, str]] = {} - for name, identity in value["files"].items(): + for name, identity in cast(dict[Any, Any], value["files"]).items(): if ( not isinstance(name, str) or not name @@ -116,22 +182,37 @@ def generated_manifest(repo: Path, generated: str) -> dict[bytes, dict[str, str] path = name.encode("utf-8") if path == MANIFEST: raise ValueError("generated manifest cannot include itself") + # A matching identity cannot turn inherited, SDK-owned CI into generated + # output. Use this verified checkpoint's literal compiler inventory, not + # the executing verifier's version or candidate Python/manifest claims. + # Public checkpoints may omit the private workflow/action blobs. + if not report.included(path) and path not in github_paths: + raise ValueError(f"generated manifest claims SDK-owned GitHub path: {name}") + if not isinstance(identity, dict) or set(cast(dict[str, Any], identity)) != {"mode", "sha256"}: + raise ValueError(f"invalid generated identity: {name}") + identity = cast(dict[str, Any], identity) if ( - not isinstance(identity, dict) - or set(identity) != {"mode", "sha256"} - or identity["mode"] not in ("100644", "100755") + identity["mode"] not in ("100644", "100755") or not isinstance(identity["sha256"], str) or not report.HASH.fullmatch(identity["sha256"]) ): raise ValueError(f"invalid generated identity: {name}") files[path] = identity - # Private snapshots must bind every generated file, including workflows and - # actions omitted from the legacy hash. Public snapshots retain these - # identities but deliberately omit their private blobs. + # Sanitized public checkpoints omit GitHub blobs, not their ownership entries. + # Otherwise an omitted identity would also escape strict candidate checks. + if missing := github_paths - files.keys(): + raise ValueError( + f"generated manifest omits generated GitHub inventory path: {min(missing).decode()}" + ) + # Checkpoints retain SDK-owned workflow/action subtrees from their parent. + # Those are outside generated ownership. Every other snapshot path, including + # renderer-owned GitHub files omitted from the legacy hash, must be listed. + # Public snapshots retain generated identities without the private blobs. snapshot_paths = set(entries) - {MANIFEST} - if snapshot_paths - files.keys() or any( - report.included(path) for path in files.keys() - snapshot_paths - ): + if any( + report.included(path) or path in github_paths + for path in snapshot_paths - files.keys() + ) or any(report.included(path) for path in files.keys() - snapshot_paths): raise ValueError("generated manifest does not cover the verified snapshot exactly") for path, identity in files.items(): if path in entries: @@ -263,7 +344,7 @@ def evaluate( ) -> tuple[dict[str, Any], bytes]: """In a queue, isolate each PR but budget the complete merged candidate.""" base, head = report.require_sha(base), report.require_sha(head) - policy_sha = report.require_sha(policy_sha or base) + policy_sha = cast(str, report.require_sha(policy_sha or base)) result: dict[str, Any] = { "base_sha": base, "policy_sha": policy_sha, @@ -502,9 +583,16 @@ def github_evaluate( raise ValueError("unsupported budget event") # PR statuses can outlive their base revision. Require the queue's fresh, # combined candidate check before merging rather than relying on those alone. - rules = report.api("GET", f"{root}/rules/branches/{branch}") - if not any(rule["type"] == "merge_queue" for rule in rules): - raise ValueError(f"{branch} must require a merge queue before activating the budget gate") + page = 1 + while True: + rules = report.api("GET", f"{root}/rules/branches/{branch}?per_page=100&page={page}") + if any(rule["type"] == "merge_queue" for rule in rules): + break + if len(rules) < 100: + raise ValueError( + f"{branch} must require a merge queue before activating the budget gate" + ) + page += 1 measurement = None if trusted_report_dir is not None: # Only the preceding main-branch step may supply this directory. Never diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index ed65be8e..02067ecf 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -3,7 +3,7 @@ "files": { ".github/workflows/castiron-custom-code-comment.yml": { "mode": "100644", - "sha256": "815b1e8ed09255088d37b7b09a758a567037c2ee5b56397fe8b096b7c97e8a41" + "sha256": "02b1c497461fcaf604e1389e7dcab06cfcca01635a8cb2e92f8769245a2aaead" }, ".github/workflows/castiron-custom-code.yml": { "mode": "100644", @@ -699,7 +699,7 @@ }, "scripts/castiron/CUSTOM_CODE.md": { "mode": "100644", - "sha256": "9c602fb42e00a40e21f3106c357fdaadfc1b9865de4105c71364354b451211d6" + "sha256": "c83e57879ee995c2871ff41a775758301d634199a00886e339a41212db711f39" }, "scripts/castiron/README.md": { "mode": "100644", @@ -707,7 +707,7 @@ }, "scripts/castiron/custom_code_budget.py": { "mode": "100644", - "sha256": "e504abba2701f4733eb4b8ce26856cca596e13feab1e7e323adc8e7d2ccb64bc" + "sha256": "e6f19706c26f9b996f7d6d5f8a654e54fbc8c49a5d2fce045c1996b119feee33" }, "scripts/castiron/custom_code_report.py": { "mode": "100644", @@ -727,19 +727,19 @@ }, "scripts/castiron/test_custom_code_budget.py": { "mode": "100644", - "sha256": "f07fcaf6d10890a47403b784898e3f40a443fa551d66701a717de8382ebf0b9c" + "sha256": "cf4938bca739d01f1db2a421ec97e4fe8b41b8bc2d2c634a01247e36cb464649" }, "scripts/castiron/test_custom_code_github.py": { "mode": "100644", - "sha256": "2d5700e28b539bb31a0a547a35803c7cf0627e89c1c0430a1307284aff3279b6" + "sha256": "aa170458b1b58523df78073a96ba8c58b75fce03206ff7d62d99b29791c6ca39" }, "scripts/castiron/test_custom_code_publication.py": { "mode": "100644", - "sha256": "835b5728882d5fb3fb41f93a5ca71034e357b938164fdee8d0d456195b92b133" + "sha256": "99b266761f427d77990e68ce2fdea4b5f1a5b175612b3a9f4355c094939130ca" }, "scripts/castiron/test_custom_code_report.py": { "mode": "100644", - "sha256": "2f6ef126e8298fb4eb68ebbb95e8966006525aa4078ec4392083c7076cf4a36d" + "sha256": "5c17ac9b5596e7f69aaefe3f29615cdac425323594854a91e1759c88d92a68bb" } } } diff --git a/scripts/castiron/test_custom_code_budget.py b/scripts/castiron/test_custom_code_budget.py index e741fdf9..e26b2dd2 100644 --- a/scripts/castiron/test_custom_code_budget.py +++ b/scripts/castiron/test_custom_code_budget.py @@ -253,13 +253,43 @@ def test_summary_has_counts_revisions_and_no_longer_generated(self) -> None: self.assertIn("human approving review", summary) +class InventoryTests(unittest.TestCase): + def test_literal_inventory_is_data_not_executable_code(self) -> None: + source = ( + b"raise AssertionError('must not execute')\n" + b"GENERATED_GITHUB_PATHS = frozenset({b'.github/workflows/generated.yml'})\n" + ) + self.assertEqual( + budget.checkpoint_github_inventory(source), + frozenset({b".github/workflows/generated.yml"}), + ) + self.assertEqual( + budget.checkpoint_github_inventory(b"GENERATED_GITHUB_PATHS = frozenset({})"), + frozenset(), + ) + + def test_inventory_rejects_missing_ambiguous_and_nonliteral_values(self) -> None: + for source in ( + b"pass", + b"GENERATED_GITHUB_PATHS = frozenset({})\nGENERATED_GITHUB_PATHS = frozenset({})", + b"GENERATED_GITHUB_PATHS = __import__('os').environ", + b"GENERATED_GITHUB_PATHS = frozenset(__import__('os').environ)", + b"GENERATED_GITHUB_PATHS = frozenset({'text'})", + b"GENERATED_GITHUB_PATHS = frozenset({b'not-github'})", + ): + with self.subTest(source=source), self.assertRaises(ValueError): + budget.checkpoint_github_inventory(source) + + class StrictTests(GitTestCase): - workflow = ".github/workflows/generated.yml" + workflow = ".github/workflows/castiron-custom-code.yml" def setUp(self) -> None: # pyright: ignore[reportImplicitOverride] super().setUp() self.write("generated.py", "generated\n") - self.write(self.workflow, "workflow\n") + for path in budget.GENERATED_GITHUB_PATHS: + self.write(path.decode(), "workflow\n") + self.inventory(budget.GENERATED_GITHUB_PATHS) self.manifest() self.generated, _ = self.baseline() self.write( @@ -270,13 +300,26 @@ def setUp(self) -> None: # pyright: ignore[reportImplicitOverride] ) self.base = self.commit("enable strict ownership") - def manifest(self) -> None: + def inventory(self, paths: frozenset[bytes]) -> None: + # Checkpoint code is data. A verifier must never execute this statement. + self.write( + "scripts/castiron/custom_code_budget.py", + "raise AssertionError('checkpoint code must not execute')\n" + f"GENERATED_GITHUB_PATHS = frozenset({set(paths)!r})\n", + ) + + def manifest(self, extra: tuple[str, ...] = ()) -> None: files = { p: { "mode": "100644", "sha256": hashlib.sha256((self.repo / p).read_bytes()).hexdigest(), } - for p in ("generated.py", self.workflow) + for p in ( + "generated.py", + *(path.decode() for path in budget.GENERATED_GITHUB_PATHS), + "scripts/castiron/custom_code_budget.py", + *extra, + ) } self.write(budget.MANIFEST.decode(), json.dumps({"schema_version": 1, "files": files})) @@ -482,12 +525,35 @@ def test_manifest_must_cover_snapshot_and_match_its_bytes(self) -> None: with self.assertRaisesRegex(ValueError, "disagrees"): budget.generated_manifest(self.repo, self.commit()) + def test_manifest_preserves_inherited_sdk_owned_github_files(self) -> None: + self.git("checkout", "--detach", "-q", self.generated) + inherited = (".github/workflows/ci.yml", ".github/actions/setup-go/action.yml") + for path in inherited: + self.write(path, "SDK-owned configuration\n") + generated = self.commit("checkpoint preserves protected parent subtrees") + manifest = budget.generated_manifest(self.repo, generated) + for path in inherited: + self.assertNotIn(path.encode(), manifest) + self.write(path, "SDK-owned configuration update\n") + head = self.commit("handwritten configuration remains outside generated ownership") + self.assertEqual(budget.verify_generated(self.repo, generated, head), []) + def test_private_manifest_checks_excluded_workflows_and_actions(self) -> None: + self.assertIn(self.workflow.encode(), budget.GENERATED_GITHUB_PATHS) for path in (self.workflow, ".github/actions/generated/action.yml"): for mutation in ("omit", "content", "mode"): - with self.subTest(path=path, mutation=mutation): + with ( + self.subTest(path=path, mutation=mutation), + mock.patch.object( + budget, + "GENERATED_GITHUB_PATHS", + budget.GENERATED_GITHUB_PATHS | {path.encode()}, + ), + ): self.git("checkout", "--detach", "-q", self.generated) self.write(path, "generated workflow or action\n") + self.inventory(budget.GENERATED_GITHUB_PATHS) + self.manifest((path,)) manifest = json.loads((self.repo / budget.MANIFEST.decode()).read_text()) manifest["files"][path] = { "mode": "100644", @@ -500,10 +566,74 @@ def test_private_manifest_checks_excluded_workflows_and_actions(self) -> None: else: (self.repo / path).chmod(0o755) self.write(budget.MANIFEST.decode(), json.dumps(manifest)) - error = "cover.*exactly" if mutation == "omit" else "disagrees" + error = ( + "omits generated GitHub inventory path" + if mutation == "omit" + else "disagrees" + ) with self.assertRaisesRegex(ValueError, error): budget.generated_manifest(self.repo, self.commit()) + def test_checkpoint_inventory_can_add_and_retire_generated_ci(self) -> None: + for path in (".github/workflows/new.yml", ".github/actions/new/action.yml"): + with self.subTest(path=path): + self.git("checkout", "--detach", "-q", self.generated) + self.write(path, "new generated CI\n") + self.inventory(budget.GENERATED_GITHUB_PATHS | {path.encode()}) + self.manifest((path,)) + newer = self.commit("new compiler inventory") + # The executing verifier still has the old inventory. + self.assertNotIn(path.encode(), budget.GENERATED_GITHUB_PATHS) + self.assertEqual( + budget.verify_generated(self.repo, newer, newer, previous=self.generated), [] + ) + (self.repo / path).unlink() + public = self.commit("public checkpoint omits private CI blob") + self.assertEqual( + budget.verify_generated(self.repo, public, newer, previous=self.generated), [] + ) + # A newer verifier must also read the prior inventory to enforce retirement. + self.assertEqual( + budget.verify_generated(self.repo, self.generated, self.base, previous=public), [] + ) + self.git("checkout", "--detach", "-q", self.base) + self.write(path, "retired generated CI\n") + failures = budget.verify_generated( + self.repo, self.generated, self.commit(), previous=public + ) + self.assertTrue(any(path in failure and "retired" in failure for failure in failures)) + + def test_manifest_cannot_claim_sdk_owned_workflows_or_actions(self) -> None: + for path in (".github/workflows/ci.yml", ".github/actions/setup-go/action.yml"): + for keep_blob in (True, False): + with self.subTest(path=path, keep_blob=keep_blob): + self.git("checkout", "--detach", "-q", self.generated) + self.write(path, "SDK-owned configuration\n") + manifest = json.loads((self.repo / budget.MANIFEST.decode()).read_text()) + manifest["files"][path] = { + "mode": "100644", + "sha256": hashlib.sha256((self.repo / path).read_bytes()).hexdigest(), + } + if not keep_blob: + (self.repo / path).unlink() + self.write(budget.MANIFEST.decode(), json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, "SDK-owned GitHub path"): + budget.generated_manifest(self.repo, self.commit()) + + def test_public_snapshot_requires_every_inventory_identity(self) -> None: + for path in (self.workflow, ".github/actions/generated/action.yml"): + with self.subTest(path=path): + self.git("checkout", "--detach", "-q", self.generated) + self.write(path, "generated CI\n") + self.inventory(budget.GENERATED_GITHUB_PATHS | {path.encode()}) + self.manifest((path,)) + (self.repo / path).unlink() + manifest = json.loads((self.repo / budget.MANIFEST.decode()).read_text()) + del manifest["files"][path] + self.write(budget.MANIFEST.decode(), json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, "omits generated GitHub inventory path"): + budget.generated_manifest(self.repo, self.commit("incomplete public checkpoint")) + def test_public_snapshot_keeps_strict_workflow_identity_without_private_blob(self) -> None: self.git("checkout", "--detach", "-q", self.generated) (self.repo / self.workflow).unlink() diff --git a/scripts/castiron/test_custom_code_github.py b/scripts/castiron/test_custom_code_github.py index bd32fd98..3c04d207 100644 --- a/scripts/castiron/test_custom_code_github.py +++ b/scripts/castiron/test_custom_code_github.py @@ -47,9 +47,11 @@ def __call__(self, method: str, path: str, payload: dict[str, Any] | None = None f"{root}/pulls/3": self.pull, f"{root}/pulls/4": self.pull, f"{root}/git/ref/heads/main": {"object": {"sha": self.main}}, - f"{root}/rules/branches/main": self.rules, + f"{root}/rules/branches/main?per_page=100&page=1": self.rules[:100], + f"{root}/rules/branches/main?per_page=100&page=2": self.rules[100:200], f"{root}/git/ref/heads/next": {"object": {"sha": self.next}}, - f"{root}/rules/branches/next": self.rules, + f"{root}/rules/branches/next?per_page=100&page=1": self.rules[:100], + f"{root}/rules/branches/next?per_page=100&page=2": self.rules[100:200], f"{root}/commits/{self.run['head_sha']}/pulls?per_page=100": self.associations, f"{root}/pulls?state=open&head=contributor%3Asdk&per_page=100&page=1": self.branches, f"{root}/issues/3/comments?per_page=100&page=1": [], @@ -392,6 +394,22 @@ def test_merge_queue_rule_is_required_before_passing(self) -> None: self.evaluate() self.assertFalse(self.repo.exists()) + def test_merge_queue_rule_on_later_page_is_honored(self) -> None: + self.api.rules = [{"type": "required_status_checks"}] * 100 + [{"type": "merge_queue"}] + with ( + mock.patch.object(budget.subprocess, "run"), + mock.patch.object(budget.report, "git"), + mock.patch.object(budget, "evaluate", return_value=({}, b"")) as evaluate, + ): + self.evaluate() + evaluate.assert_called_once() + + def test_merge_queue_rule_absent_after_full_page_is_rejected(self) -> None: + self.api.rules = [{"type": "required_status_checks"}] * 100 + with self.assertRaisesRegex(ValueError, "must require a merge queue"): + self.evaluate() + self.assertFalse(self.repo.exists()) + def test_wrong_or_superseded_source_runs_fail_before_fetching(self) -> None: for changes in ( {"run_attempt": 2}, diff --git a/scripts/castiron/test_custom_code_publication.py b/scripts/castiron/test_custom_code_publication.py index 6d10b721..f8137165 100644 --- a/scripts/castiron/test_custom_code_publication.py +++ b/scripts/castiron/test_custom_code_publication.py @@ -331,7 +331,7 @@ def publish( run = source_run(head, event_name) if event_name == "merge_group": run["head_branch"] = f"gh-readonly-queue/{branch}/pr-7-example" - payload = { + payload: dict[str, Any] = { "main_sha": "d" * 40 if policy_changed else ("c" * 40 if branch == "main" and base_changed else base), @@ -386,33 +386,50 @@ def test_statuses_attach_to_candidate_not_main(self) -> None: all(r["sha"] == "b" * 40 and r["state"] == "success" for r in results) ) - def test_next_statuses_bind_target_policy_and_queue_membership(self) -> None: + def test_next_cannot_overwrite_main_results(self) -> None: + for main_event in ("pull_request", "merge_group"): + main = self.publish(event_name=main_event, failed_budget=True) + self.assertEqual([r["state"] for r in main], ["success", "failure"]) + for event in ("pull_request", "merge_group"): + with self.subTest(main_event=main_event, event=event): + # Both evaluations use the same commit. A next success + # must not replace the main target's required failure. + result = self.publish(event_name=event, branch="next", previous_statuses=main) + self.assertEqual(result, []) + + def test_next_queue_never_authorizes_against_a_stale_main_policy(self) -> None: + for changed in (False, True): + with self.subTest(policy_changed=changed): + self.assertEqual( + self.publish(event_name="merge_group", branch="next", policy_changed=changed), + [], + ) + + def test_main_statuses_bind_target_policy_and_queue_membership(self) -> None: for event in ("pull_request", "merge_group"): with self.subTest(event=event): - result = self.publish(event_name=event, branch="next") - self.assertEqual(len(result), 2) - self.assertTrue(all(r["state"] == "success" for r in result)) - self.assertTrue(all("next" in r["description"] for r in result)) - stale = self.publish(event_name=event, branch="next", evaluated_branch="main") - self.assertTrue(all(r["state"] == "failure" for r in stale)) - for options in ({"base_changed": True}, {"policy_changed": True}, {"queue_absent": True}): - result = self.publish(event_name="merge_group", branch="next", **options) - self.assertEqual(len(result), 2) - self.assertTrue(all(r["state"] == "failure" for r in result)) - self.assertEqual(self.publish(branch="next", retarget_during_publication=True), []) + stale = self.publish(event_name=event, evaluated_branch="next") + self.assertEqual([r["state"] for r in stale], ["failure", "failure"]) + for policy_changed, queue_absent in ((True, False), (False, True)): + with self.subTest(policy_changed=policy_changed, queue_absent=queue_absent): + result = self.publish( + event_name="merge_group", policy_changed=policy_changed, queue_absent=queue_absent + ) + self.assertEqual([r["state"] for r in result], ["failure", "failure"]) + self.assertEqual(self.publish(retarget_during_publication=True), []) def test_next_fork_and_unsupported_targets(self) -> None: result = self.publish( branch="next", run_overrides={"pull_requests": []}, fallback_pulls=[{"number": 3}] ) - self.assertEqual([r["state"] for r in result], ["success", "success"]) + self.assertEqual(result, []) for event in ("pull_request", "merge_group"): for branch in ("Next", "Main", "release", "next-other"): with self.subTest(event=event, branch=branch): self.assertEqual(self.publish(event_name=event, branch=branch), []) - def test_next_queue_membership_paginates(self) -> None: - pages = [ + def test_main_queue_membership_paginates(self) -> None: + pages: list[dict[str, Any]] = [ { "expected_cursor": None, "nodes": [], @@ -424,7 +441,7 @@ def test_next_queue_membership_paginates(self) -> None: "pageInfo": {"hasNextPage": False, "endCursor": None}, }, ] - result = self.publish(event_name="merge_group", branch="next", queue_pages=pages) + result = self.publish(event_name="merge_group", queue_pages=pages) self.assertEqual([r["state"] for r in result], ["success", "success"]) def test_fork_statuses_with_no_commit_association(self) -> None: diff --git a/scripts/castiron/test_custom_code_report.py b/scripts/castiron/test_custom_code_report.py index a77d5049..205d334d 100644 --- a/scripts/castiron/test_custom_code_report.py +++ b/scripts/castiron/test_custom_code_report.py @@ -8,10 +8,9 @@ import struct import subprocess import unittest -import unittest.mock as mock +from unittest import mock import custom_code_report as report - from custom_code_test_support import GENERATION, GitTestCase