Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .castiron.stats.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
schema_version: 1
generation_id: fb116796-2ea5-4163-b68f-b61bf0010915
generation_id: b357eb8c-059b-4c0e-b1c1-2a7c0137a74c
openapi_spec_hash: e81fa0fd2133715dabef9f00a42557a9
openapi_transformed_spec_hash: bea7a30d23deed3fc8f1c6413a38d354
config_hash: 77036ac910eb540010c265da4ea569fa
codegen_sha: fb779116bbfc76b8edaca001ad8b4b91caaab006
codegen_hash: ba4243ffb54f4298c746fa063ec51f6945b7057f9ca92e2743803b4ee9a624da
public_codegen_sha: 1a345e61a29b52a1329b349e89df7e076c9b3514
codegen_sha: 71525a371d60d4542ab1d1671382cefb12006578
codegen_hash: 4d2f67859a910ff993829df42fecc3a024ef29c842916df092cac673c84a6f54
public_codegen_sha: 85d60517cb25cb1804c571769ef4ccb8d220b7c9
61 changes: 51 additions & 10 deletions .github/workflows/castiron-custom-code-comment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,22 @@ jobs:
number: ${{ steps.report.outputs.number }}
artifact-id: ${{ steps.artifact.outputs.artifact-id }}
artifact-run-attempt: ${{ github.run_attempt }}
target-branch: ${{ steps.budget.outputs.target_branch }}
policy-sha: ${{ steps.budget.outputs.policy_sha }}
base-sha: ${{ steps.budget.outputs.base_sha }}
head-sha: ${{ steps.budget.outputs.head_sha }}
isolation: ${{ steps.budget.outputs.isolation }}
budget: ${{ steps.budget.outputs.budget }}
steps:
# Selecting main here pins both the executable checker and PR policy base.
# Subsequent steps use this checkout's SHA even if main advances.
# Selecting main here pins executable checker code and budget policy.
# The reporter separately captures the live main/next target baseline.
- name: Check out the trusted reporter
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
persist-credentials: false

