ci(scripts): skipped duplicate release CI runs and enabled OIDC npm publishes

- Added a workflow `gate` job that marks `main` pushes with a `v*` tag at `HEAD` as duplicate release runs.
- Conditioned native, lint/test, and install CI jobs on that gate so redundant build and publish work is skipped on duplicate tagged pushes.
- Updated `scripts/ci-release-publish.ts` to publish packed tarballs via `npm publish` after `bun pm pack`, handling already-published versions as a no-op.
This commit is contained in:
can1357
2026-05-30 18:21:57 +02:00
parent e32c639d2e
commit e1a0d235ec
3 changed files with 465 additions and 35 deletions
+69 -28
View File
@@ -10,12 +10,17 @@
* and `dist/types` (plus `dist/client` for `stats`) is added to
* `files`. The on-repo manifest keeps pointing at source so local
* dev resolves types without any build.
* 3. Invoke `bun publish` on the (now publish-shaped) manifest.
* 3. Pack with `bun pm pack` (resolves the `catalog:`/`workspace:`
* protocols npm cannot, and runs each package's `prepack` lifecycle),
* then publish the resolved tarball with `npm publish` — see
* `packAndPublish` for why npm and not `bun publish`.
*
* Intended for CI. Mutates `package.json` in place — if you run this
* locally, expect a dirty working tree and `git restore` after.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
import {
@@ -24,7 +29,7 @@ import {
type GeneratedLeafPackage,
} from "../packages/natives/scripts/gen-npm-packages.ts";
interface PublishPackage {
export interface PublishPackage {
dir: string;
kind: "typescript" | "native";
/** Extra build steps before manifest rewrite (e.g. esbuild bundles). */
@@ -49,7 +54,7 @@ interface PackageManifest extends JsonObject {
const repoRoot = path.join(import.meta.dir, "..");
const isDryRun = process.argv.includes("--dry-run");
const packages: PublishPackage[] = [
export const packages: PublishPackage[] = [
{ dir: "packages/utils", kind: "typescript" },
{ dir: "packages/ai", kind: "typescript" },
{ dir: "packages/natives", kind: "native" },
@@ -151,16 +156,64 @@ async function prepareNativeCorePackage(pkgDir: string, write: boolean): Promise
return manifest;
}
async function publishGeneratedLeafPackage(leaf: GeneratedLeafPackage): Promise<void> {
/**
* Pack with `bun pm pack`, then publish the resolved tarball with `npm publish`.
*
* `bun pm pack` builds the tarball because it resolves the `catalog:` and
* `workspace:` protocols (npm would ship them verbatim, producing
* uninstallable manifests) and runs the `prepack` lifecycle, baking generated
* sources (e.g. coding-agent's docs index) into the tarball.
*
* The tarball is handed to `npm publish` — not `bun publish` — because only the
* npm CLI performs the OIDC trusted-publishing token exchange; `bun publish`
* has no OIDC support (oven-sh/bun#22423). In CI with `id-token: write` granted
* and `NODE_AUTH_TOKEN` set, npm tries OIDC per package and silently falls back
* to the configured token when the package has no matching trusted publisher —
* which also covers a package's first-ever publish. npm auto-enables provenance
* only on the OIDC path, so we never pass `--provenance` (it would hard-fail the
* token fallback).
*/
async function packAndPublish(dir: string, name: string): Promise<void> {
if (isDryRun) {
console.log(`DRY RUN bun publish --access public --tolerate-republish (${path.relative(repoRoot, leaf.dir)})`);
console.log(`DRY RUN bun pm pack && npm publish --access public (${path.relative(repoRoot, dir)})`);
return;
}
console.log(`Publishing ${leaf.manifest.name}…`);
const result = await $`bun publish --access public --tolerate-republish`.cwd(leaf.dir).quiet().nothrow();
const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim();
if (output) console.log(output);
if (result.exitCode !== 0) process.exit(result.exitCode ?? 1);
console.log(`Publishing ${name}…`);
const packDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-pack-"));
try {
const packed = await $`bun pm pack --quiet --destination ${packDir}`.cwd(dir).quiet().nothrow();
const packOutput = `${packed.stdout.toString()}${packed.stderr.toString()}`.trim();
if (packed.exitCode !== 0) {
if (packOutput) console.log(packOutput);
process.exit(packed.exitCode ?? 1);
}
const tarball = (await fs.readdir(packDir)).find(entry => entry.endsWith(".tgz"));
if (!tarball) throw new Error(`bun pm pack produced no tarball for ${name} (${path.relative(repoRoot, dir)})`);
const result = await $`npm publish ${path.join(packDir, tarball)} --access public`.quiet().nothrow();
const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim();
if (output) console.log(output);
if (result.exitCode !== 0) {
// Idempotent re-runs: tolerate this exact version already being on the
// registry (the `bun publish --tolerate-republish` equivalent), but
// surface every other failure.
if (isVersionAlreadyPublished(output)) {
console.log(`Skipping ${name} (version already published)`);
return;
}
process.exit(result.exitCode ?? 1);
}
} finally {
await fs.rm(packDir, { recursive: true, force: true });
}
}
/** Match npm's rejection when this exact version already exists on the registry. */
function isVersionAlreadyPublished(output: string): boolean {
return /cannot publish over the previously published version|EPUBLISHCONFLICT/i.test(output);
}
async function publishGeneratedLeafPackage(leaf: GeneratedLeafPackage): Promise<void> {
await packAndPublish(leaf.dir, leaf.manifest.name);
}
async function publishNativePackage(pkg: PublishPackage): Promise<void> {
@@ -176,14 +229,8 @@ async function publishNativePackage(pkg: PublishPackage): Promise<void> {
if (isDryRun) {
console.log(`DRY RUN native core manifest rewrite (${pkg.dir})`);
console.log(JSON.stringify({ optionalDependencies: manifest.optionalDependencies, files: manifest.files }, null, "\t"));
console.log(`DRY RUN bun publish --access public --tolerate-republish (${pkg.dir})`);
return;
}
console.log(`Publishing ${name}…`);
const result = await $`bun publish --access public --tolerate-republish`.cwd(pkgDir).quiet().nothrow();
const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim();
if (output) console.log(output);
if (result.exitCode !== 0) process.exit(result.exitCode ?? 1);
await packAndPublish(pkgDir, name);
}
async function publishPackage(pkg: PublishPackage): Promise<void> {
@@ -198,17 +245,11 @@ async function publishPackage(pkg: PublishPackage): Promise<void> {
console.log(`Skipping ${name} (private)`);
return;
}
if (isDryRun) {
console.log(`DRY RUN bun publish --access public --tolerate-republish (${pkg.dir})`);
return;
}
console.log(`Publishing ${name}…`);
const result = await $`bun publish --access public --tolerate-republish`.cwd(pkgDir).quiet().nothrow();
const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim();
if (output) console.log(output);
if (result.exitCode !== 0) process.exit(result.exitCode ?? 1);
await packAndPublish(pkgDir, name);
}
for (const pkg of packages) {
await publishPackage(pkg);
if (import.meta.main) {
for (const pkg of packages) {
await publishPackage(pkg);
}
}
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env bun
/**
* Configure npm trusted publishers (OIDC) for every package this repo ships.
*
* Trusted publishing lets the `release-npm` CI job publish with provenance and
* no long-lived token, but each package must be linked to this repo's workflow
* once — see https://docs.npmjs.com/trusted-publishers. The npm website makes
* you do this by hand, per package; this script drives `npm trust github` over
* the full published set (the same list `ci-release-publish.ts` uses, imported
* so the two never drift) in one pass.
*
* Run it locally, not in CI: `npm trust` is interactive (web 2FA) and a granular
* token with the "bypass 2FA" option is rejected by the registry. The first call
* prompts for two-factor auth; choose "skip 2FA for the next 5 minutes" on the
* npm site and the rest proceed unattended (npm docs: ~80 packages per window).
*
* Prerequisites:
* - npm >= 11.10.0 (`npm install -g npm@latest`)
* - `npm login` with a 2FA-enabled account that has publish access
* - the package must already exist on the registry — trusted publishing cannot
* create it, so brand-new packages show up as "not published yet" until the
* first token-based release creates them.
*
* Usage:
* bun scripts/setup-npm-trust.ts Configure trust for all packages
* bun scripts/setup-npm-trust.ts --list Show current config, change nothing
* bun scripts/setup-npm-trust.ts --dry-run Print the commands, change nothing
* bun scripts/setup-npm-trust.ts --force Replace any existing config (revoke + recreate)
* bun scripts/setup-npm-trust.ts --only a,b Limit to specific package names
* bun scripts/setup-npm-trust.ts --repo o/r Override the GitHub repo (default: from package.json)
* bun scripts/setup-npm-trust.ts --workflow f Override the workflow file (default: ci.yml)
*/
import * as path from "node:path";
import { $ } from "bun";
import { LEAF_TARGETS } from "../packages/natives/scripts/gen-npm-packages.ts";
import { packages } from "./ci-release-publish.ts";
const repoRoot = path.join(import.meta.dir, "..");
const MIN_NPM = "11.10.0";
const DEFAULT_WORKFLOW = "ci.yml";
const FALLBACK_REPO = "can1357/oh-my-pi";
interface Options {
list: boolean;
dryRun: boolean;
force: boolean;
repo?: string;
workflow: string;
only?: Set<string>;
}
function parseArgs(argv: readonly string[]): Options {
const opts: Options = { list: false, dryRun: false, force: false, workflow: DEFAULT_WORKFLOW };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "-h":
case "--help":
printUsageAndExit();
break;
case "--list":
opts.list = true;
break;
case "--dry-run":
opts.dryRun = true;
break;
case "--force":
opts.force = true;
break;
case "--repo":
opts.repo = argv[++i];
break;
case "--workflow":
case "--file":
opts.workflow = argv[++i];
break;
case "--only":
opts.only = new Set((argv[++i] ?? "").split(",").map(s => s.trim()).filter(Boolean));
break;
default:
console.error(`Unknown argument: ${arg}`);
printUsageAndExit(1);
}
}
return opts;
}
function printUsageAndExit(code = 0): never {
console.log(
[
"Usage: bun scripts/setup-npm-trust.ts [options]",
"",
" --list Show current trusted-publisher config, change nothing",
" --dry-run Print the npm trust commands, change nothing",
" --force Replace an existing config (revoke + recreate)",
" --only a,b,c Limit to the named packages",
" --repo owner/repo Override the GitHub repo (default: from package.json)",
" --workflow file Override the workflow filename (default: ci.yml)",
" -h, --help Show this help",
].join("\n"),
);
process.exit(code);
}
/** Parse `owner/repo` out of a package.json `repository` field. */
function parseRepo(repository: { url?: string } | string | undefined): string | null {
const url = typeof repository === "string" ? repository : repository?.url;
if (!url) return null;
const match = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/);
return match ? `${match[1]}/${match[2]}` : null;
}
interface ManifestShape {
name?: string;
private?: boolean;
repository?: { url?: string } | string;
}
/** The npm package names to configure, plus a repo slug inferred from a manifest. */
async function collectTargets(): Promise<{ names: string[]; repoFromManifest: string | null }> {
const seen = new Set<string>();
const names: string[] = [];
let repoFromManifest: string | null = null;
for (const pkg of packages) {
const manifest = (await Bun.file(path.join(repoRoot, pkg.dir, "package.json")).json()) as ManifestShape;
if (manifest.private) continue;
repoFromManifest ??= parseRepo(manifest.repository);
if (typeof manifest.name === "string" && !seen.has(manifest.name)) {
seen.add(manifest.name);
names.push(manifest.name);
}
// Native leaves are generated per platform at release time; each is its
// own published package and needs its own trusted-publisher link.
if (pkg.kind === "native") {
for (const target of LEAF_TARGETS) {
const leaf = `@oh-my-pi/pi-natives-${target.tag}`;
if (!seen.has(leaf)) {
seen.add(leaf);
names.push(leaf);
}
}
}
}
return { names, repoFromManifest };
}
/** Compare dotted version numbers; true when `version` >= `minimum`. */
function meetsMinimum(version: string, minimum: string): boolean {
const a = version.split(".").map(Number);
const b = minimum.split(".").map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff > 0;
}
return true;
}
/** Run npm with the terminal attached so the web 2FA flow stays interactive. */
function npmInteractive(args: readonly string[]): Promise<number> {
return Bun.spawn(["npm", ...args], { stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exited;
}
/**
* `npm trust list <pkg> --json`, capturing stdout while leaving stderr/stdin on
* the terminal so a 2FA challenge can still be answered. The registry allows one
* config per package, so a non-empty body means "already configured"; the `id`s
* it carries are what `npm trust revoke --id` needs.
*/
async function trustListJson(name: string): Promise<{ ok: boolean; hasConfig: boolean; ids: string[] }> {
const proc = Bun.spawn(["npm", "trust", "list", name, "--json"], {
stdin: "inherit",
stdout: "pipe",
stderr: "inherit",
});
const stdout = (await new Response(proc.stdout).text()).trim();
const code = await proc.exited;
return { ok: code === 0, hasConfig: code === 0 && stdout.length > 0, ids: extractIds(stdout) };
}
function extractIds(jsonish: string): string[] {
if (!jsonish) return [];
const ids: string[] = [];
try {
const parsed = JSON.parse(jsonish) as unknown;
const items = Array.isArray(parsed) ? parsed : [parsed];
for (const item of items) {
const id = (item as { id?: unknown }).id;
if (typeof id === "string") ids.push(id);
}
if (ids.length > 0) return ids;
} catch {
// npm prints one JSON object per config (not a single array) when several
// exist; fall back to scraping ids out of the concatenated output.
}
for (const match of jsonish.matchAll(/"id"\s*:\s*"([^"]+)"/g)) ids.push(match[1]);
return ids;
}
/** Does the package already exist on the registry? (non-interactive, no 2FA) */
async function packageExists(name: string): Promise<boolean> {
const result = await $`npm view ${name} version`.nothrow().quiet();
return result.exitCode === 0;
}
type Outcome = "configured" | "already" | "replaced" | "missing" | "failed";
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
const npmVersion = (await $`npm --version`.nothrow().quiet()).stdout.toString().trim();
if (!npmVersion) {
console.error("Could not determine npm version. Is npm installed and on PATH?");
process.exit(1);
}
if (!meetsMinimum(npmVersion, MIN_NPM)) {
console.error(`npm ${MIN_NPM}+ is required for trusted publishing (found ${npmVersion}).`);
console.error("Upgrade with: npm install -g npm@latest");
process.exit(1);
}
const { names, repoFromManifest } = await collectTargets();
let targets = names;
if (opts.only) {
const only = opts.only;
const unmatched = [...only].filter(n => !names.includes(n));
if (unmatched.length > 0) console.warn(`--only names not in the publish set: ${unmatched.join(", ")}`);
targets = names.filter(n => only.has(n));
}
if (targets.length === 0) {
console.error("No packages to process.");
process.exit(1);
}
const repo = opts.repo ?? repoFromManifest ?? FALLBACK_REPO;
const workflow = opts.workflow;
if (!(await Bun.file(path.join(repoRoot, ".github", "workflows", workflow)).exists())) {
console.warn(`Warning: .github/workflows/${workflow} not found; npm will still accept it, but OIDC won't match a non-existent workflow.`);
}
if (opts.dryRun) {
console.log(`Would configure trust for ${targets.length} package(s) → repo ${repo}, workflow ${workflow}:\n`);
for (const name of targets) {
console.log(` npm trust github ${name} --repo ${repo} --file ${workflow} --allow-publish --yes`);
}
return;
}
const whoami = await $`npm whoami`.nothrow().quiet();
if (whoami.exitCode !== 0) {
console.error("Not logged in to npm. Run `npm login` (with a 2FA-enabled account) first.");
process.exit(1);
}
console.log(`Logged in as ${whoami.stdout.toString().trim()} → repo ${repo}, workflow ${workflow}\n`);
if (opts.list) {
for (const name of targets) {
if (!(await packageExists(name))) {
console.log(`- ${name}: not published yet`);
continue;
}
console.log(`# ${name}`);
await npmInteractive(["trust", "list", name]);
}
return;
}
console.log("The first operation triggers 2FA. When prompted, complete it and choose");
console.log("'skip 2FA for the next 5 minutes' on the npm site so the rest run unattended.\n");
const outcomes = new Map<string, Outcome>();
let first = true;
for (const name of targets) {
if (!(await packageExists(name))) {
outcomes.set(name, "missing");
console.log(`- ${name}: not published yet — publish it first, then re-run.`);
continue;
}
// Throttle between mutating calls per npm's bulk-config guidance, but not
// before the very first one (it carries the interactive 2FA prompt).
if (!first) await Bun.sleep(2000);
first = false;
const existing = await trustListJson(name);
if (existing.hasConfig && !opts.force) {
outcomes.set(name, "already");
console.log(`- ${name}: already configured (use --force to replace).`);
continue;
}
let replaced = false;
if (existing.hasConfig && opts.force) {
let revokedAll = true;
for (const id of existing.ids) {
if ((await npmInteractive(["trust", "revoke", name, "--id", id])) !== 0) {
revokedAll = false;
break;
}
}
if (!revokedAll) {
outcomes.set(name, "failed");
console.error(`- ${name}: failed to revoke existing config.`);
continue;
}
replaced = true;
}
const code = await npmInteractive([
"trust",
"github",
name,
"--repo",
repo,
"--file",
workflow,
"--allow-publish",
"--yes",
]);
outcomes.set(name, code === 0 ? (replaced ? "replaced" : "configured") : "failed");
}
printSummary(outcomes);
const failed = [...outcomes.values()].filter(o => o === "failed").length;
process.exit(failed > 0 ? 1 : 0);
}
function printSummary(outcomes: ReadonlyMap<string, Outcome>): void {
const counts: Record<Outcome, number> = { configured: 0, already: 0, replaced: 0, missing: 0, failed: 0 };
for (const outcome of outcomes.values()) counts[outcome]++;
console.log("\nSummary:");
console.log(` configured: ${counts.configured}`);
if (counts.replaced) console.log(` replaced: ${counts.replaced}`);
console.log(` already: ${counts.already}`);
if (counts.missing) console.log(` missing: ${counts.missing} (not published yet)`);
if (counts.failed) console.log(` failed: ${counts.failed}`);
}
await main();