Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .castiron.stats.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions .github/workflows/castiron-custom-code-comment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 [
Expand Down
18 changes: 18 additions & 0 deletions api_reference/openapi.transformed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions scripts/castiron/CUSTOM_CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
126 changes: 107 additions & 19 deletions scripts/castiron/custom_code_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import argparse
import ast
import hashlib
import html
import importlib.util
Expand Down Expand Up @@ -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]]:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions scripts/castiron/generated-files.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -699,15 +699,15 @@
},
"scripts/castiron/CUSTOM_CODE.md": {
"mode": "100644",
"sha256": "9c602fb42e00a40e21f3106c357fdaadfc1b9865de4105c71364354b451211d6"
"sha256": "c83e57879ee995c2871ff41a775758301d634199a00886e339a41212db711f39"
},
"scripts/castiron/README.md": {
"mode": "100644",
"sha256": "4aff82cb988d01babde79902ae20de842fdd4b2b447d5f4cb3c5ce0a36d6fab8"
},
"scripts/castiron/custom_code_budget.py": {
"mode": "100644",
"sha256": "e504abba2701f4733eb4b8ce26856cca596e13feab1e7e323adc8e7d2ccb64bc"
"sha256": "e6f19706c26f9b996f7d6d5f8a654e54fbc8c49a5d2fce045c1996b119feee33"
},
"scripts/castiron/custom_code_report.py": {
"mode": "100644",
Expand All @@ -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"
}
}
}
Loading
Loading