chore(release): publish packages from CI

This commit is contained in:
Armin Ronacher
2026-05-28 17:42:33 +02:00
parent 53ca936adb
commit ae50dec121
6 changed files with 193 additions and 47 deletions

View File

@@ -11,12 +11,13 @@ on:
required: true required: true
type: string type: string
permissions: permissions: {}
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
env: env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps: steps:
@@ -80,3 +81,50 @@ jobs:
pi-windows-x64.zip \ pi-windows-x64.zip \
pi-windows-arm64.zip \ pi-windows-arm64.zip \
--clobber --clobber
publish-npm:
runs-on: ubuntu-latest
needs: build
environment: npm-publish
permissions:
contents: read
id-token: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
- name: Verify release artifacts are committed
run: git diff --exit-code
- name: Publish npm packages
run: node scripts/publish.mjs

View File

@@ -143,36 +143,16 @@ Attribution:
``` ```
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
3. **Verify npm authentication**: run `npm whoami` before starting the release script. If it fails, stop and tell the user to run `npm login` manually first, then retry after they confirm `npm whoami` succeeds. 3. **Run the release script**:
4. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message:
```
Before the release publish step, read this carefully:
- `npm publish` uses WebAuthn 2FA.
- The safest flow is for you to run the publish command yourself, because you can see and open the npm authentication URL immediately.
- I will tell you the exact command to run.
- When npm prints an auth URL, cmd/ctrl-click it, log in in the browser, and select the "don't ask again for N minutes" option if available.
- This may happen more than once during publish.
- Do not rerun `npm run release:patch` or `npm run release:minor` after a failed publish; only rerun the publish command I give you.
Reply "ready" once you have read this and are ready to run the command locally.
```
Do not proceed to step 5 until the user explicitly confirms.
5. **Run the release script**:
```bash ```bash
npm run release:patch # fixes + additions npm run release:patch # fixes + additions
npm run release:minor # breaking changes npm run release:minor # breaking changes
``` ```
Do not pass a `timeout` to the bash tool for this call. If publish fails during the WebAuthn/OTP step after version bump, stop and tell the user to run `npm run publish` themselves from the repo root. Never rerun the version bump on your own. After the user reports publish success, continue with the post-publish steps. The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
6. **After publish succeeds**: 4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
- Add fresh `## [Unreleased]` sections to package changelogs.
- Commit with `Add [Unreleased] section for next cycle`. 5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
- Push `main` and the release tag.
## User Override ## User Override

View File

@@ -79,7 +79,7 @@ We treat npm dependency changes as reviewed code changes.
- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. - `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set.
- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap. - `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap.
- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users. - The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users.
- Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before publishing. - Release smoke tests use `npm run release:local` to build, pack, and create isolated npm and Bun installs outside the repo before tagging a release.
- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported. - Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported.
- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`. - CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`.
- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed. - Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.

View File

@@ -20,13 +20,13 @@
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", "version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", "version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only", "version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:set": "npm version -ws", "version:set": "npm version -ws",
"prepublishOnly": "npm run clean && npm run build && npm run check", "prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && npm publish -ws --access public", "publish": "npm run prepublishOnly && node scripts/publish.mjs",
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", "publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
"release:local": "node scripts/local-release.mjs", "release:local": "node scripts/local-release.mjs",
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs", "shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
"release:patch": "node scripts/release.mjs patch", "release:patch": "node scripts/release.mjs patch",

