Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
48 changes: 46 additions & 2 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
* bun scripts/release.ts 0.1.0 --publish # actually publish 0.1.0
*
* Requires: gh CLI (authed). Publishing is tokenless via Trusted Publishing (OIDC) — no NPM_TOKEN.
*
* Protected-branch push: `main` and `preview` carry rulesets that require a pull request, and the
* admin bypass is `bypass_mode: "pull_request"` — enough to merge a PR, not enough to push. Set
* `OCX_RELEASE_SSH_KEY` to the private key of the dedicated write deploy key registered as a
* `DeployKey` bypass actor on those rulesets, and the version-bump push (and only that push) uses
* it. Override the SSH remote with `OCX_RELEASE_SSH_REPO` when releasing a fork. Unset, the push
* behaves exactly as before.
*/
import { commandInvocation } from "../src/lib/win-exec";

Expand Down Expand Up @@ -86,12 +93,13 @@ async function capture(command: string[]): Promise<string> {
}

/** Run a command with its output attached to this terminal; abort on failure. */
async function runLoud(command: string[]): Promise<void> {
async function runLoud(command: string[], env?: Record<string, string>): Promise<void> {
const [bin, ...rest] = command;
const invocation = commandInvocation(bin ?? "", rest);
const proc = Bun.spawn([invocation.file, ...invocation.args], {
stdout: "inherit",
stderr: "inherit",
...(env ? { env: { ...process.env, ...env } } : {}),
...(invocation.options.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
});
const exitCode = await proc.exited;
Expand All @@ -101,6 +109,40 @@ async function runLoud(command: string[]): Promise<void> {
}
}

/**
* Release-key push target for a protected branch.
*
* `main` and `preview` are covered by branch-protection rulesets that require a pull request,
* so the maintainer's own credential cannot push the version-bump commit even with admin rights:
* the admin bypass is `bypass_mode: "pull_request"`, which permits merging a PR but not a direct
* push. The v2.29.0 release died exactly there.
*
* The carve-out is a dedicated write deploy key registered as a `DeployKey` bypass actor on both
* rulesets. It is deliberately NOT a runtime toggle of the ruleset itself: flipping protection off
* around the push and back on afterwards is crash-open — a SIGKILL, a lost network, or a hung push
* between the two calls leaves the branch unprotected with no lease to expire it, and while the
* window is open the bypass applies to every holder of the admin role, not just this release. A
* key fails closed instead: if the process dies, protection was never weakened, and revoking one
* credential closes the carve-out without touching repository configuration.
*
* Opt-in by path: without `OCX_RELEASE_SSH_KEY` the push runs exactly as before over the configured
* remote, so a contributor or CI clone is unaffected. The key is used for this one push and nothing
* else; ordinary git operations keep the maintainer's normal credential.
*/
function releasePushCommand(branch: string): { command: string[]; env?: Record<string, string> } {
const keyPath = process.env.OCX_RELEASE_SSH_KEY?.trim();
if (!keyPath) return { command: ["git", "push", "origin", branch] };
const slug = process.env.OCX_RELEASE_SSH_REPO?.trim() || "git@github.com:lidge-jun/opencodex.git";
return {
// Push to the SSH URL explicitly rather than rewriting the `origin` remote: the remote stays
// HTTPS for every other command, so nothing outside this call inherits the key.
command: ["git", "push", slug, `HEAD:${branch}`],
// IdentitiesOnly stops ssh from offering the agent's other keys first, which would authenticate
// as the maintainer and get rejected by the ruleset again.
env: { GIT_SSH_COMMAND: `ssh -i ${keyPath} -o IdentitiesOnly=yes` },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};
}

async function readPackageName(): Promise<string> {
try {
const pkg = JSON.parse(await Bun.file("package.json").text()) as { name?: unknown };
Expand Down Expand Up @@ -406,7 +448,9 @@ if (pendingBump) {
const releaseSha = await capture(["git", "rev-parse", "HEAD"]);
if (pendingBump) {
console.log(`→ push origin ${branch}`);
await runLoud(["git", "push", "origin", branch]);
const push = releasePushCommand(branch);
if (push.env) console.log("→ using the release deploy key for the protected push");
await runLoud(push.command, push.env);
} else {
console.log(`→ release commit ${releaseSha.slice(0, 9)} already pushed; reusing it`);
}
Expand Down
49 changes: 47 additions & 2 deletions tests/release-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const releaseScriptPath = join(repoRoot, "scripts", "release.ts");
interface LoggedCall {
args: string[];
name: string;
/** Only the ssh override is recorded: the release deploy-key path is the reason it exists. */
gitSshCommand?: string;
}

interface ReleaseScenario {
Expand All @@ -25,6 +27,9 @@ interface ReleaseScenario {
privacyExitCode?: number;
testExitCode?: number;
typecheckExitCode?: number;
releaseSshKey?: string;
releaseSshRepo?: string;
pendingBump?: boolean;
}

function writeExecutable(path: string, contents: string): void {
Expand Down Expand Up @@ -57,7 +62,7 @@ process.exit(exitCode);
return `import { appendFileSync } from "node:fs";

const args = process.argv.slice(2);
appendFileSync(process.env.FAKE_RELEASE_LOG, JSON.stringify({ name: "git", args }) + "\\n");
appendFileSync(process.env.FAKE_RELEASE_LOG, JSON.stringify({ name: "git", args, ...(process.env.GIT_SSH_COMMAND ? { gitSshCommand: process.env.GIT_SSH_COMMAND } : {}) }) + "\\n");

const headSha = process.env.FAKE_GIT_HEAD_SHA ?? "abc123def456";
const branch = process.env.FAKE_GIT_BRANCH ?? "main";
Expand All @@ -70,7 +75,10 @@ if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD")
}

if (args[0] === "status" && args[1] === "--porcelain") {
stdout((process.env.FAKE_GIT_STATUS ?? "") + "\\n");
// The clean-tree preflight and the pendingBump probe both land here. Only the second one
// passes a path, so a scenario can report a pending bump without failing the first gate.
const pathScoped = args.length > 2;
stdout((pathScoped ? (process.env.FAKE_GIT_PENDING_BUMP ?? "") : (process.env.FAKE_GIT_STATUS ?? "")) + "\\n");
process.exit(0);
}

Expand Down Expand Up @@ -228,6 +236,9 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) {
FAKE_BUN_PRIVACY_EXIT_CODE: String(scenario.privacyExitCode ?? 0),
...(scenario.npmLatest ? { FAKE_NPM_LATEST: scenario.npmLatest } : {}),
...(scenario.npmPreview ? { FAKE_NPM_PREVIEW: scenario.npmPreview } : {}),
...(scenario.releaseSshKey ? { OCX_RELEASE_SSH_KEY: scenario.releaseSshKey } : {}),
...(scenario.releaseSshRepo ? { OCX_RELEASE_SSH_REPO: scenario.releaseSshRepo } : {}),
...(scenario.pendingBump ? { FAKE_GIT_PENDING_BUMP: " M package.json" } : {}),
},
encoding: "utf8",
});
Expand Down Expand Up @@ -324,6 +335,40 @@ describe("release helper", () => {
)).toBeGreaterThanOrEqual(0);
});

/**
* `main` and `preview` carry rulesets whose admin bypass is `pull_request` — enough to merge a
* PR, not enough to push. That is where v2.29.0 died. The carve-out is a dedicated write deploy
* key registered as a `DeployKey` bypass actor, selected for this one push and nothing else.
*
* Pin both halves: the key path must reach git as `GIT_SSH_COMMAND` with `IdentitiesOnly` (an
* ssh-agent holding the maintainer's key would otherwise authenticate as the maintainer and be
* rejected by the ruleset again), and the default path must stay byte-identical so a contributor
* or CI clone without the variable is unaffected.
*/
test("the protected push uses the release deploy key only when one is configured", () => {
const { calls, result } = runRelease("9.9.9", {
releaseSshKey: "/tmp/ocx-release-key",
releaseSshRepo: "git@github.com:lidge-jun/opencodex.git",
pendingBump: true,
});

expect(result.status).toBe(0);
const push = calls.find(call => call.name === "git" && call.args[0] === "push");
expect(push).toBeDefined();
expect(push?.args).toEqual(["push", "git@github.com:lidge-jun/opencodex.git", "HEAD:main"]);
expect(push?.gitSshCommand).toContain("/tmp/ocx-release-key");
expect(push?.gitSshCommand).toContain("IdentitiesOnly=yes");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("without a configured key the push is unchanged and carries no ssh override", () => {
const { calls, result } = runRelease("9.9.9", { pendingBump: true });

expect(result.status).toBe(0);
const push = calls.find(call => call.name === "git" && call.args[0] === "push");
expect(push?.args).toEqual(["push", "origin", "main"]);
expect(push?.gitSshCommand).toBeUndefined();
});

test("aborts before dispatch when the remote branch moved during the CI wait", () => {
const { calls, result } = runRelease("9.9.9", {
headSha: "abc123def456",
Expand Down
Loading