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
6 changes: 6 additions & 0 deletions .github/pr-stack.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,11 @@
"number": 2,
"branch": "fork/changes"
}
],
"integrationOverlays": [
{
"number": 10,
"branch": "t3-discord/f7d37879-desktop-deeplinks"
}
]
}
35 changes: 35 additions & 0 deletions .github/workflows/managed-pr-draft-lock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Managed PR draft lock

on:
pull_request_target:
types: [opened, reopened, ready_for_review, synchronize]

permissions:
contents: read
pull-requests: write

jobs:
keep-draft:
name: Keep managed PR draft
runs-on: ubuntu-24.04
steps:
- name: Restore draft state without changing CI status
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
run: |
manifest="$(
gh api \
-H 'Accept: application/vnd.github.raw+json' \
"repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/changes"
)"
if ! jq -e --argjson number "${PR_NUMBER}" \
'([.pullRequests[], .integrationOverlays[]] | any(.number == $number))' \
<<<"${manifest}" >/dev/null; then
exit 0
fi
if [[ "${IS_DRAFT}" != "true" ]]; then
gh pr ready "${PR_NUMBER}" --undo --repo "${REPOSITORY}"
fi
35 changes: 35 additions & 0 deletions .github/workflows/rebase-pr-stack.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: Rebase fork PR stack

on:
pull_request:
types: [opened, reopened, synchronize, converted_to_draft]
branches: [fork/changes]
push:
branches:
- fork/tim
Expand All @@ -20,8 +23,37 @@ permissions:
actions: write

jobs:
classify:
name: Classify stack event
runs-on: ubuntu-24.04
outputs:
run: ${{ steps.classify.outputs.run }}
steps:
- uses: actions/checkout@v6
with:
ref: fork/changes
fetch-depth: 1
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
if [[ "${EVENT_NAME}" != "pull_request" ]]; then
echo "run=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
if jq -e --argjson number "${PR_NUMBER}" \
'.integrationOverlays | any(.number == $number)' \
.github/pr-stack.json >/dev/null; then
echo "run=true" >> "${GITHUB_OUTPUT}"
else
echo "run=false" >> "${GITHUB_OUTPUT}"
fi

rebase:
name: Rebase and dispatch integration CI
needs: classify
if: needs.classify.outputs.run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -55,6 +87,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: node scripts/rebase-pr-stack.ts sync --push

- name: Compose registered integration overlays
run: node scripts/compose-integration-overlays.ts

