Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/node-release-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
36 changes: 18 additions & 18 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,45 +62,45 @@ 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-<base-version>` 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-<base-version>`
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;
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
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 12 additions & 8 deletions sdk/typescript/scripts/release-pr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -456,7 +452,7 @@ async function ensurePullRequest(
title: plan.title,
head: plan.branch,
base: "main",
draft: true,
draft: false,
body: initialPullBody(template),
});
}
Expand Down Expand Up @@ -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 ||
Expand Down
103 changes: 59 additions & 44 deletions sdk/typescript/tests-ts/release-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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");
Expand Down