This repository was archived by the owner on Sep 9, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
chore: enforce native stacked pull requests #1
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 --> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For PR bodies that include a tilde-fenced or four-space-indented example containing 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}); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 --> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this setup is installed on this repository's
maintrunk—the available repository refs containmainand nomaster—merging the setup PR cannot trigger this workflow throughpull_request_targetbecause the workflow was not yet on the default branch, and the post-mergepushis excluded by thismaster-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 👍 / 👎.