Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.
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
5 changes: 5 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- codex-stack-dependency:start -->
Depends on: none

<!-- For a dependent layer, replace none with its immediate parent, e.g. #123. Use a native GitHub stack. -->
<!-- codex-stack-dependency:end -->
190 changes: 190 additions & 0 deletions .github/workflows/stack-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Generated from policy.cjs by the Codex stack-policy enrollment helper.
name: Stack policy controller
on:
pull_request_target:
types: [opened, reopened, synchronize, edited, closed, ready_for_review, converted_to_draft, stacked, unstacked]
push:
branches: ["master"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Trigger reconciliation on the actual default branch

When this setup is installed on this repository's main trunk—the available repository refs contain main and no master—merging the setup PR cannot trigger this workflow through pull_request_target because the workflow was not yet on the default branch, and the post-merge push is excluded by this master-only filter. Consequently, the initial policy reconciliation does not run automatically and subsequent default-branch pushes remain uncovered; generate this filter from the audited default branch or allow pushes to any branch.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

delete:
workflow_dispatch:
permissions:
contents: read
pull-requests: read
checks: write
concurrency:
group: stack-policy-reconcile
cancel-in-progress: false
jobs:
reconcile:
name: Reconcile stack policy
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
// Stack policy v1. Trusted metadata only; never executes pull request code.
'use strict';

function dependency(body) {
const clean = (body || '').replace(/<!--[\s\S]*?-->/g, '').replace(/```[\s\S]*?```/g, '');
const lines = clean.split(/\r?\n/).filter(x => /^Depends on:/i.test(x.trim()));
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore all Markdown code blocks when parsing declarations

For PR bodies that include a tilde-fenced or four-space-indented example containing Depends on:, this parser treats the example as a real declaration because it strips only triple-backtick fences and then trims indentation. Such a body can have exactly one declaration outside code blocks as required by docs/stacked-prs.md, yet the policy rejects it for having multiple lines; parse Markdown code blocks consistently or also exclude these supported block forms.

Useful? React with 👍 / 👎.

if (lines.length !== 1) throw new Error('Include exactly one line: Depends on: none or Depends on: #123.');
const match = lines[0].trim().match(/^Depends on:\s*(none|#[1-9]\d*)\s*$/i);
if (!match) throw new Error('Dependency must be none or one immediate parent PR number, e.g. #123.');
return match[1].toLowerCase() === 'none' ? null : Number(match[1].slice(1));
}

function validate(snapshot) {
const {pulls, stacks, defaultBranch, repository, ancestors} = snapshot;
const byNumber = new Map(pulls.map(p => [p.number, p]));
const results = new Map();
const activeStacks = stacks.filter(s => s.open);
const membership = new Map();
for (const stack of activeStacks) {
for (const entry of stack.pull_requests) {
const list = membership.get(entry.number) || [];
list.push(stack); membership.set(entry.number, list);
}
}
for (const p of pulls.filter(p => p.state === 'open')) {
try {
const dep = dependency(p.body);
const memberships = membership.get(p.number) || [];
if (memberships.length > 1) throw new Error('PR belongs to multiple active stacks.');
const stack = memberships[0];
if (!!p.stack !== !!stack || (stack && p.stack.number !== stack.number))
throw new Error('Native stack metadata is inconsistent; resubmit/sync and rerun.');
if (stack && stack.base.ref !== defaultBranch) throw new Error('Stack must target the repository default branch.');
let expected = null;
if (stack) {
const active = stack.pull_requests.filter(e => e.state === 'open');
const index = active.findIndex(e => e.number === p.number);
if (index < 0) throw new Error('PR missing from native stack order.');
expected = index > 0 ? active[index - 1].number : null;
if (p.head.repo?.full_name !== repository) throw new Error('Native stack branches must be in this repository.');
}
const parent = dep === null ? null : byNumber.get(dep);
if (dep !== null && !parent) throw new Error(`Parent #${dep} is missing or inaccessible.`);
if (dep === p.number) throw new Error('A PR cannot depend on itself.');
if (parent && parent.state === 'open') {
if (!stack) throw new Error('Dependent PR must be linked into a native GitHub stack.');
if (expected !== dep) throw new Error('Declared parent does not match immediate native stack predecessor.');
if (parent.head.repo?.full_name !== repository || p.base.ref !== parent.head.ref)
throw new Error('PR base must be the immediate parent branch in this repository.');
if (!ancestors[`${parent.head.sha}:${p.head.sha}`]) throw new Error('Parent tip is not an ancestor; rebase the stack.');
} else {
if (expected !== null) throw new Error(`Declare immediate parent #${expected}.`);
if (p.base.ref !== defaultBranch) throw new Error('Standalone or bottom PR must target the default branch.');
if (parent && (!parent.merged_at || parent.base.ref !== defaultBranch))
throw new Error('Closed parent was not merged into the default branch.');
}
// A parent with an unregistered dependent child must not pass as standalone.
const children = pulls.filter(c => c.state === 'open' && c.number !== p.number &&
c.base.ref === p.head.ref && c.base.repo?.full_name === repository && p.head.repo?.full_name === repository);
if (children.length > 1) throw new Error('Multiple child branches: split into separate linear stacks.');
for (const child of children) {
if (!stack || !(membership.get(child.number) || []).some(s => s.number === stack.number))
throw new Error(`Dependent PR #${child.number} is not linked into the same native stack.`);
}
results.set(p.number, {ok:true, message: parent?.merged_at ? 'Parent merged; this layer now targets trunk.' : 'Dependency and native stack structure verified.'});
} catch (error) { results.set(p.number, {ok:false, message:error.message}); }
}
// A broken declaration on a native layer invalidates the whole stack, preventing partial bypass.
for (const stack of activeStacks) {
const bad = stack.pull_requests.find(e => results.get(e.number)?.ok === false);
if (bad) for (const entry of stack.pull_requests) {
if (results.get(entry.number)?.ok) results.set(entry.number, {ok:false, message:`Stack layer #${bad.number} fails policy: ${results.get(bad.number).message}`});
}
}
return results;
}

async function run({github, context, core}) {
const {owner, repo} = context.repo;
const repository = `${owner}/${repo}`;
const headers = {'X-GitHub-Api-Version':'2026-03-10'};
const args = {owner, repo, headers};
const checks = new Map();
const details_url = `${context.serverUrl}/${repository}/actions/runs/${context.runId}`;
async function listOpen() { return github.paginate(github.rest.pulls.list, {...args, state:'open', per_page:100}); }
async function pending(pulls) {
for (const p of pulls) if (!checks.has(p.head.sha)) {
const {data} = await github.rest.checks.create({...args, name:'Stack policy', head_sha:p.head.sha,
status:'in_progress', details_url, output:{title:'Validating current dependency graph', summary:'Validation is pending; no PR code is executed.'}});
checks.set(p.head.sha, data.id);
}
}
async function snapshot(open) {
const {data: repositoryData} = await github.rest.repos.get(args);
const stacks = await github.paginate('GET /repos/{owner}/{repo}/stacks', {...args, per_page:100});
const pulls = [...open];
const seen = new Set(pulls.map(p => p.number));
for (const p of open) {
let dep; try { dep = dependency(p.body); } catch { continue; }
if (dep !== null && !seen.has(dep)) {
try {
const {data} = await github.rest.pulls.get({...args, pull_number:dep});
pulls.push(data); seen.add(dep);
} catch (error) {
if (error.status !== 404) throw error; // A nonexistent parent fails its PR, not unrelated PRs.
seen.add(dep);
}
}
}
return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}};
}
function fingerprint(s) {
return JSON.stringify({defaultBranch:s.defaultBranch,
pulls:s.pulls.map(p => ({number:p.number, state:p.state, merged_at:p.merged_at, body:p.body,
head:[p.head.ref,p.head.sha,p.head.repo?.full_name],base:[p.base.ref,p.base.sha,p.base.repo?.full_name],stack:p.stack})).sort((a,b)=>a.number-b.number),
stacks:s.stacks.filter(s=>s.open).map(s=>({number:s.number,base:s.base,prs:s.pull_requests.map(p=>[p.number,p.state,p.head.sha])})).sort((a,b)=>a.number-b.number)});
}
try {
// Invalidate old success before querying stacks or ancestors. Failed API calls leave failures.
if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]);
let open = await listOpen();
await pending(open);
let stable, results;
for (let attempt=0; attempt<3; attempt++) {
const before = await snapshot(open);
for (const p of open) {
let dep; try { dep=dependency(p.body); } catch { continue; }
const parent = before.pulls.find(q=>q.number===dep && q.state==='open');
if (parent && parent.number !== p.number && parent.head.repo?.full_name === repository &&
p.head.repo?.full_name === repository && p.base.ref === parent.head.ref) {
const {data} = await github.rest.repos.compareCommitsWithBasehead({...args, basehead:`${parent.head.sha}...${p.head.sha}`});
before.ancestors[`${parent.head.sha}:${p.head.sha}`] = data.merge_base_commit.sha === parent.head.sha;
}
}
results = validate(before);
open = await listOpen(); await pending(open);
const after = await snapshot(open);
if (fingerprint(before) === fingerprint(after)) { stable=after; break; }
}
if (!stable) throw new Error('Dependency graph changed repeatedly during validation; rerun when stable.');
const rows=[];
for (const [sha,id] of checks) {
// Re-read all dependency metadata before issuing any success for this SHA.
const fresh = await snapshot(await listOpen());
if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.');
const prs = stable.pulls.filter(p=>p.state==='open' && p.head.sha===sha);
const bad = prs.filter(p=>!results.get(p.number)?.ok);
const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.';
await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:bad.length?'failure':'success',
output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}});
rows.push(summary);
}
await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write();
} catch(error) {
// Revoke even successes already published during this run if a later snapshot/API fails.
for (const id of checks.values()) {
try { await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:'failure',
output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000)}}); }
catch (updateError) { core.error(`Failed to revoke check ${id}: ${updateError.message}`); }
}
core.setFailed(error.message);
}
}