115
scripts/publish.mjs Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const packages = [
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
];
const dryRun = process.argv.includes("--dry-run");
const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run");
if (unknownArgs.length > 0) {
console.error(`Usage: node scripts/publish.mjs [--dry-run]`);
process.exit(1);
}
function commandForPlatform(command) {
return process.platform === "win32" ? `${command}.cmd` : command;
}
function run(command, args, options = {}) {
console.log(`$ ${[command, ...args].join(" ")}`);
const result = spawnSync(commandForPlatform(command), args, {
cwd: options.cwd,
encoding: "utf8",
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
}
return result;
}
function readPackageJson(directory) {
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
}
function assertBuildOutputExists(directory) {
if (!existsSync(join(directory, "dist"))) {
throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`);
}
}
function validatePack(directory) {
const result = run("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { capture: true, cwd: directory });
const packed = JSON.parse(result.stdout)[0];
console.log(` ${packed.filename}: ${packed.files.length} files, ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked`);
}
function isPublished(name, version) {
const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], {
encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"],
});
if (result.status === 0 && result.stdout.trim()) {
return true;
}
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) {
return false;
}
throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`);
}
const packageVersions = new Map();
for (const pkg of packages) {
const packageJson = readPackageJson(pkg.directory);
if (packageJson.name !== pkg.name) {
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
}
packageVersions.set(pkg.name, packageJson.version);
}
const versions = [...new Set(packageVersions.values())];
if (versions.length !== 1) {
throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`);
}
console.log(`Publishing pi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`);
for (const pkg of packages) {
const version = packageVersions.get(pkg.name);
assertBuildOutputExists(pkg.directory);
const published = isPublished(pkg.name, version);
if (dryRun) {
if (published) {
console.log(`${pkg.name}@${version} is already published; validating package contents only.`);
} else {
console.log(`${pkg.name}@${version} is not published; validating package contents before publish.`);
}
validatePack(pkg.directory);
console.log();
continue;
}
if (published) {
console.log(`Skipping ${pkg.name}@${version}: already published\n`);
continue;
}
run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory });
console.log();
}

View File

@@ -10,11 +10,12 @@
* 1. Check for uncommitted changes * 1. Check for uncommitted changes
* 2. Bump version via npm run version:xxx or set an explicit version * 2. Bump version via npm run version:xxx or set an explicit version
* 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date
* 4. Generate the coding-agent npm-shrinkwrap.json * 4. Regenerate release artifacts
* 5. Commit and tag * 5. Run checks
* 6. Publish to npm * 6. Commit and tag the release
* 7. Add new [Unreleased] section to changelogs * 7. Add new [Unreleased] section to changelogs
* 8. Commit * 8. Commit next-cycle changelog updates
* 9. Push main and the tag to trigger CI publishing
*/ */
import { execSync } from "child_process"; import { execSync } from "child_process";
@@ -91,7 +92,7 @@ function bumpOrSetVersion(target) {
} }
console.log(`Setting explicit version (${target})...`); console.log(`Setting explicit version (${target})...`);
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only`); run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`);
return getVersion(); return getVersion();
} }
@@ -163,23 +164,25 @@ console.log("Updating CHANGELOG.md files...");
updateChangelogsForRelease(version); updateChangelogsForRelease(version);
console.log(); console.log();
// 4. Generate publish shrinkwrap // 4. Regenerate release artifacts
console.log("Generating coding-agent shrinkwrap..."); console.log("Regenerating release artifacts...");
run("npm --prefix packages/ai run generate-models");
run("npm --prefix packages/ai run generate-image-models");
run("npm run shrinkwrap:coding-agent"); run("npm run shrinkwrap:coding-agent");
console.log(); console.log();
// 5. Commit and tag // 5. Run checks
console.log("Running checks...");
run("npm run check");
console.log();
// 6. Commit and tag
console.log("Committing and tagging..."); console.log("Committing and tagging...");
stageChangedFiles(); stageChangedFiles();
run(`git commit -m "Release v${version}"`); run(`git commit -m "Release v${version}"`);
run(`git tag v${version}`); run(`git tag v${version}`);
console.log(); console.log();
// 6. Publish
console.log("Publishing to npm...");
run("npm run publish");
console.log();
// 7. Add new [Unreleased] sections // 7. Add new [Unreleased] sections
console.log("Adding [Unreleased] sections for next cycle..."); console.log("Adding [Unreleased] sections for next cycle...");
addUnreleasedSection(); addUnreleasedSection();
@@ -197,4 +200,4 @@ run("git push origin main");
run(`git push origin v${version}`); run(`git push origin v${version}`);
console.log(); console.log();
console.log(`=== Released v${version} ===`); console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`);