diff --git a/.github/workflows/node-release-pr.yml b/.github/workflows/node-release-pr.yml new file mode 100644 index 000000000..7b7c035b8 --- /dev/null +++ b/.github/workflows/node-release-pr.yml @@ -0,0 +1,57 @@ +name: node-release-pr + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + dry_run: + description: Preview the release PR without changing GitHub + type: boolean + default: true + +permissions: + contents: read + pull-requests: read + +concurrency: + group: node-release-pr + cancel-in-progress: false + +jobs: + reconcile: + if: github.repository == 'openai/codex-security' && github.ref == 'refs/heads/main' + name: maintain draft release PR + runs-on: ubuntu-latest + env: + RELEASE_PR_DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) || (github.event_name != 'workflow_dispatch' && vars.RELEASE_PR_ENABLED != 'true') }} + + steps: + - name: Checkout main, never the release PR + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: "22.13.0" + + - name: Create repository-scoped release App token + id: app-token + if: env.RELEASE_PR_DRY_RUN == 'false' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Reconcile the rolling release proposal + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + run: node sdk/typescript/scripts/release-pr.mjs diff --git a/RELEASING.md b/RELEASING.md index 64a6d82d2..5bc11fba5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -41,6 +41,122 @@ Use `release` and `test` only for changes that do not affect package users. A maintainer can apply `skip-release-notes` to exclude another internal change. That manual label takes precedence over the title category. +## Version policy before 1.0 + +While the package is on `0.x`, ordinary changes, including features, use a +patch release. Breaking changes use a minor release and reset the patch to +zero. For example, changes after `0.1.23` propose `0.1.24`, or `0.2.0` if any +included change is breaking. The category of a feature remains **Features**; +the category does not imply a minor version bump. + +The release PR updater recognizes breaking changes from a `!` in a +Conventional Commit title, a `BREAKING CHANGE:` or `BREAKING-CHANGE:` footer, +or the `breaking-change` label. `skip-release-notes` affects visibility, not +version impact. All merges and direct commits after the release boundary +count toward the next version, including documentation and internal changes. +Review this policy before enabling automation for `1.x`. + +## Rolling release PRs + +`node-release-pr` runs after pushes to `main` and can be dispatched manually. +It defaults to a read-only preview. It does not merge, tag, publish, or change +the existing publication gates. + +The updater keeps one draft proposal on `release/next-` and +recomputes its version from all changes since the current package version +first reached `main`. Squash-merge release PRs, as required by this +repository's enabled merge method, so the whole proposal lands as one +release boundary commit. A later breaking change changes the version on the +same PR. Each update incorporates the latest `main` and appends a commit; +the updater never force-pushes. A concurrent commit causes it to reread and +retry. It requests Codex review when the proposal files change. Updates that +only incorporate `main` keep CI current without repeating the same proposal +review. New suggestions for human-owned notes still appear in a comment. +Before marking the proposal ready, check CI and request a final Codex review +if the last review targets an older head. + +If a run reports that GitHub has not exposed the updated PR head, manually +rerun the updater with **dry_run** disabled after the PR catches up. This +allows any deferred review request or note suggestions to be posted. Verify +review on the current head before marking the proposal ready. + +When the release version merges, the next draft can open immediately, even +while publication is still running. Until another change reaches `main`, +that draft leaves the package version unchanged. Do not mark an empty draft +ready or merge it. Publication of the previous version still has to complete +and pass the verification steps below. + +The updater leaves another open `release:` PR targeting `main`, including a +manually prepared release, untouched and does not open a duplicate. Finish +or close that PR before enabling the new flow. Closing an automated proposal +pauses its cycle; reopen it to resume. Retargeting it away from `main` also +pauses updates; restore its `main` base before resuming. Marking the proposal +ready pauses updates, preserving the reviewed version and notes. To resume, +convert it back to a draft and rerun the updater. Do this before merging if +`main` has advanced, then review the updated proposal. The updater rechecks +these conditions before advancing the branch. Changes to other files on the +release branch, or to package fields other than the version, also pause the +updater so those edits cannot be lost. These intentional pauses return +`action: "held"` and leave the workflow successful. Preserve or merge the +additional changes, then rerun the updater to resume. + +### Editing the draft notes + +The committed `.github/release-notes.md` is authoritative. The updater +drafts highlights from merged titles and lists marked breaking changes for +migration review; it does not infer migration instructions from source code. +Review and refine these suggestions before releasing. + +- Edit the highlights or upgrade notes on the release PR branch. Keep the + surrounding `release-section` comments if you want to retain section + boundaries. The bot refreshes a section only while it matches the last + generated draft. An edit or deletion makes that section human-owned, and + later updates preserve it. Custom prose outside the sections is preserved. +- Later suggestions appear in a new bot comment on the same PR. They do not + replace human-owned notes. The bot updates the version header and PR title + but never rewrites the PR description. Review version-specific links in + human-owned prose when the proposed version changes. +- `.github/release-pr-state.json` records the cycle and section ownership. + To explicitly regenerate a section, add `"reset": true` to that section's + state entry. The next run consumes the reset and resumes automatic updates. + Restore any damaged section markers before resetting. Do not delete the + state file to reset ownership. A section with missing ownership metadata + is preserved until explicitly reset; missing state does not authorize + replacing manual notes. +- Deleting the notes file is preserved too. Restore reviewed notes, or + explicitly reset the sections, before merging; publication requires the + versioned notes file. + +### Preview and enable + +With an authenticated `gh` CLI, a local checkout can preview the plan with: + +```bash +RELEASE_PR_DRY_RUN=true node sdk/typescript/scripts/release-pr.mjs +``` + +The preview may fetch missing Git objects locally, but performs no GitHub +writes. Its JSON output includes the proposed files and any reason the +updater would pause. After the workflow is on `main`, use its **Run workflow** +form with **dry_run** enabled to test the hosted read-only path. + +To test writes, configure a GitHub App installed on this repository +with **Contents: write** and **Pull requests: write**, set the +`RELEASE_APP_CLIENT_ID` repository variable and `RELEASE_APP_PRIVATE_KEY` +secret, then manually dispatch the workflow with **dry_run** disabled. +This permits a single write run while automatic updates remain disabled. +Review the resulting draft, its hosted CI, and the Codex review request. +The workflow requests an App token scoped to this repository so CI on the +bot's PR does not need the approval required for PR events created by +`GITHUB_TOKEN`. See [GitHub's token documentation](https://docs.github.com/en/actions/concepts/security/github_token). + +After the manual write run is verified, set the `RELEASE_PR_ENABLED` +repository variable to `true` to allow updates after pushes to `main`. +Remove it or set it to `false` to return push-triggered runs to previews. +Manual runs always honor their **dry_run** input, which defaults to a preview; +disabling automatic updates does not prevent an explicit manual write run. +Generated PRs leave the disclosure attestations unchecked for maintainer review. + ## Prepare a release 1. Choose the next stable version and update `sdk/typescript/package.json`. diff --git a/sdk/typescript/scripts/release-automation.mjs b/sdk/typescript/scripts/release-automation.mjs index 4eb07f40d..3c4e0b707 100644 --- a/sdk/typescript/scripts/release-automation.mjs +++ b/sdk/typescript/scripts/release-automation.mjs @@ -25,17 +25,18 @@ function stableReleaseTagVersion(tag) { return version; } +export function assertStableVersion(version) { + if (typeof version !== "string" || !stableVersion.test(version)) { + throw new Error("Release package must have a stable X.Y.Z version."); + } + return version; +} + export function releaseVersion(packageJson) { if (packageJson?.name !== packageName) { throw new Error("Release package must be @openai/codex-security."); } - if ( - typeof packageJson.version !== "string" || - !stableVersion.test(packageJson.version) - ) { - throw new Error("Release package must have a stable X.Y.Z version."); - } - return packageJson.version; + return assertStableVersion(packageJson.version); } function hasReviewedText(value) { diff --git a/sdk/typescript/scripts/release-pr.mjs b/sdk/typescript/scripts/release-pr.mjs new file mode 100644 index 000000000..0f67e439c --- /dev/null +++ b/sdk/typescript/scripts/release-pr.mjs @@ -0,0 +1,691 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import { assertStableVersion, releaseVersion } from "./release-automation.mjs"; + +export const packagePath = "sdk/typescript/package.json"; +export const notesPath = ".github/release-notes.md"; +export const statePath = ".github/release-pr-state.json"; +const templatePath = ".github/PULL_REQUEST_TEMPLATE.md"; +const sectionIds = ["highlights", "upgrades"]; +const releaseBranchPrefix = "release/next-"; +const conventionalTitle = /^([a-z][a-z0-9-]*)(?:\([^)]*\))?(!)?: (.+)$/u; + +function isReleasePull(pull) { + return ( + conventionalTitle.exec(pull.title)?.[1] === "release" || + pull.head.ref.startsWith(releaseBranchPrefix) + ); +} + +export function isBreakingChange(change) { + return ( + conventionalTitle.exec(change.title)?.[2] === "!" || + /^(?:BREAKING CHANGE|BREAKING-CHANGE):\s+\S/mu.test(change.body ?? "") || + (change.labels ?? []).includes("breaking-change") + ); +} + +export function nextReleaseVersion(version, changes) { + assertStableVersion(version); + const [major, minor, patch] = version.split(".").map(BigInt); + if (major !== 0n) { + throw new Error( + "Review the release PR version policy before enabling it for 1.x.", + ); + } + if (changes.length === 0) return version; + return changes.some((change) => change.breaking ?? isBreakingChange(change)) + ? `0.${minor + 1n}.0` + : `0.${minor}.${patch + 1n}`; +} + +function markdownText(value) { + return value.replace(/[\r\n]+/gu, " ").replace(/[\\`*_[\]<>]/gu, "\\$&"); +} + +function changeLine(change) { + const description = conventionalTitle.exec(change.title)?.[3] ?? change.title; + const reference = change.number + ? `#${change.number}` + : change.sha.slice(0, 7); + return `- ${markdownText(description)} ([${reference}](${change.url}))`; +} + +function visibleChanges(changes) { + return changes.filter((change) => { + const type = conventionalTitle.exec(change.title)?.[1]; + return ( + !(change.labels ?? []).includes("skip-release-notes") && + (change.breaking || !["release", "test"].includes(type)) + ); + }); +} + +export function generateNoteSections(changes) { + const visible = visibleChanges(changes); + const breaking = visible.filter((change) => change.breaking); + return { + highlights: [ + "## Highlights", + "", + visible.length > 0 + ? visible.map(changeLine).join("\n") + : "No release highlights have been drafted yet.", + ].join("\n"), + upgrades: [ + "## Upgrade notes", + "", + breaking.length > 0 + ? [ + "Review migration steps for these breaking changes:", + "", + ...breaking.map(changeLine), + ].join("\n") + : "Review compatibility and document any required migration steps before releasing.", + ].join("\n"), + }; +} + +function sectionBlock(id, content) { + return `\n${content}\n`; +} + +function findSection(notes, id) { + const markers = [ + ...notes.matchAll( + new RegExp(`^\\r?$`, "gm"), + ), + ]; + if ( + markers.length !== 2 || + markers[0][1] !== "start" || + markers[1][1] !== "end" + ) + return null; + const start = markers[0].index; + const end = markers[1].index + markers[1][0].length; + return { start, end, text: notes.slice(start, end) }; +} + +function hash(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function updateReleaseNotes( + version, + generated, + previousNotes, + previousSections, +) { + const header = ``; + const sections = {}; + if (previousSections === undefined) { + const blocks = sectionIds.map((id) => { + const block = sectionBlock(id, generated[id]); + sections[id] = { generatedHash: hash(block), humanOwned: false }; + return block; + }); + return { notes: `${header}\n\n${blocks.join("\n\n")}\n`, sections }; + } + + let notes = previousNotes; + if (notes !== null) { + notes = /^/u.test(notes) + ? notes.replace(/^/u, header) + : `${header}\n\n${notes}`; + } + for (const id of sectionIds) { + const previous = previousSections[id]; + const block = notes === null ? null : findSection(notes, id); + const humanOwned = + previous?.reset !== true && + (previous?.humanOwned !== false || + block === null || + hash(block.text) !== previous.generatedHash); + if (humanOwned) { + sections[id] = { + generatedHash: previous?.generatedHash ?? null, + humanOwned: true, + }; + continue; + } + const next = sectionBlock(id, generated[id]); + if (block === null) { + notes = `${notes ?? `${header}\n`}\n${next}\n`; + } else { + notes = notes.slice(0, block.start) + next + notes.slice(block.end); + } + sections[id] = { generatedHash: hash(next), humanOwned: false }; + } + return { notes, sections }; +} + +function updatePackageVersion(packageText, version) { + const expected = JSON.parse(packageText); + expected.version = version; + // Preserve formatting while selecting only the top-level version field. + for (const match of packageText.matchAll( + /("version"\s*:\s*)("(?:\\.|[^"\\])*")/gu, + )) { + const start = match.index + match[1].length; + const updated = + packageText.slice(0, start) + + JSON.stringify(version) + + packageText.slice(start + match[2].length); + if (isDeepStrictEqual(JSON.parse(updated), expected)) return updated; + } + throw new Error("Unable to update only the top-level package version."); +} + +export function createReleasePlan(history, previous = null) { + const { baseVersion, baseCommit, mainSha, packageText, changes } = history; + if ( + previous && + (previous.state.baseVersion !== baseVersion || + previous.state.baseCommit !== baseCommit) + ) { + throw new Error( + "The release branch belongs to a different release cycle; review it before continuing.", + ); + } + const version = nextReleaseVersion(baseVersion, changes); + const generated = generateNoteSections(changes); + const { notes, sections } = updateReleaseNotes( + version, + generated, + previous?.notes ?? null, + previous?.state.sections, + ); + const state = { baseVersion, baseCommit, sections }; + return { + baseVersion, + baseCommit, + mainSha, + version, + branch: `${releaseBranchPrefix}${baseVersion}`, + title: + changes.length > 0 + ? `release: bump Codex Security to ${version}` + : "release: prepare the next Codex Security release", + changes, + generated, + humanOwned: sectionIds.filter((id) => sections[id].humanOwned), + files: { + [packagePath]: updatePackageVersion(packageText, version), + [notesPath]: notes, + [statePath]: `${JSON.stringify(state, null, 2)}\n`, + }, + }; +} + +export function createGitRepository(directory) { + const git = (...args) => + execFileSync("git", args, { + cwd: directory, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + return { + git, + ensureCommit(sha) { + try { + git("cat-file", "-e", `${sha}^{commit}`); + } catch { + git("fetch", "--no-tags", "origin", sha); + } + }, + readFile(sha, path) { + if (git("ls-tree", sha, "--", path).trim() === "") return null; + return git("show", `${sha}:${path}`); + }, + }; +} + +export async function readReleaseHistory(repo, mainSha, github) { + repo.ensureCommit(mainSha); + const packageText = repo.readFile(mainSha, packagePath); + const baseVersion = releaseVersion(JSON.parse(packageText)); + let baseCommit = mainSha; + for (const sha of repo + .git("log", "--first-parent", "--format=%H", mainSha, "--", packagePath) + .trim() + .split("\n")) { + const packageJson = JSON.parse(repo.readFile(sha, packagePath)); + if (packageJson.version !== baseVersion) break; + baseCommit = sha; + } + + const commits = repo + .git("rev-list", "--first-parent", "--reverse", `${baseCommit}..${mainSha}`) + .trim() + .split("\n") + .filter(Boolean); + const changes = new Map(); + for (const sha of commits) { + const body = repo.git("show", "-s", "--format=%B", sha); + const pulls = await github.list(`commits/${sha}/pulls?per_page=100`); + const pull = pulls.find( + (candidate) => candidate.merged_at && candidate.base.ref === "main", + ); + const change = { + sha, + number: pull?.number ?? null, + title: pull?.title ?? body.split("\n")[0], + labels: (pull?.labels ?? []).map((label) => label.name), + url: pull?.html_url ?? `${github.repositoryUrl}/commit/${sha}`, + }; + change.breaking = isBreakingChange({ + ...change, + body: [pull?.body ?? "", body].join("\n"), + }); + const key = pull ? `pr-${pull.number}` : sha; + if (changes.has(key)) { + changes.get(key).breaking ||= change.breaking; + } else { + changes.set(key, change); + } + } + return { + baseVersion, + baseCommit, + mainSha, + packageText, + changes: [...changes.values()], + }; +} + +function readReleaseBranch(repo, mainSha, headSha) { + repo.ensureCommit(headSha); + const mergeBase = repo.git("merge-base", mainSha, headSha).trim(); + const paths = repo + .git("diff", "--no-renames", "--name-only", "-z", mergeBase, headSha) + .split("\0") + .filter(Boolean); + if ( + paths.some((path) => ![packagePath, notesPath, statePath].includes(path)) + ) { + return { + holdReason: + "The release branch has other file edits. Preserve or merge them before running the updater.", + }; + } + const originalPackage = JSON.parse(repo.readFile(mergeBase, packagePath)); + const branchPackage = JSON.parse(repo.readFile(headSha, packagePath)); + delete originalPackage.version; + delete branchPackage.version; + if (!isDeepStrictEqual(originalPackage, branchPackage)) { + return { + holdReason: + "The release branch has package edits beyond its version. Preserve them before running the updater.", + }; + } + const state = JSON.parse(repo.readFile(headSha, statePath)); + if (!state?.sections) { + throw new Error( + "The existing release branch has no note ownership state; it must be reviewed manually.", + ); + } + return { state, notes: repo.readFile(headSha, notesPath), mergeBase }; +} + +function initialPullBody(template) { + const sections = { + Summary: + "Keep a draft release proposal current with changes merged into main.", + Changes: + "Update the package version and draft release notes. The version header and PR title are maintained by automation. Edit the marked note sections in `.github/release-notes.md`; edited or deleted sections become human-owned. New suggestions appear in subsequent bot comments. The PR description is never regenerated.", + Testing: + "The updater does not run package tests. Check required CI and Codex review on the current head before marking this draft ready. Request a final Codex review if the last review targets an older head. Record any additional checks here.", + "Risk and rollout": + "This PR does not merge itself. Merging a nonempty proposal starts the existing CI and protected release process. An empty proposal leaves the package version unchanged. Review migration details and complete the public disclosure review before merging.", + }; + let body = template; + for (const [heading, content] of Object.entries(sections)) { + body = body.replace( + new RegExp(`(## ${heading}\\n)[\\s\\S]*?(?=\\n## |$)`, "u"), + `$1\n${content}\n`, + ); + } + return body; +} + +function reviewComment( + plan, + headSha, + proposalMarker, + suggestionsMarker, + requestReview, +) { + const marker = ``; + return [ + marker, + proposalMarker, + suggestionsMarker, + `Release proposal updated through main commit \`${plan.mainSha}\`.`, + plan.humanOwned.length > 0 + ? `Preserved human-owned sections: ${plan.humanOwned.join(", ")}. The suggestions below have not replaced them.` + : "The note sections still match the generated draft and were refreshed.", + "These suggestions use merged titles, not a review of migration requirements. The committed notes remain authoritative.", + ...sectionIds.map((id) => plan.generated[id]), + ...(requestReview ? [`@codex review the current head ${headSha}.`] : []), + ].join("\n\n"); +} + +async function branchHead(github, branch) { + try { + return (await github.request("GET", `git/ref/heads/${branch}`)).object.sha; + } catch (error) { + if (error.status === 404) return null; + throw error; + } +} + +function pullHoldReason(pull, branch) { + if ( + pull?.state !== "open" || + pull.head.ref !== branch || + pull.base.ref !== "main" + ) { + return "The release PR was closed or retargeted during the update. Review it before continuing."; + } + if (!pull.draft) { + return `Release PR #${pull.number} is ready for review. Convert it back to a draft to resume automatic updates.`; + } + return null; +} + +function releaseHoldReason(openPulls, pullNumber, branch, repository) { + const retargeted = openPulls.find( + (candidate) => + candidate.head.ref === branch && + candidate.head.repo?.full_name === repository && + candidate.base.ref !== "main", + ); + if (retargeted) + return `Release PR #${retargeted.number} was retargeted. Restore its main base before resuming updates.`; + const other = openPulls.find( + (candidate) => + candidate.number !== pullNumber && + candidate.base.ref === "main" && + isReleasePull(candidate), + ); + if (other) + return `Another release PR is open: #${other.number}. It has not been changed.`; + const current = openPulls.find( + (candidate) => candidate.number === pullNumber, + ); + return pullNumber === undefined ? null : pullHoldReason(current, branch); +} + +async function ensurePullRequest( + github, + plan, + pull, + template, + headSha, + currentPulls, +) { + const holdReason = releaseHoldReason( + currentPulls ?? (await github.list("pulls?state=open&per_page=100")), + pull?.number, + plan.branch, + github.repository, + ); + if (holdReason) + return { action: "held", reason: holdReason, reviewRequested: false }; + if (pull) { + pull = await github.request("GET", `pulls/${pull.number}`); + const reason = pullHoldReason(pull, plan.branch); + if (reason) + return { + action: "held", + reason, + pull: pull.number, + reviewRequested: false, + }; + if (pull.title !== plan.title) { + await github.request("PATCH", `pulls/${pull.number}`, { + title: plan.title, + }); + } + } else { + pull = await github.request("POST", "pulls", { + title: plan.title, + head: plan.branch, + base: "main", + draft: true, + body: initialPullBody(template), + }); + } + const current = await github.request("GET", `pulls/${pull.number}`); + const reason = pullHoldReason(current, plan.branch); + if (reason) + return { + action: "held", + reason, + pull: current.number, + reviewRequested: false, + }; + if (current.head.sha !== headSha) { + return { + pull: current.number, + reviewRequested: false, + reason: + "GitHub has not exposed the updated PR head yet. Rerun the updater before final review.", + }; + } + const marker = ``; + const proposalMarker = ``; + const suggestionsMarker = ``; + const comments = await github.list( + `issues/${current.number}/comments?per_page=100`, + ); + const previous = comments.findLast((comment) => + comment.body?.startsWith("\n\nPreviously reviewed release.\n", + ".github/PULL_REQUEST_TEMPLATE.md": template, + "sdk/typescript/src/example.ts": "export const initial = true;\n", + }, + "release: bump to 0.1.23", + ); + } + + head(branch = "main") { + try { + return this.git("rev-parse", "--verify", `refs/heads/${branch}`).trim(); + } catch { + return null; + } + } + + tree(base: string | null, files: Record) { + this.git("read-tree", base ?? "--empty"); + for (const [path, content] of Object.entries(files)) { + if (content === null) { + this.git("update-index", "--force-remove", "--", path); + } else { + const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: this.directory, + input: content, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + this.git("update-index", "--add", "--cacheinfo", "100644", blob, path); + } + } + return this.git("write-tree").trim(); + } + + commit( + branch: string, + files: Record, + message: string, + from = this.head(branch), + ) { + const tree = this.tree(from, files); + const sha = this.git( + "commit-tree", + tree, + ...(from ? ["-p", from] : []), + "-m", + message, + ).trim(); + this.git("update-ref", `refs/heads/${branch}`, sha); + return sha; + } + + merge( + title: string, + files: Record = {}, + body = "", + labels: string[] = [], + ) { + const sha = this.commit("main", files, `${title}\n\n${body}`); + const pull = this.github.addPull( + `change/${this.github.pulls.length + 1}`, + title, + sha, + ); + pull.state = "closed"; + pull.merged_at = "2026-01-01T00:00:00Z"; + pull.body = body; + pull.labels = labels.map((name) => ({ name })); + this.github.commitPulls.set(sha, [pull]); + return sha; + } + + run(dryRun = false) { + return reconcileReleasePullRequest({ + repo: this.repo, + github: this.github, + dryRun, + }); + } +} + +class FakeGitHub { + readonly repository = "example/release-fixture"; + readonly owner = "example"; + readonly repositoryUrl = "https://github.com/example/release-fixture"; + readonly pulls: Pull[] = []; + readonly commitPulls = new Map(); + readonly comments = new Map(); + readonly writes: { method: string; path: string; body: Body }[] = []; + beforeRefWrite: (() => void) | undefined; + afterCommit: (() => void) | undefined; + failPullCreation = false; + + constructor(readonly fixture: Fixture) {} + + addPull(branch: string, title: string, sha: string): Pull { + const number = this.pulls.length + 1; + const pull: Pull = { + number, + title, + body: "", + state: "open", + draft: false, + merged_at: null, + head: { ref: branch, sha, repo: { full_name: this.repository } }, + base: { ref: "main" }, + html_url: `${this.repositoryUrl}/pull/${number}`, + labels: [], + }; + this.pulls.push(pull); + return pull; + } + + async list(path: string) { + if (path.startsWith("commits/")) + return this.commitPulls.get(path.split("/")[1]!) ?? []; + if (path.startsWith("pulls?")) { + const query = new URLSearchParams(path.split("?")[1]); + const head = query.get("head")?.slice(`${this.owner}:`.length); + const base = query.get("base"); + return this.pulls.filter( + (pull) => + pull.state === query.get("state") && + (!head || pull.head.ref === head) && + (!base || pull.base.ref === base), + ); + } + if (path.startsWith("issues/")) + return this.comments.get(Number(path.split("/")[1])) ?? []; + throw new Error(`Unexpected list: ${path}`); + } + + async request( + method: string, + path: string, + body: Body = {}, + ): Promise { + if (method !== "GET") this.writes.push({ method, path, body }); + if (method === "GET" && path.startsWith("git/ref/heads/")) { + const sha = this.fixture.head(path.slice("git/ref/heads/".length)); + if (!sha) throw apiError(404); + return { object: { sha } }; + } + if (path === "git/trees") { + const entries = body["tree"] as { + path: string; + content?: string; + sha?: null; + }[]; + return { + sha: this.fixture.tree( + body["base_tree"] as string, + Object.fromEntries( + entries.map((entry) => [entry.path, entry.content ?? null]), + ), + ), + }; + } + if (path === "git/commits") { + const parents = body["parents"] as string[]; + const sha = this.fixture + .git( + "commit-tree", + body["tree"] as string, + ...parents.flatMap((parent) => ["-p", parent]), + "-m", + body["message"] as string, + ) + .trim(); + const callback = this.afterCommit; + this.afterCommit = undefined; + callback?.(); + return { sha }; + } + if (path === "git/refs" || path.startsWith("git/refs/heads/")) { + const callback = this.beforeRefWrite; + this.beforeRefWrite = undefined; + callback?.(); + const branch = + path === "git/refs" + ? (body["ref"] as string).slice("refs/heads/".length) + : path.slice("git/refs/heads/".length); + const current = this.fixture.head(branch); + const next = body["sha"] as string; + if (method === "POST" && current) throw apiError(422); + if (current) { + expect(body["force"]).toBe(false); + try { + this.fixture.git("merge-base", "--is-ancestor", current, next); + } catch { + throw apiError(422); + } + } + this.fixture.git( + "update-ref", + `refs/heads/${branch}`, + next, + current ?? "0".repeat(40), + ); + return { object: { sha: next } }; + } + if (method === "POST" && path === "pulls") { + if (this.failPullCreation) throw apiError(500); + const branch = body["head"] as string; + const pull = this.addPull( + branch, + body["title"] as string, + this.fixture.head(branch)!, + ); + pull.body = body["body"] as string; + pull.draft = body["draft"] as boolean; + return pull; + } + if (path.startsWith("pulls/")) { + const pull = this.pulls.find( + (candidate) => candidate.number === Number(path.split("/")[1]), + )!; + if (method === "PATCH") Object.assign(pull, body); + return { + ...pull, + head: { + ...pull.head, + sha: this.fixture.head(pull.head.ref) ?? pull.head.sha, + }, + }; + } + if (method === "POST" && path.startsWith("issues/")) { + const number = Number(path.split("/")[1]); + const comments = this.comments.get(number) ?? []; + comments.push({ body: body["body"] as string }); + this.comments.set(number, comments); + return {}; + } + throw new Error(`Unexpected request: ${method} ${path}`); + } +} + +describe("GitHub request transport", () => { + test("creates and updates a draft through serialized requests, including a concurrent commit conflict", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + let conflicts = 0; + const github = createGitHubClient( + fixture.github.repository, + "synthetic-release-token", + async (url, options) => { + const endpoint = new URL(url); + expect(endpoint.origin).toBe("https://api.github.com"); + expect(new Headers(options.headers).get("Authorization")).toBe( + "Bearer synthetic-release-token", + ); + const path = + endpoint.pathname.slice( + `/repos/${fixture.github.repository}/`.length, + ) + endpoint.search; + const method = options.method!; + try { + const value = + method === "GET" && + (path.startsWith("commits/") || + path.startsWith("pulls?") || + path.startsWith("issues/")) + ? await fixture.github.list(path) + : await fixture.github.request( + method, + path, + options.body ? JSON.parse(String(options.body)) : undefined, + ); + return Response.json(value, { + status: method === "POST" ? 201 : 200, + }); + } catch (error) { + const status = (error as { status?: number }).status; + if (status === undefined) throw error; + if (status === 422) conflicts++; + return Response.json( + { message: "Synthetic GitHub failure" }, + { status }, + ); + } + }, + ); + const run = (dryRun = false) => + reconcileReleasePullRequest({ repo: fixture.repo, github, dryRun }); + expect((await run(true)).action).toBe("would-create"); + expect(fixture.github.writes).toHaveLength(0); + const first = await run(); + fixture.merge("fix: later fix"); + fixture.github.beforeRefWrite = () => { + const notes = fixture.repo.readFile( + fixture.head(first.plan.branch)!, + notesPath, + )!; + fixture.commit( + first.plan.branch, + { + [notesPath]: notes.replace( + "initial feature", + "Human-reviewed release note", + ), + }, + "docs: review release notes", + ); + }; + const updated = await run(); + expect(conflicts).toBe(1); + expect(updated.pull).toBe(first.pull); + expect(updated.plan.humanOwned).toEqual(["highlights"]); + expect(updated.plan.files[notesPath]).toContain( + "Human-reviewed release note", + ); + expect(updated.reviewRequested).toBe(true); + fixture.git( + "merge-base", + "--is-ancestor", + first.headSha!, + updated.headSha!, + ); + }); + + test.each([30, 100])( + "follows %i-item pages without losing the original query", + async (pageSize) => { + const pages: number[] = []; + const github = createGitHubClient( + "example/release-fixture", + "synthetic-release-token", + async (url, options) => { + const endpoint = new URL(url); + expect(options.method).toBe("GET"); + expect(endpoint.searchParams.get("state")).toBe("open"); + expect(endpoint.searchParams.get("per_page")).toBe(String(pageSize)); + const page = Number(endpoint.searchParams.get("page")); + pages.push(page); + endpoint.searchParams.set("page", "2"); + return Response.json( + page === 1 + ? Array.from({ length: pageSize }, (_, index) => ({ + number: index + 1, + })) + : [{ number: pageSize + 1 }], + { + headers: + page === 1 ? { link: `<${endpoint.href}>; rel="next"` } : {}, + }, + ); + }, + ); + const pulls = await github.list(`pulls?state=open&per_page=${pageSize}`); + expect(pulls).toHaveLength(pageSize + 1); + expect(pulls.at(-1)).toEqual({ number: pageSize + 1 }); + expect(pages).toEqual([1, 2]); + }, + ); +}); + +describe("release workflow controls", () => { + test.each([ + { event: "push", enabled: undefined, dryRun: undefined, expected: true }, + { event: "push", enabled: "false", dryRun: undefined, expected: true }, + { event: "push", enabled: "true", dryRun: undefined, expected: false }, + { + event: "workflow_dispatch", + enabled: undefined, + dryRun: true, + expected: true, + }, + { + event: "workflow_dispatch", + enabled: undefined, + dryRun: false, + expected: false, + }, + { + event: "workflow_dispatch", + enabled: "false", + dryRun: false, + expected: false, + }, + { + event: "workflow_dispatch", + enabled: "true", + dryRun: true, + expected: true, + }, + { + event: "workflow_dispatch", + enabled: "true", + dryRun: false, + expected: false, + }, + ])( + "selects preview mode for $event with enabled=$enabled and dry_run=$dryRun", + ({ event, enabled, dryRun, expected }) => { + const expression = workflow.match( + /RELEASE_PR_DRY_RUN: \$\{\{ (.+) \}\}/u, + )![1]!; + const evaluate = new Function( + "github", + "inputs", + "vars", + `return (${expression});`, + ); + expect( + evaluate( + { event_name: event }, + { dry_run: dryRun }, + { RELEASE_PR_ENABLED: enabled }, + ), + ).toBe(expected); + }, + ); +}); + +describe("pre-1.0 release policy", () => { + test.each([ + "fix: repair output", + "feat(sdk): add an option", + "chore(deps): update a dependency", + "docs: clarify setup", + "test: add coverage", + ])("uses a patch for %s", (title) => { + expect(nextReleaseVersion("0.1.23", [change(title)])).toBe("0.1.24"); + }); + + test.each([ + change("feat(sdk)!: remove an option"), + change("fix: correct behavior", 1, { + body: "Details.\n\nBREAKING CHANGE: old options were removed", + }), + change("chore: update behavior", 1, { + body: "BREAKING-CHANGE: configuration changed", + }), + change("fix: correct behavior", 1, { + labels: ["breaking-change", "skip-release-notes"], + }), + ])("uses a minor for a breaking change: $title", (breaking) => { + expect( + nextReleaseVersion("0.1.23", [change("fix: first fix"), breaking]), + ).toBe("0.2.0"); + }); + + test("recomputes the whole cycle, including hidden changes, without incrementing on reruns", () => { + const changes = [ + change("feat: an addition"), + change("fix!: incompatible behavior", 2, { + labels: ["skip-release-notes"], + }), + ]; + const first = createReleasePlan(history(changes)); + const next = updatePlan(first, [...changes, change("fix: another fix", 3)]); + expect(first.version).toBe("0.2.0"); + expect(next.version).toBe("0.2.0"); + expect(next.branch).toBe(first.branch); + expect(next.files[notesPath]).not.toContain("incompatible behavior"); + }); + + test("leaves an empty cycle at the merged version and does not invent a 1.x policy", () => { + expect(nextReleaseVersion("0.1.23", [])).toBe("0.1.23"); + expect(() => + nextReleaseVersion("1.0.0", [change("feat: add behavior")]), + ).toThrow("policy"); + }); + + test.each([ + { format: "compact", indent: undefined }, + { format: "indented", indent: 2 }, + ])( + "updates only the top-level package version with $format formatting", + ({ indent }) => { + const metadata = { + config: { version: 'legacy "1.2.3"' }, + name: "@openai/codex-security", + version: "0.1.23", + dependencies: { example: "1.0.0" }, + }; + const text = `${JSON.stringify(metadata, null, indent)}\n`; + const plan = createReleasePlan({ + ...history([change("fix: repair output")]), + packageText: text, + }); + expect(JSON.parse(plan.files[packagePath]!)).toEqual({ + ...metadata, + version: "0.1.24", + }); + expect(plan.files[packagePath]).toBe( + text.replace('"0.1.23"', '"0.1.24"'), + ); + const empty = createReleasePlan({ ...history([]), packageText: text }); + expect(empty.files[packagePath]).toBe(text); + }, + ); +}); + +describe("human note ownership", () => { + test("keeps edited sections sticky while updating other sections and the header", () => { + const first = createReleasePlan(history([change("feat: initial feature")])); + const edited = first.files[notesPath]!.replace( + "initial feature", + "A reviewed explanation with [documentation](https://example.com/v0.1.24).", + ); + const changes = [ + change("feat: initial feature"), + change("fix!: new incompatible change", 2), + ]; + const second = updatePlan(first, changes, edited); + const third = updatePlan(second, [ + ...changes, + change("feat: later feature", 3), + ]); + expect(third.version).toBe("0.2.0"); + expect(third.files[notesPath]).toContain(""); + expect(third.files[notesPath]).toContain("A reviewed explanation"); + expect(third.files[notesPath]).toContain("https://example.com/v0.1.24"); + expect(third.files[notesPath]).not.toContain("later feature"); + expect(third.files[notesPath]).toContain("new incompatible change"); + expect(third.humanOwned).toEqual(["highlights"]); + expect(third.generated.highlights).toContain("later feature"); + const originalBlock = first.files[notesPath]!.match( + /[\s\S]*?/u, + )![0]; + const restored = third.files[notesPath]!.replace( + /[\s\S]*?/u, + originalBlock, + ); + const stillOwned = updatePlan(third, third.changes, restored); + expect(stillOwned.humanOwned).toEqual(["highlights"]); + expect(stillOwned.files[notesPath]).not.toContain("later feature"); + }); + + test("preserves deletion, custom prose, and an explicit reset", () => { + const first = createReleasePlan(history([change("feat: initial feature")])); + const deleted = first.files[notesPath]!.replace( + /[\s\S]*?/u, + "A custom migration explanation.", + ); + const second = updatePlan( + first, + [change("feat!: new interface", 2)], + deleted, + ); + const third = updatePlan(second, [ + change("feat!: new interface", 2), + change("fix: later fix", 3), + ]); + expect(third.files[notesPath]).toContain("A custom migration explanation."); + expect(third.files[notesPath]).not.toContain( + "release-section: upgrades:start", + ); + expect(third.humanOwned).toEqual(["upgrades"]); + const state = JSON.parse(third.files[statePath]!); + state.sections.upgrades.reset = true; + const reset = createReleasePlan(history(third.changes), { + notes: third.files[notesPath]!, + state, + }); + expect(reset.files[notesPath]).toContain("A custom migration explanation."); + expect(reset.files[notesPath]).toContain("release-section: upgrades:start"); + expect(reset.humanOwned).toEqual([]); + }); + + test("does not recreate a deleted notes file or overwrite a section with edited markers", () => { + const first = createReleasePlan(history([change("fix: initial fix")])); + const deleted = updatePlan(first, [change("feat: new feature", 2)], null); + expect(deleted.files[notesPath]).toBeNull(); + expect( + updatePlan(deleted, [change("fix!: breaking fix", 3)]).files[notesPath], + ).toBeNull(); + const edited = first.files[notesPath]!.replace( + "release-section: highlights:start", + "edited section boundary", + ); + const preserved = updatePlan( + first, + [change("feat: new feature", 2)], + edited, + ); + expect(preserved.files[notesPath]).toContain("edited section boundary"); + expect(preserved.files[notesPath]).toContain("initial fix"); + expect(preserved.files[notesPath]).not.toContain("new feature"); + }); + + test("preserves existing prose when a section loses its ownership metadata until explicitly reset", () => { + const first = createReleasePlan(history([change("feat: initial feature")])); + const notes = first.files[notesPath]!.replace( + "initial feature", + "Human-reviewed notes", + ); + const state = JSON.parse(first.files[statePath]!); + delete state.sections.highlights; + const changes = [change("feat: later feature", 2)]; + const preserved = createReleasePlan(history(changes), { notes, state }); + expect(preserved.humanOwned).toEqual(["highlights"]); + expect(preserved.files[notesPath]).toContain("Human-reviewed notes"); + expect(preserved.files[notesPath]).not.toContain("later feature"); + const nextState = JSON.parse(preserved.files[statePath]!); + nextState.sections.highlights.reset = true; + const reset = createReleasePlan(history(changes), { + notes: preserved.files[notesPath] ?? null, + state: nextState, + }); + expect(reset.humanOwned).toEqual([]); + expect(reset.files[notesPath]).toContain("later feature"); + }); + + test("feeds the preserved canonical notes into the existing publisher", () => { + const first = createReleasePlan(history([change("feat: initial feature")])); + const edited = first.files[notesPath]!.replace( + "initial feature", + "Reviewed release behavior", + ); + const updated = updatePlan( + first, + [change("fix!: breaking fix", 2)], + edited, + ); + const summary = parseReviewedReleaseNotes( + updated.version, + updated.files[notesPath]!, + ); + const published = composeReleaseNotes( + "Generated change inventory", + summary, + ); + expect(published).toContain("Reviewed release behavior"); + expect(published).toContain("Generated change inventory"); + expect(published).not.toContain("release-version:"); + }); +}); + +describe("rolling release reconciliation", () => { + test("previews without writes and then creates one draft, refreshes it, and reuses its branch", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature", { + "plugins/codex-security/skills/example/SKILL.md": + "Example plugin instructions.\n", + }); + const preview = await fixture.run(true); + expect(preview.action).toBe("would-create"); + expect(preview.plan.version).toBe("0.1.24"); + expect(fixture.github.writes).toHaveLength(0); + const first = await fixture.run(); + expect(first.action).toBe("created"); + expect(first.reviewRequested).toBe(true); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + expect(pull.draft).toBe(true); + expect(pull.body).toContain("- [ ]"); + pull.body += "\nMaintainer review and test results.\n"; + const humanBody = pull.body; + fixture.merge("fix!: new interface", { + "sdk/typescript/src/example.ts": "export const next = true;\n", + }); + const second = await fixture.run(); + expect(second.action).toBe("updated"); + expect(second.pull).toBe(first.pull); + expect(second.plan.version).toBe("0.2.0"); + expect(second.plan.branch).toBe(first.plan.branch); + expect(pull.body).toBe(humanBody); + expect( + fixture.repo.readFile(second.headSha!, "sdk/typescript/src/example.ts"), + ).toContain("next"); + expect( + fixture.github.pulls.filter((candidate) => candidate.state === "open"), + ).toHaveLength(1); + expect( + fixture.github.writes + .filter( + (write) => + write.path === `pulls/${pull.number}` && write.method === "PATCH", + ) + .every((write) => Object.keys(write.body).join() === "title"), + ).toBe(true); + fixture.git("merge-base", "--is-ancestor", first.headSha!, second.headSha!); + const writes = fixture.github.writes.length; + expect((await fixture.run()).action).toBe("unchanged"); + expect(fixture.github.writes).toHaveLength(writes); + }); + + test("incorporates main-only changes without another proposal review or repeated commits", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + fixture.merge("test: add coverage", { + "sdk/typescript/tests-ts/example.test.ts": "// Additional coverage.\n", + }); + const updated = await fixture.run(); + expect(updated.action).toBe("updated"); + expect(updated.headSha).not.toBe(first.headSha); + expect(updated.plan.files[packagePath]).toBe(first.plan.files[packagePath]); + expect(updated.plan.files[notesPath]).toBe(first.plan.files[notesPath]); + expect(updated.reviewRequested).toBe(false); + expect(fixture.github.comments.get(first.pull!)).toHaveLength(1); + expect( + fixture.repo.readFile( + updated.headSha!, + "sdk/typescript/tests-ts/example.test.ts", + ), + ).toBe("// Additional coverage.\n"); + fixture.git( + "merge-base", + "--is-ancestor", + fixture.head()!, + updated.headSha!, + ); + const writes = fixture.github.writes.length; + const rerun = await fixture.run(); + expect(rerun.action).toBe("unchanged"); + expect(rerun.headSha).toBe(updated.headSha); + expect(rerun.reviewRequested).toBe(false); + expect(fixture.github.writes).toHaveLength(writes); + }); + + test("posts new suggestions for human-owned notes without repeating an unchanged proposal review", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + fixture.commit( + first.plan.branch, + { + [notesPath]: first.plan.files[notesPath]!.replace( + "initial feature", + "Human-reviewed notes", + ), + }, + "docs: review release notes", + ); + const reviewed = await fixture.run(); + const commentCount = fixture.github.comments.get(first.pull!)!.length; + fixture.merge("feat: another feature"); + const updated = await fixture.run(); + expect(updated.plan.files).toEqual(reviewed.plan.files); + expect(updated.reviewRequested).toBe(false); + const comments = fixture.github.comments.get(first.pull!)!; + expect(comments).toHaveLength(commentCount + 1); + expect(comments.at(-1)!.body).toContain("another feature"); + expect(comments.at(-1)!.body).not.toContain("@codex review"); + const rerun = await fixture.run(); + expect(rerun.action).toBe("unchanged"); + expect(comments).toHaveLength(commentCount + 1); + }); + + test("recovers a failed review comment without another release commit", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const request = fixture.github.request.bind(fixture.github); + let failComment = true; + fixture.github.request = async (method, path, body) => { + if (failComment && method === "POST" && path.startsWith("issues/")) { + failComment = false; + throw apiError(500); + } + return request(method, path, body); + }; + await expect(fixture.run()).rejects.toThrow("HTTP 500"); + const head = fixture.head("release/next-0.1.23"); + const recovered = await fixture.run(); + expect(recovered.headSha).toBe(head!); + expect(recovered.reviewRequested).toBe(true); + expect(fixture.github.comments.get(recovered.pull!)).toHaveLength(1); + }); + + test("recovers review on a manual rerun after GitHub exposes the updated head", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + fixture.merge("fix: later fix"); + const request = fixture.github.request.bind(fixture.github); + let headLagged = true; + fixture.github.request = async (method, path, body) => { + const result = structuredClone(await request(method, path, body)); + if (headLagged && method === "GET" && path === `pulls/${first.pull}`) { + (result as Pull).head.sha = first.headSha!; + } + return result; + }; + const pending = await fixture.run(); + expect(pending.reviewRequested).toBe(false); + expect(pending.reason).toContain("Rerun"); + expect(fixture.github.comments.get(first.pull!)).toHaveLength(1); + headLagged = false; + const recovered = await fixture.run(); + expect(recovered.action).toBe("unchanged"); + expect(recovered.headSha).toBe(pending.headSha); + expect(recovered.reviewRequested).toBe(true); + expect(fixture.github.comments.get(first.pull!)).toHaveLength(2); + }); + + test("requests review when the proposal changes back to previously reviewed content", async () => { + const fixture = new Fixture(); + const merged = fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const mergedPull = fixture.github.commitPulls.get(merged)![0]!; + mergedPull.title = "feat: reworded feature"; + expect((await fixture.run()).reviewRequested).toBe(true); + mergedPull.title = "feat: initial feature"; + const restored = await fixture.run(); + expect(restored.plan.files).toEqual(first.plan.files); + expect(restored.headSha).not.toBe(first.headSha); + expect(restored.reviewRequested).toBe(true); + expect(fixture.github.comments.get(first.pull!)).toHaveLength(3); + }); + + test("retries a concurrent human commit without dropping its notes or history", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + fixture.merge("fix: later fix"); + let humanSha = ""; + fixture.github.beforeRefWrite = () => { + const notes = fixture.repo.readFile( + fixture.head(first.plan.branch)!, + notesPath, + )!; + humanSha = fixture.commit( + first.plan.branch, + { + [notesPath]: notes.replace( + "initial feature", + "Human migration details", + ), + }, + "docs: review release notes", + ); + }; + const result = await fixture.run(); + expect(result.plan.humanOwned).toEqual(["highlights"]); + expect(result.plan.files[notesPath]).toContain("Human migration details"); + fixture.git("merge-base", "--is-ancestor", humanSha, result.headSha!); + expect(fixture.github.comments.get(result.pull!)!.at(-1)!.body).toContain( + "later fix", + ); + }); + + test("recomputes when main moves during the update", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + fixture.github.afterCommit = () => { + fixture.merge("fix!: concurrent breaking change"); + }; + const result = await fixture.run(); + expect(result.plan.version).toBe("0.2.0"); + expect(result.plan.mainSha).toBe(fixture.head()!); + expect( + fixture.github.pulls.filter((pull) => pull.state === "open"), + ).toHaveLength(1); + }); + + test.each(["closed", "retargeted", "ready"])( + "does not request review after a PR is %s during its final checks", + async (change) => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + const commentCount = fixture.github.comments.get(pull.number)!.length; + fixture.merge("fix: another fix"); + const request = fixture.github.request.bind(fixture.github); + let reads = 0; + fixture.github.request = async (method, path, body) => { + const result = structuredClone(await request(method, path, body)); + if ( + method === "GET" && + path === `pulls/${pull.number}` && + ++reads === 1 + ) { + if (change === "closed") pull.state = "closed"; + else if (change === "retargeted") pull.base.ref = "maintenance"; + else pull.draft = false; + } + return result; + }; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain( + change === "ready" ? "draft" : "closed or retargeted", + ); + expect(fixture.github.comments.get(pull.number)).toHaveLength( + commentCount, + ); + }, + ); + + test("opens the next empty draft after repeated updates are squash-merged, without waiting for publication", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + fixture.merge("fix: another change before release"); + const updated = await fixture.run(); + expect(updated.headSha).not.toBe(first.headSha); + fixture.merge(updated.plan.title, updated.plan.files); + pull.state = "closed"; + pull.merged_at = "2026-01-01T00:00:00Z"; + const second = await fixture.run(); + expect(second.pull).not.toBe(first.pull); + expect(second.plan.branch).not.toBe(first.plan.branch); + expect(second.plan.baseVersion).toBe("0.1.24"); + expect(second.plan.version).toBe("0.1.24"); + expect(second.plan.changes).toHaveLength(0); + expect(fixture.repo.readFile(second.headSha!, packagePath)).toBe( + packageText("0.1.24"), + ); + fixture.merge("feat: next feature"); + const third = await fixture.run(); + expect(third.pull).toBe(second.pull); + expect(third.plan.version).toBe("0.1.25"); + }); + + test("does not touch another release PR or recreate an intentionally closed proposal", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const manual = fixture.github.addPull( + "manual-release", + "release: prepare a release", + fixture.head()!, + ); + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain(`#${manual.number}`); + expect(fixture.github.writes).toHaveLength(0); + manual.state = "closed"; + const created = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === created.pull, + )!; + pull.state = "closed"; + const writes = fixture.github.writes.length; + expect((await fixture.run()).action).toBe("held"); + expect(fixture.github.writes).toHaveLength(writes); + pull.state = "open"; + expect((await fixture.run()).pull).toBe(pull.number); + }); + + test("does not create a duplicate when a manual release PR opens during preparation", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + fixture.github.beforeRefWrite = () => { + fixture.github.addPull( + "manual-release", + "release: prepare a manual release", + fixture.head()!, + ); + }; + expect((await fixture.run()).action).toBe("held"); + expect( + fixture.github.pulls.filter((pull) => pull.state === "open"), + ).toHaveLength(1); + }); + + test("preserves a deleted notes file on the remote branch", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + fixture.commit( + first.plan.branch, + { [notesPath]: null }, + "docs: remove draft notes", + ); + fixture.merge("fix: another fix"); + const next = await fixture.run(); + expect(fixture.repo.readFile(next.headSha!, notesPath)).toBeNull(); + expect(next.plan.humanOwned).toEqual(["highlights", "upgrades"]); + }); + + test.each([ + { "sdk/typescript/src/example.ts": "Human implementation changes.\n" }, + { [packagePath]: packageText("0.1.24", { example: "1.0.0" }) }, + ])("pauses instead of losing unrelated human edits", async (files) => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const humanSha = fixture.commit( + first.plan.branch, + files, + "fix: additional human changes", + ); + const writes = fixture.github.writes.length; + for (const title of [ + "fix: first fix", + "fix: second fix", + "fix: third fix", + ]) { + fixture.merge(title); + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain("edits"); + expect(fixture.head(first.plan.branch)).toBe(humanSha); + expect(fixture.github.writes).toHaveLength(writes); + } + }); + + test("recovers a branch whose PR creation failed without another commit or a duplicate PR", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + fixture.github.failPullCreation = true; + await expect(fixture.run()).rejects.toThrow("HTTP 500"); + const head = fixture.head("release/next-0.1.23"); + fixture.github.failPullCreation = false; + const result = await fixture.run(); + expect(result.action).toBe("created"); + expect(result.headSha).toBe(head!); + expect( + fixture.github.pulls.filter((pull) => pull.state === "open"), + ).toHaveLength(1); + }); + + test("deduplicates rebased PRs and recognizes breaking direct commits", async () => { + const fixture = new Fixture(); + const first = fixture.merge("feat: a rebased feature"); + const second = fixture.commit("main", {}, "feat: follow-up commit"); + fixture.github.commitPulls.set( + second, + fixture.github.commitPulls.get(first)!, + ); + fixture.commit( + "main", + {}, + "fix: direct commit\n\nBREAKING CHANGE: configuration changes", + ); + const releaseHistory = await readReleaseHistory( + fixture.repo, + fixture.head()!, + fixture.github, + ); + expect(releaseHistory.changes).toHaveLength(2); + expect( + nextReleaseVersion(releaseHistory.baseVersion, releaseHistory.changes), + ).toBe("0.2.0"); + expect(JSON.stringify(releaseHistory.changes)).not.toContain( + "BREAKING CHANGE:", + ); + }); + + test("uses the merge commit as the boundary for a release merged without squashing", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const releaseTree = fixture + .git("rev-parse", `${first.headSha}^{tree}`) + .trim(); + const merged = fixture + .git( + "commit-tree", + releaseTree, + "-p", + fixture.head()!, + "-p", + first.headSha!, + "-m", + first.plan.title, + ) + .trim(); + fixture.git("update-ref", "refs/heads/main", merged); + fixture.merge("fix: next change"); + const releaseHistory = await readReleaseHistory( + fixture.repo, + fixture.head()!, + fixture.github, + ); + expect(releaseHistory.baseCommit).toBe(merged); + expect(releaseHistory.baseVersion).toBe("0.1.24"); + expect(releaseHistory.changes).toHaveLength(1); + expect( + nextReleaseVersion(releaseHistory.baseVersion, releaseHistory.changes), + ).toBe("0.1.25"); + }); +}); + +describe("release proposal pauses", () => { + test.each(["open", "closed"] as const)( + "keeps a retargeted %s proposal paused across workflow runs", + async (state) => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + pull.base.ref = "maintenance"; + pull.state = state; + fixture.merge("fix: later fix"); + const writes = fixture.github.writes.length; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain(state === "open" ? "retargeted" : "closed"); + expect(fixture.head(first.plan.branch)).toBe(first.headSha!); + expect(fixture.github.writes).toHaveLength(writes); + expect( + fixture.github.pulls.filter( + (candidate) => candidate.head.ref === first.plan.branch, + ), + ).toHaveLength(1); + pull.base.ref = "main"; + pull.state = "open"; + const resumed = await fixture.run(); + expect(resumed.action).toBe("updated"); + expect(resumed.pull).toBe(first.pull); + }, + ); + + test("does not pause for an unrelated release targeting another base", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const maintenance = fixture.github.addPull( + "maintenance-release", + "release: prepare a maintenance release", + fixture.head()!, + ); + maintenance.base.ref = "maintenance"; + const result = await fixture.run(); + expect(result.action).toBe("created"); + expect(result.pull).not.toBe(maintenance.number); + expect(maintenance.base.ref).toBe("maintenance"); + }); + + test("pauses recovery when the orphan branch gains a PR targeting another base", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + fixture.github.failPullCreation = true; + await expect(fixture.run()).rejects.toThrow("HTTP 500"); + fixture.github.failPullCreation = false; + const branch = "release/next-0.1.23"; + const previousHead = fixture.head(branch)!; + fixture.merge("fix: later fix"); + fixture.github.afterCommit = () => { + const pull = fixture.github.addPull( + branch, + "release: review the existing proposal", + previousHead, + ); + pull.base.ref = "maintenance"; + }; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain("retargeted"); + expect(fixture.head(branch)).toBe(previousHead); + expect( + fixture.github.pulls.filter((candidate) => candidate.head.ref === branch), + ).toHaveLength(1); + }); + + test("keeps a ready release proposal frozen until it returns to draft", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + pull.draft = false; + pull.body += "\nMaintainer completed the final review.\n"; + const reviewedBody = pull.body; + const reviewedTitle = pull.title; + const reviewedHead = fixture.head(first.plan.branch); + fixture.merge("feat!: later breaking change"); + const writes = fixture.github.writes.length; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain("draft"); + expect(fixture.head(first.plan.branch)).toBe(reviewedHead); + expect(fixture.github.writes).toHaveLength(writes); + expect(pull.title).toBe(reviewedTitle); + expect(pull.body).toBe(reviewedBody); + pull.draft = true; + const resumed = await fixture.run(); + expect(resumed.action).toBe("updated"); + expect(resumed.pull).toBe(first.pull); + expect(resumed.plan.version).toBe("0.2.0"); + }); + + test("does not advance a proposal marked ready during preparation", async () => { + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = await fixture.run(); + const pull = fixture.github.pulls.find( + (candidate) => candidate.number === first.pull, + )!; + fixture.merge("fix: later fix"); + fixture.github.afterCommit = () => { + pull.draft = false; + }; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(fixture.head(first.plan.branch)).toBe(first.headSha!); + }); + + test.each(["new", "existing"])( + "pauses before writing a %s release branch when another release opens", + async (proposal) => { + const hasPull = proposal === "existing"; + const fixture = new Fixture(); + fixture.merge("feat: initial feature"); + const first = hasPull ? await fixture.run() : null; + if (first) fixture.merge("fix: later fix"); + const branch = first?.plan.branch ?? "release/next-0.1.23"; + const previousHead = fixture.head(branch); + fixture.github.afterCommit = () => { + fixture.github.addPull( + "manual-release", + "release: prepare a manual release", + fixture.head()!, + ); + }; + const held = await fixture.run(); + expect(held.action).toBe("held"); + expect(held.reason).toContain("Another release PR"); + expect(fixture.head(branch)).toBe(previousHead); + expect( + fixture.github.pulls.filter((candidate) => candidate.state === "open"), + ).toHaveLength(hasPull ? 2 : 1); + }, + ); +});