await run({github, context, core});
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!-- codex-stack-policy:start -->
## Native stacked pull requests (default)

- Use GitHub's official `gh stack` extension for dependent changes. Before implementing multi-part work, choose focused, reviewable layers; keep tests with the behavior they validate. Small independent changes may use one PR. Do not manufacture a second PR or impose a line-count cutoff.
- The bottom layer targets the repository default branch; every subsequent layer targets its immediate predecessor. Independent efforts use separate stacks. Read the official `gh-stack` skill when available and `docs/stacked-prs.md` in enrolled repositories.
- Use explicit non-interactive commands: `gh stack init <branch>`, `gh stack add <branch>`, `gh stack view --json`, and `gh stack submit --auto --remote origin`. Substitute the verified remote name. Submit drafts, then write specific PR titles and descriptions and verify native remote stack membership through the GitHub API. Each PR body must contain exactly one `Depends on: none` or `Depends on: #123` line naming its immediate parent. After a parent merges and GitHub retargets the next layer, change its declaration to `none` (the check also accepts the merged parent during transition).
- Before `rebase`, `sync`, or `push`, inspect `git worktree list --porcelain`, dirty state, branch ownership, and remote tips. Serialize writes within a stack. Never rewrite a branch active in another task/worktree. Use the extension's explicit remote and lease protection; never plain force-push. Inspect resulting local and remote state: `gh stack sync` can report an aborted operation with exit code zero.
- Edit the layer that owns a change and restack affected descendants. Resolve conflicts deliberately; preserve unrelated changes. Do not use interactive-only `gh stack modify` in automation.
- Follow repository validation requirements for every affected layer and wait for required checks. Do not use admin bypass or disable policy to ship feature work. Merge a native stack only with an explicit target, e.g. `gh stack merge <PR-number> --yes --squash` when squash is the repository's allowed method. That operation also merges every unmerged layer below the target: confirm the intended set and existing merge authorization first. Use `gh pr merge` only for standalone PRs.
- On first setup of a repository owned by `aletty`, audit enrollment using `python3 ~/.codex/stack-policy/enroll.py audit --repo OWNER/REPO`. For a non-archived, non-fork repository, apply missing policy through its setup PR and activate protection after the setup lands and a real policy check succeeds. The helper's `apply`, `enforce`, and `rollback` subcommands are documented in its README. Empty repositories wait for their initial branch. If tools, permissions, or account features prevent enrollment, report the exact gap; do not claim enforcement exists.
- Global defaults apply to local Codex work in all repositories. Repository changes and GitHub administration are limited to enrolled, user-owned repositories. Respect explicit user instructions and higher-priority instructions; flag conflicting repository policy rather than silently weakening enforcement.
<!-- codex-stack-policy:end -->
46 changes: 46 additions & 0 deletions docs/stacked-prs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Stacked pull requests

