From 2324c8d086e91348a837baba5cad2e803cdd91b3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:32:09 +0200 Subject: [PATCH 1/3] fix(ci): credit takeover authors and exempt stacked PR bases Release notes attributed maintainer takeovers to the landing author only, and enforce-pr-target drafted stacked children that correctly targeted a parent head. Rewrite takeover credits to name the original creator, skip wrong-base for open stacked parents, and drop the retired dual-track essay from AGENTS.md so agent guidance stays current-policy only. --- .github/scripts/enforce-pr-target.test.cjs | 11 ++ .github/scripts/pr-quality.cjs | 7 +- .github/scripts/pr-quality.test.cjs | 35 ++++++ .github/workflows/enforce-pr-target.yml | 36 +++++- .github/workflows/release.yml | 4 + AGENTS.md | 34 ++--- scripts/release-notes.ts | 138 +++++++++++++++++++++ tests/ci-workflows.test.ts | 69 ++++++++++- tests/helpers/enforce-pr-target-harness.ts | 75 ++++++++++- tests/release-notes.test.ts | 62 +++++++++ 10 files changed, 437 insertions(+), 34 deletions(-) diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 79c3dbd26..f0636f273 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -61,6 +61,17 @@ describe("enforce-pr-target workflow", () => { assert.match(workflow, /collectPrQualityFailures/); }); + it("checks stacked bases via open PR heads before wrong_base enforcement", () => { + assert.match(workflow, /stackedBase/); + assert.match(workflow, /github\.rest\.pulls\.list/); + assert.match(workflow, /treating as stacked/); + const qualityCall = workflow.match( + /collectPrQualityFailures\(\{([\s\S]*?)\}\);/, + ); + assert.ok(qualityCall, "must call collectPrQualityFailures"); + assert.match(qualityCall[1], /stackedBase/); + }); + it("strips stale WRONG BRANCH prefix on failure when base is corrected", () => { const failureBlock = workflow.match( /if \(failures\.length > 0\) \{([\s\S]*?)core\.setFailed\(/, diff --git a/.github/scripts/pr-quality.cjs b/.github/scripts/pr-quality.cjs index ff286acdc..0d9f9e786 100644 --- a/.github/scripts/pr-quality.cjs +++ b/.github/scripts/pr-quality.cjs @@ -134,15 +134,20 @@ function collectPrQualityFailures({ authorPermission, permissionLookupFailed = false, ancestryLookupFailed = false, + /** True when baseRef is another open PR's head (stacked child). */ + stackedBase = false, }) { const failures = []; - const wrongBase = !allowedBases.includes(baseRef); + const wrongBase = !allowedBases.includes(baseRef) && !stackedBase; if (wrongBase) { failures.push({ code: "wrong_base" }); } else { // Permission lookup fails closed (still evaluate ancestry). Compare API // failures skip ancestry — zeros would falsely pass the #644 heuristic. + // Stacked children skip ancestry against the integration base; their parent + // PR is the temporary target. const skipAncestry = + stackedBase || ancestryLookupFailed || (!permissionLookupFailed && authorHasPushPermission(authorPermission)); if ( diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs index 487ef38b2..fa501162f 100644 --- a/.github/scripts/pr-quality.test.cjs +++ b/.github/scripts/pr-quality.test.cjs @@ -242,4 +242,39 @@ describe("collectPrQualityFailures", () => { }); assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); }); + + it("skips wrong_base when stackedBase is set", () => { + const failures = collectPrQualityFailures({ + baseRef: "feature/parent", + allowedBases: allowed, + body: [ + "## Summary", + "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", + "", + "## Test plan", + "- Launch the tray app after setting CODEX_HOME", + "- Confirm the listener and launcher use the same workspace root", + ].join("\n"), + behindMain: 0, + behindBase: 0, + aheadMain: 0, + authorPermission: "read", + stackedBase: true, + }); + assert.ok(!failures.some((f) => f.code === "wrong_base")); + assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + }); + + it("still flags wrong_base for non-allow-list bases without stackedBase", () => { + const failures = collectPrQualityFailures({ + baseRef: "main", + allowedBases: allowed, + body: "fix stuff", + behindMain: 0, + behindBase: 0, + authorPermission: "read", + stackedBase: false, + }); + assert.ok(failures.some((f) => f.code === "wrong_base")); + }); }); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index 3c9b61459..c6b9a3123 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -281,6 +281,39 @@ jobs: let ancestryLookupFailed = false; const baseAllowed = ALLOWED_BASES.includes(pr.base.ref); + // Stacked PR exception: base is another open PR's head branch (same + // head repo as this PR's base repo). Closed/missing parent stays wrong_base. + let stackedBase = false; + if (!baseAllowed) { + try { + const openPrs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100 + }); + const baseOwner = + pr.base.repo?.owner?.login ?? owner; + const baseName = pr.base.repo?.name ?? repo; + stackedBase = openPrs.some( + other => + other.number !== pull_number && + other.head?.ref === pr.base.ref && + (other.head?.repo?.owner?.login ?? owner) === baseOwner && + (other.head?.repo?.name ?? repo) === baseName + ); + if (stackedBase) { + core.info( + `Base ${pr.base.ref} matches an open PR head; treating as stacked (skip wrong_base).` + ); + } + } catch (error) { + core.warning( + `Could not list open PRs for stacked-base check: ${error.message}` + ); + } + } + if (baseAllowed) { const headSha = pr.head.sha; try { @@ -317,7 +350,8 @@ jobs: aheadMain, authorPermission, permissionLookupFailed, - ancestryLookupFailed + ancestryLookupFailed, + stackedBase }); if (failures.length > 0) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 091058da3..6d613cc54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -393,6 +393,10 @@ jobs: pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" # Drop generate-notes' trailing compare link; we re-append it after the commit list. printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" + bun scripts/release-notes.ts credit-takeovers \ + --repo "$GITHUB_REPOSITORY" \ + --in "$delta_file" \ + --out "$delta_file" else # First release on this channel: never call generate-notes without previous_tag_name. # GitHub would baseline the newest repo tag, which may belong to the other channel. diff --git a/AGENTS.md b/AGENTS.md index fce9ba00c..00a6c472a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,33 +101,13 @@ non-trivial change. CI runs these on Linux, Windows, and macOS. from `dev` (releases, docs deploys). Do not open feature PRs against `main`. - `preview` — prerelease train (`x.y.z-preview.*` versions). -### The retired `dev2-go` line - -The project previously ran a parallel `dev2-go` integration line that was -rebuilding the runtime as a Go native port, and every merge into `dev` had to be -carried onto it. That dual-track policy is over: maintaining two integration -lines cost more than the port returned, and dogfooding the Go runtime kept -surfacing new defects. - -`dev2-go` has been deleted, along with the `codex/260728-go-port-*` and -`tmp/dev2-go-source-export` side branches. The full history lives in -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive) -and the final tip is tagged `archive/dev2-go` in this repository. There is no -carry or port obligation attached to a `dev` merge any more, and the -`needs-go-port` label is gone. - -Bun-native TypeScript is the only runtime line. If native code returns, the -expectation is an incremental module (for example Rust via N-API) landing on -`dev`, not a second full-runtime branch. - -The Claude Desktop integration formerly carried on the `claudedesktop` branch is -now fully merged into `dev`, and that branch has been retired. Desktop work -continues as normal pull requests against `dev`. - -Porting and rebase pull requests are welcome. Forward-porting a fix from one -integration line to another, or rebasing a stale branch onto the current head, -is ordinary maintenance rather than noise — open it as a normal pull request -and name the source commits in the description. +Bun-native TypeScript on `dev` is the only runtime line. If native code +returns, the expectation is an incremental module (for example Rust via N-API) +landing on `dev`, not a second full-runtime branch. + +Rebase pull requests are welcome. Bringing a stale branch onto the current head +is ordinary maintenance — open it as a normal pull request and name the source +commits in the description. The **`enforce-target`** CI check rejects pull requests whose head ancestry sits on the **`main`** tip while far behind **`dev`**, and rejects diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 0d37785f0..8590102db 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -10,6 +10,7 @@ * bun scripts/release-notes.ts matching-preview-tags * bun scripts/release-notes.ts previous-release-tag * bun scripts/release-notes.ts has-meaningful [body-file] + * bun scripts/release-notes.ts credit-takeovers --repo --in --out * bun scripts/release-notes.ts assemble --npm-metadata ... --out ... */ @@ -207,6 +208,69 @@ export function selectNewestCarriedPreviewTag( return newest; } +/** + * Parse a maintainer-takeover source PR number from title/body text. + * Matches forms already used in-repo: `takeover of #N`, `takeover #N`, + * `maintainer takeover of #N` (case-insensitive). + */ +export function parseTakeoverSourcePr(title: string, body = ""): number | null { + const text = `${title}\n${body}`; + const match = /\b(?:maintainer\s+)?takeover(?:\s+of)?\s+#(\d+)\b/i.exec(text); + if (!match) return null; + const n = Number(match[1]); + return Number.isInteger(n) && n > 0 ? n : null; +} + +const GENERATE_NOTES_PR_LINE = + /^(?\* .+? by @)(?[A-Za-z0-9-]+)(? in https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/)(?\d+)(?\s*)$/; + +export type TakeoverCreditLookup = { + title: string; + body: string; + authorLogin: string; +}; + +/** + * Rewrite generate-notes lines so maintainer-takeover PRs also credit the + * original PR creator: `by @Original (takeover by @Landing) in …/pull/P`. + */ +export async function rewriteTakeoverCredits( + notesBody: string, + resolveLanding: (prNumber: number) => Promise, + resolveOriginalAuthor: (sourcePrNumber: number) => Promise, +): Promise { + const lines = notesBody.replace(/\r\n/g, "\n").split("\n"); + const out: string[] = []; + for (const line of lines) { + const match = GENERATE_NOTES_PR_LINE.exec(line); + if (!match?.groups) { + out.push(line); + continue; + } + const landingPr = Number(match.groups.pr); + const landingAuthor = match.groups.author!; + const landing = await resolveLanding(landingPr); + if (!landing) { + out.push(line); + continue; + } + const sourcePr = parseTakeoverSourcePr(landing.title, landing.body); + if (sourcePr == null) { + out.push(line); + continue; + } + const original = await resolveOriginalAuthor(sourcePr); + if (!original || original.toLowerCase() === landingAuthor.toLowerCase()) { + out.push(line); + continue; + } + out.push( + `${match.groups.prefix}${original} (takeover by @${landingAuthor})${match.groups.mid}${match.groups.pr}${match.groups.suffix ?? ""}`, + ); + } + return out.join("\n"); +} + export function assembleReleaseNotes(input: { npmMetadata: string; carriedPreviewNotes?: string; @@ -324,6 +388,79 @@ async function main(argv: string[]): Promise { return; } + if (cmd === "credit-takeovers") { + const args = new Map(); + for (let i = 0; i < rest.length; i += 1) { + const key = rest[i]; + if (!key?.startsWith("--")) continue; + const value = rest[i + 1]; + if (!value || value.startsWith("--")) { + console.error(`Missing value for ${key}`); + process.exit(1); + } + args.set(key.slice(2), value); + i += 1; + } + const repo = args.get("repo"); + const inputPath = args.get("in"); + const outPath = args.get("out"); + if (!repo || !inputPath || !outPath) { + console.error("Usage: bun scripts/release-notes.ts credit-takeovers --repo --in --out "); + process.exit(1); + } + const [owner, name] = repo.split("/"); + if (!owner || !name || repo.split("/").length !== 2) { + console.error(`Invalid --repo value: ${repo}`); + process.exit(1); + } + + async function ghJson(path: string): Promise { + const proc = Bun.spawn(["gh", "api", path], { + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) { + console.error(`gh api ${path} failed: ${stderr.trim() || `exit ${exitCode}`}`); + return null; + } + try { + return JSON.parse(stdout) as unknown; + } catch { + console.error(`gh api ${path} returned non-JSON`); + return null; + } + } + + const body = await Bun.file(inputPath).text(); + const rewritten = await rewriteTakeoverCredits( + body, + async (prNumber) => { + const data = await ghJson(`repos/${owner}/${name}/pulls/${prNumber}`); + if (!data || typeof data !== "object") return null; + const pr = data as { title?: unknown; body?: unknown; user?: { login?: unknown } }; + if (typeof pr.title !== "string" || typeof pr.user?.login !== "string") return null; + return { + title: pr.title, + body: typeof pr.body === "string" ? pr.body : "", + authorLogin: pr.user.login, + }; + }, + async (sourcePrNumber) => { + const data = await ghJson(`repos/${owner}/${name}/pulls/${sourcePrNumber}`); + if (!data || typeof data !== "object") return null; + const pr = data as { user?: { login?: unknown } }; + return typeof pr.user?.login === "string" ? pr.user.login : null; + }, + ); + await Bun.write(outPath, rewritten.endsWith("\n") ? rewritten : rewritten + "\n"); + return; + } + if (cmd === "assemble") { const args = new Map(); for (let i = 0; i < rest.length; i += 1) { @@ -372,6 +509,7 @@ Usage: bun scripts/release-notes.ts matching-preview-tag # tags on stdin bun scripts/release-notes.ts matching-preview-tags # tags on stdin, oldest→newest bun scripts/release-notes.ts previous-release-tag # tags on stdin + bun scripts/release-notes.ts credit-takeovers --repo --in --out bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); process.exit(1); } diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 76b8eb9dd..66f22a577 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -283,6 +283,7 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("bun scripts/release-notes.ts previous-release-tag"); expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful"); expect(workflow).toContain("bun scripts/release-notes.ts join-carried"); + expect(workflow).toContain("bun scripts/release-notes.ts credit-takeovers"); // Preview notes must baseline any prior release (stable or preview), not preview-only. expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"'); expect(workflow).not.toMatch( @@ -410,6 +411,7 @@ describe("GitHub Actions hardening", () => { "pulls.get", "issues.listComments", "repos.getCollaboratorPermissionLevel", + "pulls.list", ...tail, ]; } @@ -715,12 +717,13 @@ describe("GitHub Actions hardening", () => { expect(script).not.toMatch(/issue_number:\s*\d/); // These are the only three mutating REST calls. A fourth is a new write - // nobody reviewed. + // nobody reviewed. `pulls.list` is a stacked-base read, not a write. const restWrites = [...script.matchAll(/github\.rest\.[\w.]+/g)] .map(match => match[0]) .filter( name => !name.endsWith(".get") && + !name.endsWith(".list") && !name.endsWith(".listComments") && name !== "github.rest.repos.getCollaboratorPermissionLevel" && name !== "github.rest.repos.compareCommitsWithBasehead", @@ -1006,6 +1009,70 @@ describe("GitHub Actions hardening", () => { expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); }); + test("a stacked PR targeting another open PR head is not wrong-base", async () => { + const parentHead = "feature/parent-stack"; + const result = await run({ + pr: { + number: 42, + base: { + ref: parentHead, + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + title: "Stacked child", + draft: false, + }, + openPulls: [ + { + number: 41, + head: { + ref: parentHead, + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + }, + ], + }); + + expect(methodsOf(result)).toEqual(readsWrongBase()); + expect(callsTo(result, "pulls.update")).toEqual([]); + expect(callsTo(result, "graphql")).toEqual([]); + expect(result.logs.join(" ")).toContain("treating as stacked"); + expect(result.logs.join(" ")).toContain("All PR quality gates passed"); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + }); + + test("a non-dev base with no open parent PR is still wrong-base", async () => { + const result = await run({ + pr: { base: { ref: "feature/orphan" }, title: "Orphan stack", draft: false }, + openPulls: [ + { + number: 99, + head: { + ref: "feature/other", + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + }, + ], + }); + + expect(methodsOf(result)).toEqual(readsWrongBase([ + "issues.createComment", + "pulls.update", + "issues.updateComment", + "graphql", + "issues.updateComment", + "issues.updateComment", + ])); + expect(callsTo(result, "pulls.update")).toEqual([ + { + owner: "lidge-jun", + repo: "opencodex", + pull_number: 42, + title: "[WRONG BRANCH] Orphan stack", + }, + ]); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + }); + test("a PR that was already a draft is not un-drafted afterwards", async () => { const wrong = await run({ pr: { base: { ref: "main" }, draft: true }, diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index e6bdf879a..08ee528e5 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -89,6 +89,11 @@ export type RunOptions = { failPermissionLookup?: boolean; /** Overrides for `compareCommitsWithBasehead` keyed by `basehead`. */ compareByBasehead?: Record; + /** + * Open PRs returned by `pulls.list` (page 1). Used for stacked-base detection + * when this PR's base is another PR's head ref. + */ + openPulls?: unknown[]; }; /** @@ -121,8 +126,18 @@ const DEFAULT_PR = { merged: false, locked: false, html_url: "https://github.com/lidge-jun/opencodex/pull/42", - base: { ref: "dev", sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", label: "lidge-jun:dev" }, - head: { ref: "feature", sha: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", label: "contributor:feature" }, + base: { + ref: "dev", + sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + label: "lidge-jun:dev", + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + head: { + ref: "feature", + sha: "3f1c0de0a6a4d0a3f9a1b2c3d4e5f60718293a4b", + label: "contributor:feature", + repo: { name: "opencodex", owner: { login: "contributor" } }, + }, user: { login: "contributor", id: 67890, type: "User" }, labels: [] as unknown[], }; @@ -408,7 +423,30 @@ export async function runEnforcePrTarget( const pr = { ...DEFAULT_PR, ...options.pr, - base: { ...DEFAULT_PR.base, ...(options.pr.base ?? {}) }, + base: { + ...DEFAULT_PR.base, + ...(options.pr.base ?? {}), + repo: { + ...DEFAULT_PR.base.repo, + ...((options.pr.base as { repo?: object } | undefined)?.repo ?? {}), + owner: { + ...DEFAULT_PR.base.repo.owner, + ...((options.pr.base as { repo?: { owner?: object } } | undefined)?.repo?.owner ?? {}), + }, + }, + }, + head: { + ...DEFAULT_PR.head, + ...(options.pr.head ?? {}), + repo: { + ...DEFAULT_PR.head.repo, + ...((options.pr.head as { repo?: object } | undefined)?.repo ?? {}), + owner: { + ...DEFAULT_PR.head.repo.owner, + ...((options.pr.head as { repo?: { owner?: object } } | undefined)?.repo?.owner ?? {}), + }, + }, + }, user: { ...DEFAULT_PR.user, ...(options.pr.user ?? {}) }, }; // Deep-independent from `pr`, so nothing the script does to one can reach the @@ -418,10 +456,34 @@ export async function runEnforcePrTarget( const eventPr = { ...DEFAULT_PR, ...source, - base: { ...DEFAULT_PR.base, ...(source.base ?? {}) }, + base: { + ...DEFAULT_PR.base, + ...(source.base ?? {}), + repo: { + ...DEFAULT_PR.base.repo, + ...((source.base as { repo?: object } | undefined)?.repo ?? {}), + owner: { + ...DEFAULT_PR.base.repo.owner, + ...((source.base as { repo?: { owner?: object } } | undefined)?.repo?.owner ?? {}), + }, + }, + }, + head: { + ...DEFAULT_PR.head, + ...(source.head ?? {}), + repo: { + ...DEFAULT_PR.head.repo, + ...((source.head as { repo?: object } | undefined)?.repo ?? {}), + owner: { + ...DEFAULT_PR.head.repo.owner, + ...((source.head as { repo?: { owner?: object } } | undefined)?.repo?.owner ?? {}), + }, + }, + }, user: { ...DEFAULT_PR.user, ...(source.user ?? {}) }, }; const pages: Comment[][] = options.commentPages ?? [options.comments ?? []]; + const openPulls = options.openPulls ?? []; /** * Record the call, then either reject or return a plausible payload. Every @@ -490,6 +552,11 @@ export async function runEnforcePrTarget( pulls: { get: (args: unknown) => respond("pulls.get", args, pr), update: (args: unknown) => respond("pulls.update", args, { ...pr }), + // Page 1 returns openPulls; later pages empty so paginate does not duplicate. + list: (args: unknown) => { + const page = Number((args as { page?: number })?.page ?? 1); + return respond("pulls.list", args, page === 1 ? openPulls : []); + }, }, issues: { // Honours `page`, so a caller that skips `paginate` sees only page one — diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index def4bdf0a..fe62425b3 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -6,7 +6,9 @@ import { joinCarriedPreviewNotes, matchingPreviewTag, matchingPreviewTags, + parseTakeoverSourcePr, previousReleaseNotesTag, + rewriteTakeoverCredits, selectNewestCarriedPreviewTag, stripCarriedReleaseNotes, stripGenerateNotesCompareLink, @@ -256,3 +258,63 @@ describe("assembleReleaseNotes", () => { expect(stripGenerateNotesCompareLink("x\n**Full Changelog**: y")).toBe("x"); }); }); + +describe("parseTakeoverSourcePr", () => { + test("matches common maintainer-takeover title forms", () => { + expect(parseTakeoverSourcePr("feat(images): Grok image bridge (maintainer takeover of #424)")).toBe(424); + expect(parseTakeoverSourcePr("feat(codex): account pause (takeover #565)")).toBe(565); + expect(parseTakeoverSourcePr("feat x", "Maintainer takeover of #424.")).toBe(424); + expect(parseTakeoverSourcePr("feat x", "no mention")).toBeNull(); + }); +}); + +describe("rewriteTakeoverCredits", () => { + test("credits original author and keeps landing PR link", async () => { + const body = [ + "## What's Changed", + "### New Features", + "* feat(images): Grok image bridge by @Wibias in https://github.com/lidge-jun/opencodex/pull/577", + "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", + ].join("\n"); + + const rewritten = await rewriteTakeoverCredits( + body, + async (pr) => { + if (pr === 577) { + return { + title: "feat(images): Grok image bridge (maintainer takeover of #424)", + body: "Credit: original feature work by @tizerluo on #424.", + authorLogin: "Wibias", + }; + } + if (pr === 100) { + return { title: "feat(other): normal change", body: "", authorLogin: "Alice" }; + } + return null; + }, + async (source) => (source === 424 ? "tizerluo" : null), + ); + + expect(rewritten).toContain( + "* feat(images): Grok image bridge by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + ); + expect(rewritten).toContain( + "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", + ); + }); + + test("leaves line unchanged when original author matches landing author", async () => { + const line = + "* feat x by @Wibias in https://github.com/lidge-jun/opencodex/pull/10"; + const rewritten = await rewriteTakeoverCredits( + line, + async () => ({ + title: "feat x (takeover #9)", + body: "", + authorLogin: "Wibias", + }), + async () => "Wibias", + ); + expect(rewritten).toBe(line); + }); +}); From 9c7622dc3792346da4dab4b47117c134b5c7be8e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:52:13 +0200 Subject: [PATCH 2/3] fix(release): harden takeover credits and document stacked PR exception Grant pull-requests:read for PR author lookups, fail closed on non-404 gh api errors, rewrite carried preview notes as well as the delta, and record the stacked-child wrong-base exemption in AGENTS.md. --- .github/workflows/release.yml | 21 +++++++++++++--- AGENTS.md | 5 ++++ scripts/release-notes.ts | 46 ++++++++++++++++++++++++++++------- tests/ci-workflows.test.ts | 3 +++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d613cc54..d9767d931 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ on: permissions: contents: write # create the matching GitHub Release + version tag after npm publish actions: read # verify the release commit already passed Cross-platform CI + pull-requests: read # credit-takeovers looks up landing/source PR authors via gh api id-token: write # OIDC auth for Trusted Publishing + automatic provenance attestation concurrency: @@ -393,16 +394,28 @@ jobs: pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" # Drop generate-notes' trailing compare link; we re-append it after the commit list. printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" - bun scripts/release-notes.ts credit-takeovers \ - --repo "$GITHUB_REPOSITORY" \ - --in "$delta_file" \ - --out "$delta_file" else # First release on this channel: never call generate-notes without previous_tag_name. # GitHub would baseline the newest repo tag, which may belong to the other channel. echo "::notice::No previous channel tag; skipping generate-notes (commits-only notes)" fi + # Rewrite takeover credits on both carried preview notes and the since-preview + # delta. Carried bodies may predate this helper and would otherwise keep + # landing-author-only attribution on stable releases. + if [ -s "$carried_file" ]; then + bun scripts/release-notes.ts credit-takeovers \ + --repo "$GITHUB_REPOSITORY" \ + --in "$carried_file" \ + --out "$carried_file" + fi + if [ -s "$delta_file" ]; then + bun scripts/release-notes.ts credit-takeovers \ + --repo "$GITHUB_REPOSITORY" \ + --in "$delta_file" \ + --out "$delta_file" + fi + if [ -n "$notes_range_start" ]; then commit_range="${notes_range_start}..${GITHUB_SHA}" git log --pretty=format:'- %s (%h)' "$commit_range" > "$commits_file" diff --git a/AGENTS.md b/AGENTS.md index 00a6c472a..15fb73a6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,11 @@ Bun-native TypeScript on `dev` is the only runtime line. If native code returns, the expectation is an incremental module (for example Rust via N-API) landing on `dev`, not a second full-runtime branch. +Stacked child pull requests that target another **open** PR's head branch are +an intentional review workflow, not an alternate integration line. The +**`enforce-target`** check skips the wrong-base gate for those children; after +the parent lands or closes, retarget the child to `dev`. + Rebase pull requests are welcome. Bringing a stale branch onto the current head is ordinary maintenance — open it as a normal pull request and name the source commits in the description. diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 8590102db..2b1630f1a 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -414,7 +414,10 @@ async function main(argv: string[]): Promise { process.exit(1); } - async function ghJson(path: string): Promise { + async function ghJson( + path: string, + options: { allowNotFound?: boolean } = {}, + ): Promise { const proc = Bun.spawn(["gh", "api", path], { stdout: "pipe", stderr: "pipe", @@ -425,14 +428,22 @@ async function main(argv: string[]): Promise { proc.exited, ]); if (exitCode !== 0) { - console.error(`gh api ${path} failed: ${stderr.trim() || `exit ${exitCode}`}`); - return null; + const detail = stderr.trim() || `exit ${exitCode}`; + const notFound = + /\b404\b/i.test(detail) || + /\bNot Found\b/i.test(detail) || + /\bHTTP\s+404\b/i.test(detail); + if (options.allowNotFound && notFound) { + return null; + } + console.error(`gh api ${path} failed: ${detail}`); + process.exit(1); } try { return JSON.parse(stdout) as unknown; } catch { console.error(`gh api ${path} returned non-JSON`); - return null; + process.exit(1); } } @@ -441,9 +452,15 @@ async function main(argv: string[]): Promise { body, async (prNumber) => { const data = await ghJson(`repos/${owner}/${name}/pulls/${prNumber}`); - if (!data || typeof data !== "object") return null; + if (!data || typeof data !== "object") { + console.error(`Landing PR #${prNumber} lookup returned no object`); + process.exit(1); + } const pr = data as { title?: unknown; body?: unknown; user?: { login?: unknown } }; - if (typeof pr.title !== "string" || typeof pr.user?.login !== "string") return null; + if (typeof pr.title !== "string" || typeof pr.user?.login !== "string") { + console.error(`Landing PR #${prNumber} is missing title or author login`); + process.exit(1); + } return { title: pr.title, body: typeof pr.body === "string" ? pr.body : "", @@ -451,10 +468,21 @@ async function main(argv: string[]): Promise { }; }, async (sourcePrNumber) => { - const data = await ghJson(`repos/${owner}/${name}/pulls/${sourcePrNumber}`); - if (!data || typeof data !== "object") return null; + // Missing source PRs leave the line unchanged; other lookup failures abort. + const data = await ghJson(`repos/${owner}/${name}/pulls/${sourcePrNumber}`, { + allowNotFound: true, + }); + if (data === null) return null; + if (typeof data !== "object") { + console.error(`Source PR #${sourcePrNumber} lookup returned no object`); + process.exit(1); + } const pr = data as { user?: { login?: unknown } }; - return typeof pr.user?.login === "string" ? pr.user.login : null; + if (typeof pr.user?.login !== "string") { + console.error(`Source PR #${sourcePrNumber} is missing author login`); + process.exit(1); + } + return pr.user.login; }, ); await Bun.write(outPath, rewritten.endsWith("\n") ? rewritten : rewritten + "\n"); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 66f22a577..0aedf75ab 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -203,6 +203,7 @@ describe("GitHub Actions hardening", () => { // Least privilege + never cancel a publish mid-flight. expect(workflow).toContain("actions: read"); + expect(workflow).toContain("pull-requests: read"); expect(workflow).toContain("id-token: write"); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain("timeout-minutes: 15"); @@ -284,6 +285,8 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful"); expect(workflow).toContain("bun scripts/release-notes.ts join-carried"); expect(workflow).toContain("bun scripts/release-notes.ts credit-takeovers"); + expect(workflow).toContain('if [ -s "$carried_file" ]; then'); + expect(workflow).toContain('if [ -s "$delta_file" ]; then'); // Preview notes must baseline any prior release (stable or preview), not preview-only. expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"'); expect(workflow).not.toMatch( From fafbaf07e5218c63a9c111cc99e5e90899fd1dce Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:09:31 +0200 Subject: [PATCH 3/3] fix(ci): address CodeRabbit follow-ups on takeover credits and stacked PRs Tighten stacked ancestry regression inputs, skip non-takeover gh lookups, tolerate missing landing PRs, and paginate open-PR fixtures so stacked parents beyond page one stay covered. --- .github/scripts/pr-quality.test.cjs | 4 +- scripts/release-notes.ts | 39 ++++++++++++----- tests/ci-workflows.test.ts | 43 +++++++++++++++++++ tests/helpers/enforce-pr-target-harness.ts | 21 ++++++--- tests/release-notes.test.ts | 50 ++++++++++++++++------ 5 files changed, 125 insertions(+), 32 deletions(-) diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs index fa501162f..010b69cdd 100644 --- a/.github/scripts/pr-quality.test.cjs +++ b/.github/scripts/pr-quality.test.cjs @@ -256,8 +256,8 @@ describe("collectPrQualityFailures", () => { "- Confirm the listener and launcher use the same workspace root", ].join("\n"), behindMain: 0, - behindBase: 0, - aheadMain: 0, + behindBase: 44, + aheadMain: 1, authorPermission: "read", stackedBase: true, }); diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 2b1630f1a..3b4357b42 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -233,6 +233,12 @@ export type TakeoverCreditLookup = { /** * Rewrite generate-notes lines so maintainer-takeover PRs also credit the * original PR creator: `by @Original (takeover by @Landing) in …/pull/P`. + * + * Prefer the takeover marker already present in the notes-line title (cheap, + * no landing lookup). Fall back to `resolveLanding` only when the title + * mentions "takeover" but does not match a known `#N` form, so body text can + * still supply the source. Ordinary non-takeover lines never call either + * resolver. */ export async function rewriteTakeoverCredits( notesBody: string, @@ -249,15 +255,25 @@ export async function rewriteTakeoverCredits( } const landingPr = Number(match.groups.pr); const landingAuthor = match.groups.author!; - const landing = await resolveLanding(landingPr); - if (!landing) { - out.push(line); - continue; - } - const sourcePr = parseTakeoverSourcePr(landing.title, landing.body); + const titleHint = match.groups.prefix + .replace(/^\* /, "") + .replace(/ by @$/, ""); + let sourcePr = parseTakeoverSourcePr(titleHint); if (sourcePr == null) { - out.push(line); - continue; + if (!/\btakeover\b/i.test(titleHint)) { + out.push(line); + continue; + } + const landing = await resolveLanding(landingPr); + if (!landing) { + out.push(line); + continue; + } + sourcePr = parseTakeoverSourcePr(landing.title, landing.body); + if (sourcePr == null) { + out.push(line); + continue; + } } const original = await resolveOriginalAuthor(sourcePr); if (!original || original.toLowerCase() === landingAuthor.toLowerCase()) { @@ -451,7 +467,10 @@ async function main(argv: string[]): Promise { const rewritten = await rewriteTakeoverCredits( body, async (prNumber) => { - const data = await ghJson(`repos/${owner}/${name}/pulls/${prNumber}`); + const data = await ghJson(`repos/${owner}/${name}/pulls/${prNumber}`, { + allowNotFound: true, + }); + if (data === null) return null; if (!data || typeof data !== "object") { console.error(`Landing PR #${prNumber} lookup returned no object`); process.exit(1); @@ -468,7 +487,7 @@ async function main(argv: string[]): Promise { }; }, async (sourcePrNumber) => { - // Missing source PRs leave the line unchanged; other lookup failures abort. + // Missing landing/source PRs leave the line unchanged; other lookup failures abort. const data = await ghJson(`repos/${owner}/${name}/pulls/${sourcePrNumber}`, { allowNotFound: true, }); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 0aedf75ab..f5b6374d6 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -1043,6 +1043,49 @@ describe("GitHub Actions hardening", () => { expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); }); + test("a stacked parent found on open-PR page two is still exempt", async () => { + const parentHead = "feature/parent-page-two"; + const filler = Array.from({ length: 100 }, (_, i) => ({ + number: 1000 + i, + head: { + ref: `feature/filler-${i}`, + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + })); + const result = await run({ + pr: { + number: 42, + base: { + ref: parentHead, + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + title: "Stacked child beyond page one", + draft: false, + }, + openPullPages: [ + filler, + [ + { + number: 41, + head: { + ref: parentHead, + repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + }, + }, + ], + ], + }); + + const listPages = callsTo(result, "pulls.list").map( + (args) => Number((args as { page?: number }).page ?? 1), + ); + expect(listPages).toEqual([1, 2]); + expect(methodsOf(result).filter((m) => m === "pulls.list")).toHaveLength(2); + expect(callsTo(result, "pulls.update")).toEqual([]); + expect(result.logs.join(" ")).toContain("treating as stacked"); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(false); + }); + test("a non-dev base with no open parent PR is still wrong-base", async () => { const result = await run({ pr: { base: { ref: "feature/orphan" }, title: "Orphan stack", draft: false }, diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index 08ee528e5..99197e7ec 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -91,9 +91,12 @@ export type RunOptions = { compareByBasehead?: Record; /** * Open PRs returned by `pulls.list` (page 1). Used for stacked-base detection - * when this PR's base is another PR's head ref. + * when this PR's base is another PR's head ref. Prefer `openPullPages` when + * the test needs pagination beyond the first page. */ openPulls?: unknown[]; + /** Page-keyed open PR fixtures for `pulls.list` (1-based via array index). */ + openPullPages?: unknown[][]; }; /** @@ -483,7 +486,10 @@ export async function runEnforcePrTarget( user: { ...DEFAULT_PR.user, ...(source.user ?? {}) }, }; const pages: Comment[][] = options.commentPages ?? [options.comments ?? []]; - const openPulls = options.openPulls ?? []; + const openPullPages: unknown[][] = + options.openPullPages ?? + (options.openPulls && options.openPulls.length > 0 ? [options.openPulls] : []); + const paginatePageCount = Math.max(pages.length, openPullPages.length, 1); /** * Record the call, then either reject or return a plausible payload. Every @@ -552,10 +558,10 @@ export async function runEnforcePrTarget( pulls: { get: (args: unknown) => respond("pulls.get", args, pr), update: (args: unknown) => respond("pulls.update", args, { ...pr }), - // Page 1 returns openPulls; later pages empty so paginate does not duplicate. + // Page-specific open-PR fixtures; missing pages are empty so paginate ends. list: (args: unknown) => { const page = Number((args as { page?: number })?.page ?? 1); - return respond("pulls.list", args, page === 1 ? openPulls : []); + return respond("pulls.list", args, openPullPages[page - 1] ?? []); }, }, issues: { @@ -600,12 +606,13 @@ export async function runEnforcePrTarget( respond("request", { route, params }); /** * `github.paginate(fn, params)` — walk every page and concatenate, the way - * Octokit does. A one-page fake would make dropping pagination invisible. + * Octokit does. Page count covers both comment and open-PR fixtures so a + * stacked parent on page two is still visible. */ paginate = Object.assign( async (fn: (args: unknown) => Promise<{ data: unknown[] }>, params: unknown) => { const collected: unknown[] = []; - for (let page = 1; page <= pages.length; page += 1) { + for (let page = 1; page <= paginatePageCount; page += 1) { const response = await fn({ ...(params as object), page }); collected.push(...response.data); } @@ -620,7 +627,7 @@ export async function runEnforcePrTarget( */ iterator: (fn: (args: unknown) => Promise<{ data: unknown[] }>, params: unknown) => ({ async *[Symbol.asyncIterator]() { - for (let page = 1; page <= pages.length; page += 1) { + for (let page = 1; page <= paginatePageCount; page += 1) { yield await fn({ ...(params as object), page }); } }, diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index fe62425b3..e9f69c83a 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -273,39 +273,63 @@ describe("rewriteTakeoverCredits", () => { const body = [ "## What's Changed", "### New Features", - "* feat(images): Grok image bridge by @Wibias in https://github.com/lidge-jun/opencodex/pull/577", + "* feat(images): Grok image bridge (maintainer takeover of #424) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577", "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", ].join("\n"); + const landingCalls: number[] = []; const rewritten = await rewriteTakeoverCredits( body, async (pr) => { - if (pr === 577) { - return { - title: "feat(images): Grok image bridge (maintainer takeover of #424)", - body: "Credit: original feature work by @tizerluo on #424.", - authorLogin: "Wibias", - }; - } - if (pr === 100) { - return { title: "feat(other): normal change", body: "", authorLogin: "Alice" }; - } + landingCalls.push(pr); return null; }, async (source) => (source === 424 ? "tizerluo" : null), ); + expect(landingCalls).toEqual([]); expect(rewritten).toContain( - "* feat(images): Grok image bridge by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + "* feat(images): Grok image bridge (maintainer takeover of #424) by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", ); expect(rewritten).toContain( "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", ); }); + test("falls back to landing lookup when title says takeover without #N", async () => { + const line = + "* feat x (takeover) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577"; + const rewritten = await rewriteTakeoverCredits( + line, + async (pr) => { + if (pr !== 577) return null; + return { + title: "feat x (takeover)", + body: "Maintainer takeover of #424.", + authorLogin: "Wibias", + }; + }, + async (source) => (source === 424 ? "tizerluo" : null), + ); + expect(rewritten).toContain( + "* feat x (takeover) by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + ); + }); + + test("leaves line unchanged when landing lookup returns null", async () => { + const line = + "* feat x (takeover) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577"; + const rewritten = await rewriteTakeoverCredits( + line, + async () => null, + async () => "tizerluo", + ); + expect(rewritten).toBe(line); + }); + test("leaves line unchanged when original author matches landing author", async () => { const line = - "* feat x by @Wibias in https://github.com/lidge-jun/opencodex/pull/10"; + "* feat x (takeover #9) by @Wibias in https://github.com/lidge-jun/opencodex/pull/10"; const rewritten = await rewriteTakeoverCredits( line, async () => ({