- name: Compute from the selected main and pull request Git objects
- name: Compute from trusted main and target branch Git objects
id: report
if: github.event.workflow_run.event == 'pull_request'
env:
Expand Down Expand Up @@ -133,6 +135,8 @@ jobs:
- name: Publish exact-head statuses after checking freshness
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TARGET_BRANCH: ${{ needs.compute.outputs.target-branch }}
POLICY_SHA: ${{ needs.compute.outputs.policy-sha }}
BASE_SHA: ${{ needs.compute.outputs.base-sha }}
HEAD_SHA: ${{ needs.compute.outputs.head-sha }}
ISOLATION_RESULT: ${{ needs.compute.outputs.isolation }}
Expand All @@ -148,7 +152,10 @@ jobs:
const head = run.head_sha;
if (!/^[0-9a-f]{40}$/.test(head)) throw new Error('Invalid candidate SHA');
const base = process.env.BASE_SHA;
let fresh = /^[0-9a-f]{40}$/.test(base) && head === process.env.HEAD_SHA;
const policy = process.env.POLICY_SHA;
const evaluatedBranch = process.env.TARGET_BRANCH;
let branch, number;
let fresh = /^[0-9a-f]{40}$/.test(base) && /^[0-9a-f]{40}$/.test(policy) && head === process.env.HEAD_SHA;
if (run.event === 'pull_request') {
let pulls = run.pull_requests.length ? run.pull_requests : await github.paginate(
github.rest.repos.listPullRequestsAssociatedWithCommit, {...context.repo, commit_sha: head});
Expand All @@ -159,15 +166,20 @@ jobs:
for (const pull of pulls) {
const {data: pr} = await github.rest.pulls.get({...context.repo, pull_number: pull.number});
if (pr.state === 'open' && pr.head.sha === head &&
pr.base.ref === 'main' && pr.base.repo.full_name === `${context.repo.owner}/${context.repo.repo}`) current.push(pr);
['main', 'next'].includes(pr.base.ref) && pr.base.repo.full_name === `${context.repo.owner}/${context.repo.repo}`) current.push(pr);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep target-specific results out of the same status context

This now accepts both main and next, but the publisher below still keys both results by the same head SHA and the same two context names. The results can legitimately differ: if next already contains a policy-only commit B and H adds code on top of B, isolation passes against next but fails against main, which sees the mixed policy/code diff. Retargeting H to next lets its later success replace the main failure; retargeting it back leaves the wrong target's result displayed until a new evaluation finishes. The ordering marker also ignores target identity. Please keep next advisory for now or use target-specific status contexts/ordering, with a same-head retargeting regression case. This is status correctness feedback, not a claim that the separate main merge-queue check can be bypassed.

}
if (current.length !== 1) return;
} else if (run.event !== 'merge_group' || !run.head_branch.startsWith('gh-readonly-queue/main/')) {
return;
branch = current[0].base.ref;
number = current[0].number;
} else {
const {data: main} = await github.rest.git.getRef({...context.repo, ref: 'heads/main'});
fresh = fresh && base === main.object.sha;
const match = run.head_branch.match(/^gh-readonly-queue\/(main|next)\//);
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.
// Keep an older evaluation from overwriting a newer result for this head.
Expand All @@ -183,13 +195,42 @@ jobs:
const different = previous.findIndex((value, index) => value !== order[index]);
if (different !== -1 && previous[different] > order[different]) return;
}
// Recheck after pagination, immediately before attaching statuses.
if (run.event === 'pull_request') {
const {data: pr} = await github.rest.pulls.get({...context.repo, pull_number: number});
if (pr.state !== 'open' || pr.head.sha !== head || pr.base.ref !== branch ||
pr.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) return;
} else {
// A removed/rebuilt queue entry must not receive a passing result.
let cursor = null, member = false;
do {
const response = await github.graphql(`query($owner:String!,$name:String!,$branch:String!,$cursor:String) {
repository(owner:$owner,name:$name) { mergeQueue(branch:$branch) {
entries(first:100,after:$cursor) {
nodes { headCommit { oid } }
pageInfo { hasNextPage endCursor }
}
} }
}`, {owner: context.repo.owner, name: context.repo.repo, branch, cursor});
const entries = response.repository.mergeQueue?.entries;
if (!entries) break;
member = entries.nodes.some(entry => entry.headCommit?.oid === head);
if (member || !entries.pageInfo.hasNextPage) break;
const next = entries.pageInfo.endCursor;
if (!next || next === cursor) throw new Error('Invalid queue pagination');
cursor = next;
} while (true);
const {data: target} = await github.rest.git.getRef({...context.repo, ref: `heads/${branch}`});
fresh = fresh && base === target.object.sha && policy === target.object.sha;
fresh = fresh && member;
Comment on lines +223 to +225

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Invalidate next queue results when the main policy changes

This checks main only immediately before writing the status. Once a next queue head has passed under policy P1, it may still be waiting for another required check when main changes to P2. That does not change the next base or queue SHA, and neither workflow is triggered to invalidate/recompute this result, so the queue can retain a passing budget result for the old policy. This matters when the new CUSTOM_CODE.md activation steps make these statuses required on next. Please keep next non-authoritative until main-policy changes invalidate/re-evaluate its queue results, or implement that coupling before recommending activation. Add a case where main changes after status publication while next is still waiting.

}
for (const [name, result] of [
['Castiron / budget-only change', process.env.ISOLATION_RESULT],
['Castiron / custom-code budget', process.env.BUDGET_RESULT],
]) {
const state = fresh && result === 'success' ? 'success' : 'failure';
const description = !fresh ? 'Evaluation unavailable or queue base changed; inspect the trusted run and rerun.'
: `${state === 'success' ? 'Passed' : 'Failed'} against main ${base.slice(0, 12)}. See the trusted run summary.`;
: `${state === 'success' ? 'Passed' : 'Failed'}: ${branch} ${base.slice(0, 12)}, policy ${policy.slice(0, 12)}.`;
await github.rest.repos.createCommitStatus({...context.repo, sha: head, context: name,
state, description: `${description} ${marker}`, target_url: url});
}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/castiron-custom-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ concurrency:
cancel-in-progress: false

env:
REPORTER_SHA256: d92cc331cea526bb3230ea4b0c2430ad974338a8373134e0b07b3a1ad3535e2a
REPORTER_SHA256: 157ed30ac9d54d20fe5f7e625dc8ee5958beb91cc7e30aaa97cfba8fb3e06e21

jobs:
queue-signal:
Expand Down
115 changes: 86 additions & 29 deletions scripts/castiron/CUSTOM_CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ generation ownership remain visible in the report; do not change exclusions or
checkpoints to hide customization. Non-text custom patches fail as uncountable.

The ceiling is defined in [`.castiron-ratchet.json`](../../.castiron-ratchet.json).
The policy file contains only an integer `schema_version` (currently 1) and a
nonnegative integer `max_custom_patch_lines`. Missing, deleted, renamed, executable, symlinked,
Migration policy contains an integer `schema_version` of 1 and a
nonnegative integer `max_custom_patch_lines`. Strict schema v2 is described below.
Missing, deleted, renamed, executable, symlinked,
malformed, or unsupported policy files fail closed. There is no disable flag or
automatic budget update command.

Expand All @@ -35,40 +36,82 @@ review, not an automated claim that GitHub can identify who operated an account.

## Trusted CI

### Strict generated-file ownership

Schema v1 remains migration mode with its existing patch budget. An SDK with
zero customizations can opt into schema v2 through a policy-only PR:

```json
{ "schema_version": 2, "generated_files": "strict", "max_custom_patch_lines": 0 }
```

First land shared compiler/verifier support and regenerate the SDK, including
`scripts/castiron/generated-files.json`. Then activate strict policy. Both the
main policy and a proposed activation are enforced; strict policy cannot be
downgraded. A policy change cannot be combined with SDK or generated-source edits.
SDK promotion rejects policy-file changes; submit the policy-only PR directly
in the SDK repository.

Strict mode compares generated paths, final file bytes, and Git modes against
the manifest in the verified generated snapshot. The candidate's manifest is
also checked exactly. Header removal, deletion, renaming, symlink substitution,
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.
Local generation validates before integration and publication using compiler-owned
code. New generated snapshots still require the existing generation/promotion
provenance and review controls; a self-computed hash is not approval to publish.

Agents must fix inputs/compiler code or handwritten extensions, regenerate, and
verify. Never patch generated files or the manifest, disable strict mode, change
ownership exclusions, or rewrite provenance to get a passing result. Verification
failure must preserve edits for inspection rather than overwrite them.

The custom-code workflow pair separates candidate execution from trusted checks:

- `castiron-custom-code.yml` runs proposed offline tests and the advisory report
on `pull_request` with read-only permissions.
- `castiron-custom-code-comment.yml` handles `workflow_run` from **main**. Its
read-only compute job captures the main checkout SHA and the source run's PR
head as one immutable evaluation pair. It runs that main reporter against Git objects in a
new bare repository, then reuses that verified report to check main's budget.
It never checks out, imports, installs, or executes candidate code.
- An unprivileged `merge_group` job in the first workflow only signals that a candidate
needs checking. A `workflow_run` handler whose **workflow definition is on
main** then uses the same trusted checkout. It ignores the signal's conclusion
and artifacts, verifies the run and queue membership through GitHub and commit
ancestry, checks isolation per constituent PR, and measures the complete merged
candidate against actual main. A budget PR queued in the same group cannot
grant the SDK PR 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:
read-only compute job pins executable checker code and budget policy to main.
It independently resolves the live PR target (`main` or `next`) and captures
the target baseline and candidate SHA. For main, the target baseline is the
pinned main checkout; for next, it is the live next ref, not stale PR metadata.
It reads only Git objects in a fresh bare repository, never candidate code.
- An unprivileged `merge_group` job in the first workflow signals that a candidate
needs checking. The trusted handler resolves the target from the queue ref,
requires an effective merge-queue rule on that target, and verifies current
queue membership and target ancestry. It checks isolation against the target
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 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`

The policy is read from the captured main commit, not the PR or its merge base.
Reporter changes in a PR cannot change the checker executing on that PR. Missing
snapshots, invalid hashes, unavailable queue membership, and policy errors fail
closed. PR base metadata may lag main; it does not select the evaluation base.
If main moves during a PR evaluation, the result still publishes for the captured
pair, with the evaluated base recorded in the report and status. Merge-group checks
still require current main and independently evaluate the combined candidate.
PR-head statuses are feedback at a point in time: a main update alone
does not rerun them. The checker therefore fails unless main has an effective
**require merge queue** rule. The queue must recheck combined usage against current
main before merging; do not replace that protection with PR-head statuses alone.
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
branch, policy, and head identities; retargeted PRs fail closed.

PR results describe a captured snapshot even if a branch moves during computation.
They do not authorize merging without queue validation. Queue checks require
current main policy and current target baseline. Immediately before publishing,
the handler rechecks the PR head/target or queue membership, target baseline,
and main policy. A stale queue evaluation cannot publish success. Missing
snapshots, invalid hashes, unavailable membership, and policy errors fail closed.

The queue signal cannot supply a passing result, and removing it leaves required
statuses missing. No write-capable job executes from the candidate's workflow
Expand Down Expand Up @@ -101,8 +144,9 @@ python3 -I scripts/castiron/custom_code_budget.py check \
```

The inspected repo's `origin` must match its snapshot source. Omit `--public`
for the private SDK repository's checkpoints. Local checks are diagnostic; CI
independently binds main and the candidate to live GitHub metadata.
for the private SDK repository's checkpoints. Local checks are diagnostic and
use the supplied base as policy; CI independently binds the target, main policy,
and candidate to live GitHub metadata.

## Activation order

Expand All @@ -123,3 +167,16 @@ Workflow definitions and ruleset changes still have the normal repository review
and administrator controls; GitHub Actions status names alone are not a unique
workflow identity. Stronger organization-required workflow enforcement can be
added later.

## Activating next

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. 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.
Loading
Loading