Dependent work uses native GitHub stacks. A focused independent change may be a standalone PR. Each layer includes its relevant tests and targets the immediately preceding branch; the bottom targets this repository's default branch.

## Develop and submit

Read the repository's AGENTS.md and the official gh-stack skill. Verify the repository and remote before running commands:

```sh
gh stack init topic/foundation
# Implement, validate, stage only intended files, and commit.
gh stack add topic/consumer
# Implement and validate the dependent concern, then commit.
gh stack submit --auto --remote origin
gh stack view --json
```

Use `gh pr edit` to give each draft a specific title and description. Include exactly one dependency line outside comments and code fences:

```text
Depends on: none
```

For a child, use `Depends on: #123` with the immediate parent's number. Bot PRs follow the same declaration rule; add the line before making them ready to merge. Verify native membership with `gh api repos/OWNER/REPO/stacks`, not merely branch bases or navigation comments.

## Update safely

Inspect dirty state, `git worktree list --porcelain`, other task ownership, and remote tips before rewriting. One writer manages a stack. Do not rewrite branches in another active worktree. Edit the owning layer, use `gh stack rebase --upstack --remote origin`, then validate the changed layer and descendants and push with the extension's lease checks. `gh stack sync --remote origin` can abort with exit code zero: inspect output and `gh stack view --json` and verify GitHub state.

