diff --git a/.github/workflows/node-release-pr.yml b/.github/workflows/node-release-pr.yml index c4bbcffe13..27dce5b0a1 100644 --- a/.github/workflows/node-release-pr.yml +++ b/.github/workflows/node-release-pr.yml @@ -24,7 +24,7 @@ jobs: github.repository == 'openai/codex-security' && github.ref == 'refs/heads/main' && ((github.event_name == 'workflow_dispatch' && inputs.dry_run) || (github.event_name != 'workflow_dispatch' && vars.RELEASE_PR_ENABLED != 'true')) - name: preview draft release PR + name: preview release PR runs-on: ubuntu-latest env: RELEASE_PR_DRY_RUN: "true" @@ -52,7 +52,7 @@ jobs: github.repository == 'openai/codex-security' && github.ref == 'refs/heads/main' && ((github.event_name == 'workflow_dispatch' && !inputs.dry_run) || (github.event_name != 'workflow_dispatch' && vars.RELEASE_PR_ENABLED == 'true')) - name: maintain draft release PR + name: maintain release PR runs-on: ubuntu-latest permissions: contents: write diff --git a/RELEASING.md b/RELEASING.md index 26deac6eb1..fc364ebb90 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -62,9 +62,10 @@ Review this policy before enabling automation for `1.x`. 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 +The updater opens one ready-for-review proposal on `release/next-` +once a change reaches `main` after the current package version. It recomputes +the proposal's version from all changes since that 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; @@ -72,35 +73,34 @@ 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 +Before merging the proposal, 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. +review on the current head before merging the proposal. -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. +When the release version merges, the updater waits for another change to +reach `main` before opening the next proposal. An empty release cycle returns +`action: "unchanged"` without creating a branch or PR. 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 +pauses updates; restore its `main` base before resuming. Both ready proposals +and existing drafts receive updates. Existing drafts can be marked ready +without pausing the updater. Review the current head before merging if +`main` has advanced. The updater rechecks pause 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 +### Editing the release notes The committed `.github/release-notes.md` is authoritative. The updater drafts highlights from merged titles and lists marked breaking changes for @@ -148,7 +148,7 @@ the repository's `GITHUB_TOKEN` with **Contents: write** and **Pull requests: wr by default; no separate App credentials are required. Preview runs use a separate job with read-only permissions for both scopes. -Review the resulting draft and select **Approve workflows to run** in its merge +Review the resulting PR and select **Approve workflows to run** in its merge box to start hosted CI. GitHub requires this approval for PRs created or updated with `GITHUB_TOKEN`. Check the Codex review on the current head as well, and request it manually if the automated request has not started a review. diff --git a/sdk/typescript/scripts/release-pr.mjs b/sdk/typescript/scripts/release-pr.mjs index ac3edd4e68..857dfa6341 100644 --- a/sdk/typescript/scripts/release-pr.mjs +++ b/sdk/typescript/scripts/release-pr.mjs @@ -333,14 +333,13 @@ function readReleaseBranch(repo, mainSha, headSha) { function initialPullBody(template) { const sections = { - Summary: - "Keep a draft release proposal current with changes merged into main.", + Summary: "Keep a 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.", + "The updater does not run package tests. Check required CI and Codex review on the current head before merging. 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.", + "Merging this PR starts the existing CI and protected release process. Review migration details and complete the public disclosure review before merging.", }; let body = template; for (const [heading, content] of Object.entries(sections)) { @@ -391,9 +390,6 @@ function pullHoldReason(pull, branch) { ) { 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; } @@ -456,7 +452,7 @@ async function ensurePullRequest( title: plan.title, head: plan.branch, base: "main", - draft: true, + draft: false, body: initialPullBody(template), }); } @@ -546,6 +542,14 @@ export async function reconcileReleasePullRequest({ if (holdReason) return { action: "held", reason: holdReason, dryRun, plan }; if (pull && !headSha) throw new Error("The open release PR has no branch head."); + if (plan.changes.length === 0) + return { + action: "unchanged", + reason: + "No changes have reached main since the current release version.", + dryRun, + plan, + }; const changed = !headSha || previous.mergeBase !== mainSha || diff --git a/sdk/typescript/tests-ts/release-pr.test.ts b/sdk/typescript/tests-ts/release-pr.test.ts index f5eefe6345..f1010601d7 100644 --- a/sdk/typescript/tests-ts/release-pr.test.ts +++ b/sdk/typescript/tests-ts/release-pr.test.ts @@ -513,7 +513,7 @@ describe("GitHub request transport", () => { }, ); - test("creates and updates a draft through serialized requests, including a concurrent commit conflict", async () => { + test("creates and updates a release PR through serialized requests, including a concurrent commit conflict", async () => { const fixture = new Fixture(); fixture.merge("feat: initial feature"); let conflicts = 0; @@ -950,7 +950,21 @@ describe("human note ownership", () => { }); describe("rolling release reconciliation", () => { - test("previews without writes and then creates one draft, refreshes it, and reuses its branch", async () => { + test.each([true, false])( + "leaves an empty release cycle unchanged with dryRun=%s", + async (dryRun) => { + const fixture = new Fixture(); + const result = await fixture.run(dryRun); + expect(result.action).toBe("unchanged"); + expect(result.plan.changes).toHaveLength(0); + expect(result.pull).toBeUndefined(); + expect(fixture.head(result.plan.branch)).toBeNull(); + expect(fixture.github.writes).toHaveLength(0); + expect(fixture.github.pulls).toHaveLength(0); + }, + ); + + test("previews without writes and then creates one ready PR, refreshes it, and reuses its branch", async () => { const fixture = new Fixture(); fixture.merge("feat: initial feature", { "plugins/codex-security/skills/example/SKILL.md": @@ -966,7 +980,7 @@ describe("rolling release reconciliation", () => { const pull = fixture.github.pulls.find( (candidate) => candidate.number === first.pull, )!; - expect(pull.draft).toBe(true); + expect(pull.draft).toBe(false); expect(pull.body).toContain("- [ ]"); pull.body += "\nMaintainer review and test results.\n"; const humanBody = pull.body; @@ -1168,7 +1182,7 @@ describe("rolling release reconciliation", () => { ).toHaveLength(1); }); - test.each(["closed", "retargeted", "ready"])( + test.each(["closed", "retargeted"])( "does not request review after a PR is %s during its final checks", async (change) => { const fixture = new Fixture(); @@ -1189,23 +1203,20 @@ describe("rolling release reconciliation", () => { ++reads === 1 ) { if (change === "closed") pull.state = "closed"; - else if (change === "retargeted") pull.base.ref = "maintenance"; - else pull.draft = false; + else pull.base.ref = "maintenance"; } return result; }; const held = await fixture.run(); expect(held.action).toBe("held"); - expect(held.reason).toContain( - change === "ready" ? "draft" : "closed or retargeted", - ); + expect(held.reason).toContain("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 () => { + test("waits for a new change after a release is squash-merged before opening the next ready PR", async () => { const fixture = new Fixture(); fixture.merge("feat: initial feature"); const first = await fixture.run(); @@ -1218,19 +1229,26 @@ describe("rolling release reconciliation", () => { fixture.merge(updated.plan.title, updated.plan.files); pull.state = "closed"; pull.merged_at = "2026-01-01T00:00:00Z"; + const writes = fixture.github.writes.length; const second = await fixture.run(); - expect(second.pull).not.toBe(first.pull); + expect(second.action).toBe("unchanged"); + expect(second.pull).toBeUndefined(); 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"), - ); + expect(fixture.head(second.plan.branch)).toBeNull(); + expect(fixture.github.writes).toHaveLength(writes); fixture.merge("feat: next feature"); const third = await fixture.run(); - expect(third.pull).toBe(second.pull); + expect(third.action).toBe("created"); + expect(third.pull).not.toBe(first.pull); + expect(third.plan.branch).toBe(second.plan.branch); expect(third.plan.version).toBe("0.1.25"); + expect( + fixture.github.pulls.find((candidate) => candidate.number === third.pull) + ?.draft, + ).toBe(false); }); test("does not touch another release PR or recreate an intentionally closed proposal", async () => { @@ -1466,35 +1484,32 @@ describe("release proposal pauses", () => { ).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.each([false, true])( + "updates an existing proposal with draft=%s", + async (draft) => { + 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 = draft; + pull.body += "\nMaintainer review notes.\n"; + const reviewedBody = pull.body; + const reviewedHead = fixture.head(first.plan.branch); + fixture.merge("feat!: later breaking change"); + const updated = await fixture.run(); + expect(updated.action).toBe("updated"); + expect(updated.pull).toBe(first.pull); + expect(updated.plan.version).toBe("0.2.0"); + expect(fixture.head(first.plan.branch)).not.toBe(reviewedHead); + expect(pull.title).toBe(updated.plan.title); + expect(pull.body).toBe(reviewedBody); + expect(pull.draft).toBe(draft); + }, + ); - test("does not advance a proposal marked ready during preparation", async () => { + test("does not advance a proposal closed during preparation", async () => { const fixture = new Fixture(); fixture.merge("feat: initial feature"); const first = await fixture.run(); @@ -1503,7 +1518,7 @@ describe("release proposal pauses", () => { )!; fixture.merge("fix: later fix"); fixture.github.afterCommit = () => { - pull.draft = false; + pull.state = "closed"; }; const held = await fixture.run(); expect(held.action).toBe("held");