Skip to content

Commit 8e98112

Browse files
authored
ci(release): add automated npm release workflow (#2)
Release on every push to main (merged PR or direct push) by auto- bumping the patch segment, with manual workflow_dispatch as an escape hatch to pin an exact version or bump minor/major. Git tags (vX.Y.Z) are the source of truth, so the committed package.json version is only the baseline before the first tag exists, and the bump is never committed back — a release never pushes to main and cannot re-trigger itself. Version resolution is delegated to bun-run scripts under scripts/release so each CI step (compute-version, set-version, publish, create-github-release) stays idempotent and is reusable as a plain command. Publishing uses npm OIDC trusted publishing with provenance, so no NPM_TOKEN is needed. This requires package.json to carry a repository field (mandatory for --provenance) and publishConfig; files is narrowed to providers + meta.json so the published tarball excludes the workflow and release scripts. Signed-off-by: Kevin Cui <bh@bugs.cc>
1 parent aab1ac6 commit 8e98112

6 files changed

Lines changed: 488 additions & 0 deletions

File tree

.bun-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1.3.14

.github/workflows/release.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: Release
2+
3+
# Automated npm + GitHub release. Two triggers:
4+
# - push to main (every merged PR or direct push) → auto-bump the patch segment, or
5+
# - manual workflow_dispatch → set "expected_version" to release an exact X.Y.Z, or leave it
6+
# empty and pick a "version_bump" segment to auto-bump from the latest tag.
7+
#
8+
# Versions are resolved by scripts/release/cli.ts; git tags (vX.Y.Z) are the source of truth, so
9+
# the committed package.json version is only the baseline used before the first tag exists.
10+
# The package.json version bump is never committed back, so a release never pushes to main and
11+
# therefore never re-triggers itself.
12+
on:
13+
push:
14+
branches: [main]
15+
workflow_dispatch:
16+
inputs:
17+
expected_version:
18+
description: "Exact version to release (e.g. 1.2.3). Leave empty to auto-bump from the latest tag."
19+
required: false
20+
type: string
21+
default: ""
22+
version_bump:
23+
description: "Segment to bump when expected_version is empty."
24+
required: true
25+
type: choice
26+
options:
27+
- patch
28+
- minor
29+
- major
30+
default: patch
31+
32+
permissions:
33+
contents: write # create the git tag + GitHub release
34+
id-token: write # OIDC: npm trusted publishing (token-free) + provenance
35+
36+
concurrency:
37+
group: release
38+
cancel-in-progress: false
39+
40+
jobs:
41+
release:
42+
runs-on: ubuntu-latest
43+
env:
44+
# On a push there are no inputs: EXPECTED_VERSION is empty (→ auto-bump) and
45+
# VERSION_BUMP falls back to patch via the `|| 'patch'` guard.
46+
EXPECTED_VERSION: ${{ inputs.expected_version }}
47+
VERSION_BUMP: ${{ inputs.version_bump || 'patch' }}
48+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
steps:
50+
- uses: actions/checkout@v4
51+
with:
52+
fetch-depth: 0 # need full history + tags to compute the next version
53+
fetch-tags: true
54+
55+
- uses: oven-sh/setup-bun@v2
56+
with:
57+
bun-version-file: .bun-version
58+
59+
- uses: actions/setup-node@v4
60+
with:
61+
# OIDC trusted publishing needs Node >= 22.14.0; "22" resolves to the latest 22.x.
62+
node-version: "22"
63+
registry-url: "https://registry.npmjs.org"
64+
65+
# Trusted publishing needs npm >= 11.5.1, but Node 22 ships npm 10.x — upgrade the
66+
# global npm so `release:publish` runs a CLI that performs the OIDC token exchange.
67+
# Pinned to the 11.x major (>= 11.5.1) so a future npm major can't silently change the
68+
# publish/provenance behaviour; `npm --version` echoes the resolved version to the log.
69+
- name: Upgrade npm for OIDC trusted publishing
70+
run: npm install -g "npm@^11.5.1" && npm --version
71+
72+
# Exports RELEASE_VERSION / RELEASE_TAG / PREVIOUS_TAG to the following steps,
73+
# and fails fast if the target tag already exists.
74+
- name: Compute release version
75+
run: bun run release:compute-version
76+
77+
# Write the resolved version into package.json before publishing.
78+
- name: Set package.json version
79+
run: bun run release:set-version
80+
81+
# Authenticates via OIDC trusted publishing (configured in the npm package settings
82+
# for this repo + workflow file), so no NPM_TOKEN / NODE_AUTH_TOKEN is required.
83+
- name: Publish to npm
84+
run: bun run release:publish
85+
86+
# Creates the git tag at this commit and the GitHub release with generated notes.
87+
- name: Create GitHub release
88+
run: bun run release:create-github-release

package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,24 @@
33
"version": "0.0.0",
44
"description": "Auto-generated type augmentation for @oomol-lab/connector. Import the per-provider subpath you use.",
55
"type": "module",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/oomol-lab/connector-types.git"
9+
},
10+
"publishConfig": {
11+
"registry": "https://registry.npmjs.org"
12+
},
613
"sideEffects": false,
14+
"files": [
15+
"providers",
16+
"meta.json"
17+
],
18+
"scripts": {
19+
"release:compute-version": "bun scripts/release/cli.ts compute-version",
20+
"release:set-version": "bun scripts/release/cli.ts set-version",
21+
"release:publish": "bun scripts/release/cli.ts publish",
22+
"release:create-github-release": "bun scripts/release/cli.ts create-github-release"
23+
},
724
"exports": {
825
"./*": {
926
"types": "./providers/*.d.ts",

scripts/release/cli.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Release workflow CLI. Run a single step with `bun run scripts/release/cli.ts <command>`:
3+
*
4+
* compute-version Resolve the version from git tags + workflow inputs and export
5+
* RELEASE_VERSION / RELEASE_TAG / PREVIOUS_TAG to later job steps.
6+
* set-version Write RELEASE_VERSION into package.json before publish.
7+
* publish Publish the current package to npm (idempotent; skips if the
8+
* version already exists). In CI it authenticates via npm OIDC
9+
* trusted publishing (no token) and adds --provenance.
10+
* create-github-release Create the git tag + GitHub release at GITHUB_SHA (idempotent).
11+
*
12+
* Inputs are read from the environment so the same script works as plain CI steps:
13+
* compute-version ← EXPECTED_VERSION, VERSION_BUMP
14+
* set-version ← RELEASE_VERSION
15+
* publish ← RELEASE_VERSION, GITHUB_ACTIONS (auth via OIDC trusted publishing in CI)
16+
* create-release ← RELEASE_TAG, PREVIOUS_TAG, GITHUB_SHA, GH_TOKEN
17+
*/
18+
19+
import process from "node:process";
20+
import { fileURLToPath } from "node:url";
21+
22+
import {
23+
listGitTags,
24+
readPackageName,
25+
readPackageVersion,
26+
readRequiredEnv,
27+
runCapture,
28+
runInherit,
29+
setPackageVersion,
30+
writeGitHubEnv,
31+
} from "./lib";
32+
import {
33+
computeReleaseVersion,
34+
isStableSemver,
35+
readVersionBump,
36+
} from "./version";
37+
38+
// Run everything from the repo root regardless of the caller's cwd, so git/npm/gh and the
39+
// relative package.json path all resolve correctly.
40+
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
41+
process.chdir(repoRoot);
42+
const packageJsonPath = "package.json";
43+
44+
async function runComputeVersion(): Promise<void> {
45+
const baseVersion = await readPackageVersion(packageJsonPath);
46+
const result = computeReleaseVersion({
47+
expectedVersion: process.env.EXPECTED_VERSION ?? "",
48+
versionBump: readVersionBump(process.env.VERSION_BUMP ?? "patch"),
49+
tags: listGitTags(),
50+
baseVersion: isStableSemver(baseVersion) ? baseVersion : "0.0.0",
51+
});
52+
53+
await writeGitHubEnv({
54+
RELEASE_VERSION: result.version,
55+
RELEASE_TAG: result.tagName,
56+
PREVIOUS_TAG: result.previousTag,
57+
});
58+
process.stdout.write(
59+
`Release version ${result.version} (tag ${result.tagName}, previous ${result.previousTag || "none"}).\n`,
60+
);
61+
}
62+
63+
async function runSetVersion(): Promise<void> {
64+
const version = readRequiredEnv("RELEASE_VERSION");
65+
await setPackageVersion(packageJsonPath, version);
66+
process.stdout.write(`Set package.json version to ${version}.\n`);
67+
}
68+
69+
function npmVersionExists(packageSpec: string): boolean {
70+
const result = runCapture("npm", ["view", packageSpec, "version", "--json"]);
71+
if (result.status === 0) {
72+
return result.stdout.trim() !== "";
73+
}
74+
75+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
76+
if (output.includes("e404") || output.includes("no match found")) {
77+
return false;
78+
}
79+
throw new Error(`Failed to query npm for ${packageSpec}:\n${(result.stderr || result.stdout).trim()}`);
80+
}
81+
82+
async function runPublish(): Promise<void> {
83+
const version = readRequiredEnv("RELEASE_VERSION");
84+
const name = await readPackageName(packageJsonPath);
85+
const packageSpec = `${name}@${version}`;
86+
87+
if (npmVersionExists(packageSpec)) {
88+
process.stdout.write(`Skipping publish: ${packageSpec} already exists on npm.\n`);
89+
return;
90+
}
91+
92+
const args = ["publish", "--access", "public"];
93+
// In CI npm authenticates via OIDC trusted publishing; provenance rides on the same
94+
// id-token (id-token: write). Neither is available locally, so add --provenance only here.
95+
if (process.env.GITHUB_ACTIONS === "true") {
96+
args.push("--provenance");
97+
}
98+
runInherit("npm", args);
99+
process.stdout.write(`Published ${packageSpec}.\n`);
100+
}
101+
102+
function gitHubReleaseExists(tag: string): boolean {
103+
return runCapture("gh", ["release", "view", tag, "--json", "tagName"]).status === 0;
104+
}
105+
106+
function runCreateGitHubRelease(): void {
107+
const tag = readRequiredEnv("RELEASE_TAG");
108+
109+
if (gitHubReleaseExists(tag)) {
110+
process.stdout.write(`Skipping GitHub release: ${tag} already exists.\n`);
111+
return;
112+
}
113+
114+
const previousTag = process.env.PREVIOUS_TAG ?? "";
115+
const args = [
116+
"release",
117+
"create",
118+
tag,
119+
"--target",
120+
readRequiredEnv("GITHUB_SHA"),
121+
"--title",
122+
tag,
123+
"--generate-notes",
124+
];
125+
if (previousTag !== "") {
126+
args.push("--notes-start-tag", previousTag);
127+
}
128+
args.push("--latest");
129+
130+
runInherit("gh", args);
131+
process.stdout.write(`Created GitHub release ${tag}.\n`);
132+
}
133+
134+
async function main(): Promise<void> {
135+
const command = process.argv[2];
136+
switch (command) {
137+
case "compute-version":
138+
await runComputeVersion();
139+
return;
140+
case "set-version":
141+
await runSetVersion();
142+
return;
143+
case "publish":
144+
await runPublish();
145+
return;
146+
case "create-github-release":
147+
runCreateGitHubRelease();
148+
return;
149+
default:
150+
throw new Error(
151+
`Unknown command: ${command ?? "(none)"}. `
152+
+ "Expected one of: compute-version, set-version, publish, create-github-release.",
153+
);
154+
}
155+
}
156+
157+
await main();

scripts/release/lib.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Side-effect helpers for the Release workflow: git, npm/gh subprocesses,
3+
* package.json I/O, and GitHub Actions env wiring.
4+
*
5+
* Node built-ins only (no `Bun.*`) so the scripts stay portable; they still run under `bun run`.
6+
*/
7+
8+
import { spawnSync } from "node:child_process";
9+
import { appendFile, readFile, writeFile } from "node:fs/promises";
10+
import process from "node:process";
11+
12+
export interface CommandResult {
13+
status: number;
14+
stdout: string;
15+
stderr: string;
16+
}
17+
18+
/** Runs a command and captures its output. Use for queries (git tags, npm view). */
19+
export function runCapture(command: string, args: readonly string[]): CommandResult {
20+
const result = spawnSync(command, [...args], { encoding: "utf8" });
21+
if (result.error) throw result.error;
22+
return {
23+
status: result.status ?? 1,
24+
stdout: result.stdout ?? "",
25+
stderr: result.stderr ?? "",
26+
};
27+
}
28+
29+
/**
30+
* Runs a command with inherited stdio (so its output streams to the CI log) and throws
31+
* on a non-zero exit. Use for side-effecting steps (npm publish, gh release create).
32+
*/
33+
export function runInherit(
34+
command: string,
35+
args: readonly string[],
36+
env?: NodeJS.ProcessEnv,
37+
): void {
38+
const result = spawnSync(command, [...args], {
39+
stdio: "inherit",
40+
env: env ?? process.env,
41+
});
42+
if (result.error) throw result.error;
43+
if (result.status !== 0) {
44+
throw new Error(`${command} ${args.join(" ")} exited with code ${result.status ?? "unknown"}.`);
45+
}
46+
}
47+
48+
/** Lists `vX.Y.Z`-style tags newest-first; returns `[]` when the repo has no matching tags. */
49+
export function listGitTags(): string[] {
50+
const result = runCapture("git", ["tag", "-l", "v*", "--sort=-v:refname"]);
51+
if (result.status !== 0) {
52+
throw new Error(`Failed to list git tags: ${result.stderr.trim() || "unknown error"}`);
53+
}
54+
return result.stdout
55+
.split("\n")
56+
.map(tag => tag.trim())
57+
.filter(tag => tag !== "");
58+
}
59+
60+
interface PackageManifest {
61+
name?: unknown;
62+
version?: unknown;
63+
}
64+
65+
async function readManifest(packageJsonPath: string): Promise<Record<string, unknown>> {
66+
return JSON.parse(await readFile(packageJsonPath, "utf8")) as Record<string, unknown>;
67+
}
68+
69+
export async function readPackageVersion(packageJsonPath: string): Promise<string> {
70+
const manifest = await readManifest(packageJsonPath) as PackageManifest;
71+
return typeof manifest.version === "string" ? manifest.version : "";
72+
}
73+
74+
export async function readPackageName(packageJsonPath: string): Promise<string> {
75+
const manifest = await readManifest(packageJsonPath) as PackageManifest;
76+
if (typeof manifest.name !== "string" || manifest.name === "") {
77+
throw new Error(`package.json at ${packageJsonPath} is missing a "name".`);
78+
}
79+
return manifest.name;
80+
}
81+
82+
/** Writes `version` into package.json, preserving key order and 2-space formatting. */
83+
export async function setPackageVersion(packageJsonPath: string, version: string): Promise<void> {
84+
const manifest = await readManifest(packageJsonPath);
85+
manifest.version = version;
86+
await writeFile(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
87+
}
88+
89+
/**
90+
* Exports `key=value` pairs to later steps of the same job via `$GITHUB_ENV`.
91+
* Outside Actions (no `$GITHUB_ENV`) it just prints them, so local dry-runs work.
92+
*/
93+
export async function writeGitHubEnv(entries: Record<string, string>): Promise<void> {
94+
const text = `${Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
95+
const githubEnvPath = process.env.GITHUB_ENV;
96+
if (githubEnvPath === undefined || githubEnvPath === "") {
97+
process.stdout.write(text);
98+
return;
99+
}
100+
await appendFile(githubEnvPath, text, "utf8");
101+
}
102+
103+
export function readRequiredEnv(name: string): string {
104+
const value = process.env[name];
105+
if (value === undefined || value === "") {
106+
throw new Error(`${name} is required.`);
107+
}
108+
return value;
109+
}

0 commit comments

Comments
 (0)