## Required policy

`Stack policy` validates declarations, immediate parents, current parent ancestry, native membership, and linear ordering. Independent and bottom PRs target the default branch. Closed parents must have merged into the default branch; the surviving bottom layer may temporarily keep that merged parent's declaration after retargeting. A broken layer blocks its native stack. Parent/child PRs with the same head SHA share the most restrictive result.

The controller reads trusted workflow code and GitHub metadata only. It does not check out or execute PR code. PR events, pushes, branch deletion, and manual dispatch reconcile all open PRs. API or consistency failures leave failing checks. Re-run the controller after correcting a declaration or linking a stack. Semantic focus remains a reviewer responsibility.

Default-branch protection requires PRs and policy/CI checks, including for administrators, and forbids force pushes and deletion. Native stacks inherit trunk requirements for each layer. Branches used for stack development remain rewriteable with lease protection. Fork contributions can be standalone PRs; cross-fork native stacks are unsupported.

## Merge

Wait for checks and existing merge authorization. Verify the exact target and its unmerged predecessors. Use `gh stack merge <PR-number> --yes` with an explicit allowed merge method; targeting an upper layer includes lower layers. Verify the asynchronous result on GitHub. After bottom-layer merge, verify automatic retargeting and rebase, update the new bottom's dependency to `none`, and sync locally. Do not bypass protection.

## Enrollment and recovery

The policy bundle and enrollment helper live at `~/.codex/stack-policy` on the configured Codex host. The helper audits before applying and saves original branch protection and repository file contents. Roll back protection with its `rollback --repo OWNER/REPO` command; revert the setup PR through a new PR to remove committed policy. Rollback does not reset developer branches or undo unrelated repository settings.

GitHub may change its public-preview stack APIs. A failing validation must be investigated, not silently bypassed. Repository owners can deliberately change GitHub settings; these controls govern normal contribution and merge paths, not owner authority.