- name: Dispatch integration CI
env:
GH_TOKEN: ${{ github.token }}
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ branches.
contains selected open upstream PRs that we run before upstream accepts them, one provenance
commit per source PR. The permanent `fork/changes` PR is based on `fork/candidates`, contains only
our private layer, remains open, and is the GitHub/T3 default branch.
- Long-lived upstreamable features may be registered as `integrationOverlays`. They remain parallel
draft PRs based on `fork/changes`; `fork/integration` composes them in manifest order. Never merge
a registered overlay directly. Update its branch, or use
`pnpm fork:stack overlay-start <pr> <branch>` and target the child PR at the overlay branch.
Draft state blocks merging while normal green CI remains meaningful.
- Start new work with `pnpm fork:stack start <branch>` and open the PR against `fork/changes`.
Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork
only after being reviewed and merged into `fork/changes`.
Expand Down
32 changes: 29 additions & 3 deletions docs/fork-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,42 @@ pingdotgg/t3code:main
└── fork/tim selected Tim Smart PRs
└── fork/candidates selected open upstream PRs
└── fork/changes our private changes
├── feature PRs
└── fork/integration tested and deployed tip
├── ordinary feature PRs
├── registered draft overlays
└── fork/integration changes + overlays, tested/deployed
```

`main` mirrors `pingdotgg/t3code:main`. `fork/tim` is a linear provenance layer with one commit per
selected Tim Smart PR and a permanently open PR against `main`. `fork/candidates` is a temporary
upstream-provenance layer with one commit per selected open upstream PR and a permanently open PR
against `fork/tim`. `fork/changes` is the GitHub default branch and canonical private layer, with a
permanently open PR against `fork/candidates`.
`fork/integration` is generated from both reviewed layers and is used by running instances.
`fork/integration` is generated from the reviewed layers plus registered integration overlays and
is used by running instances.

## Long-lived integration overlays

An upstreamable feature may remain as an open PR instead of being merged into `fork/changes`.
Register it under `integrationOverlays` in `.github/pr-stack.json`. Every overlay remains a
**parallel draft PR based on `fork/changes`**; overlays are never based on each other. The stack
workflow rebases overlays when `fork/changes` moves and composes their commits, in manifest order,
only in `fork/integration`.

Draft state is the merge lock. Normal Fork CI continues to run and can remain green, so health and
merge permission remain separate signals. A trusted workflow automatically returns managed PRs
(#1, #27, #2, and registered overlays) to draft if they are accidentally marked ready.

```sh
pnpm fork:stack overlay-add 10
pnpm fork:stack overlay-start 10 feature/deep-link-follow-up
pnpm fork:stack overlay-promote 10 upstream/desktop-deep-links
```

To change an overlay, commit directly to its branch or create a child PR with the overlay branch as
its base and merge the child into the overlay PR. Do not put the same change into `fork/changes`.
Landing an overlay is deliberate: remove its manifest entry in the same reviewed change that lands
the implementation in `fork/changes`, then verify that the resulting `fork/integration` tree is
unchanged.

## Updating from upstream

Expand Down
13 changes: 13 additions & 0 deletions scripts/compose-integration-overlays.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vite-plus/test";

import { overlayCommitList } from "./compose-integration-overlays.ts";

describe("integration overlay composition", () => {
it("keeps overlay commits in oldest-first rev-list order", () => {
expect(overlayCommitList("oldest\nmiddle\nnewest\n")).toEqual(["oldest", "middle", "newest"]);
});

it("handles an empty rev-list", () => {
expect(overlayCommitList("")).toEqual([]);
});
});
107 changes: 107 additions & 0 deletions scripts/compose-integration-overlays.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env node

import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeURL from "node:url";

import { readManifest, StackError } from "./rebase-pr-stack.ts";

function git(cwd: string, args: ReadonlyArray<string>): string {
const result = NodeChildProcess.spawnSync("git", [...args], {
cwd,
encoding: "utf8",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true" },
});
if (result.status !== 0) {
throw new StackError(`git ${args.join(" ")} failed: ${result.stderr.trim()}`);
}
return result.stdout.trim();
}

export function overlayCommitList(revListOutput: string): ReadonlyArray<string> {
return revListOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

export function composeIntegration(sourceRoot = process.cwd(), push = true): string {
const manifest = readManifest(sourceRoot);
const originUrl = git(sourceRoot, ["remote", "get-url", "origin"]);
const workDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "compose-overlays-"));
const repoDir = NodePath.join(workDir, "repo");
NodeFS.mkdirSync(repoDir);
try {
git(repoDir, ["init", "--quiet"]);
git(repoDir, ["config", "user.name", "T3 Code PR Stack"]);
git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]);
git(repoDir, ["config", "commit.gpgsign", "false"]);
git(repoDir, ["remote", "add", "origin", originUrl]);
const branches = [
manifest.forkChangesBranch,
manifest.integrationBranch,
...manifest.integrationOverlays.map(({ branch }) => branch),
];
git(repoDir, [
"fetch",
"--quiet",
"--no-tags",
"origin",
...branches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`),
]);
const base = git(repoDir, ["rev-parse", `origin/${manifest.forkChangesBranch}`]);
const previous = git(repoDir, ["rev-parse", `origin/${manifest.integrationBranch}`]);
git(repoDir, ["checkout", "--quiet", "--detach", base]);
for (const overlay of manifest.integrationOverlays) {
const tip = git(repoDir, ["rev-parse", `origin/${overlay.branch}`]);
const ancestor = NodeChildProcess.spawnSync(
"git",
["merge-base", "--is-ancestor", base, tip],
{ cwd: repoDir, encoding: "utf8" },
);
if (ancestor.status !== 0) {
throw new StackError(
`Overlay PR #${overlay.number} (${overlay.branch}) is not based on current ${manifest.forkChangesBranch}.`,
);
}
const commits = overlayCommitList(
git(repoDir, ["rev-list", "--reverse", "--no-merges", `${base}..${tip}`]),
);
if (commits.length === 0) {
throw new StackError(
`Overlay PR #${overlay.number} has no commits above ${manifest.forkChangesBranch}.`,
);
}
git(repoDir, ["cherry-pick", ...commits]);
}
const next = git(repoDir, ["rev-parse", "HEAD"]);
if (push && next !== previous) {
git(repoDir, [
"push",
`--force-with-lease=refs/heads/${manifest.integrationBranch}:${previous}`,
"origin",
`${next}:refs/heads/${manifest.integrationBranch}`,
]);
}
return next;
} finally {
NodeFS.rmSync(workDir, { recursive: true, force: true });
}
}

const isMain =
process.argv[1] !== undefined &&
import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href;

if (isMain) {
const push = !process.argv.includes("--dry-run");
try {
const tip = composeIntegration(process.cwd(), push);
console.log(`${push ? "Updated" : "Would update"} integration to ${tip}.`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
33 changes: 33 additions & 0 deletions scripts/fork-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import {
planFeatureBranchUpdate,
planLocalSyncWithRemote,
registerPullRequest,
registerIntegrationOverlay,
shouldRetargetPullRequestBase,
stackParentBranch,
uniqueLocalCommitsFromCherry,
unregisterTopPullRequest,
unregisterIntegrationOverlay,
} from "./fork-stack.ts";

const manifest: StackManifest = {
Expand All @@ -26,6 +28,7 @@ const manifest: StackManifest = {
forkChangesBranch: "fork/changes",
integrationBranch: "fork/integration",
pullRequests: [],
integrationOverlays: [],
};

describe("fork stack helpers", () => {
Expand Down Expand Up @@ -251,4 +254,34 @@ describe("fork stack helpers", () => {
]);
expect(() => unregisterTopPullRequest(stacked, 201)).toThrow(/Only the top PR/);
});

it("registers only draft overlays based on fork/changes", () => {
const next = registerIntegrationOverlay(manifest, {
number: 10,
state: "OPEN",
headRefName: "feature/deep-links",
baseRefName: "fork/changes",
isDraft: true,
});
expect(next.integrationOverlays).toEqual([{ number: 10, branch: "feature/deep-links" }]);
expect(() =>
registerIntegrationOverlay(manifest, {
number: 11,
state: "OPEN",
headRefName: "feature/ready",
baseRefName: "fork/changes",
isDraft: false,
}),
).toThrow(/must be a draft/);
expect(() =>
registerIntegrationOverlay(manifest, {
number: 12,
state: "OPEN",
headRefName: "feature/wrong-base",
baseRefName: "main",
isDraft: true,
}),
).toThrow(/expected fork\/changes/);
expect(unregisterIntegrationOverlay(next, 10).integrationOverlays).toEqual([]);
});
});
Loading
Loading