diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ccc4eab84..9190ec221 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -11,12 +11,13 @@ on: required: true type: string -permissions: - contents: write +permissions: {} jobs: build: runs-on: ubuntu-latest + permissions: + contents: write env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} steps: @@ -80,3 +81,50 @@ jobs: pi-windows-x64.zip \ pi-windows-arm64.zip \ --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 diff --git a/AGENTS.md b/AGENTS.md index 8662a98da..6686b5e1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. -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. - -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**: +3. **Run the release script**: ```bash npm run release:patch # fixes + additions 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**: - - Add fresh `## [Unreleased]` sections to package changelogs. - - Commit with `Add [Unreleased] section for next cycle`. - - Push `main` and the release tag. +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. + +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. ## User Override diff --git a/README.md b/README.md index b9a45021a..92ad3d7bb 100644 --- a/README.md +++ b/README.md @@ -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. - `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. -- 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. - 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. diff --git a/package.json b/package.json index 7a62003d7..072c908af 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,13 @@ "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "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:minor": "npm version minor -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", + "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 --ignore-scripts", + "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", "prepublishOnly": "npm run clean && npm run build && npm run check", - "publish": "npm run prepublishOnly && npm publish -ws --access public", - "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "publish": "npm run prepublishOnly && node scripts/publish.mjs", + "publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run", "release:local": "node scripts/local-release.mjs", "shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs", "release:patch": "node scripts/release.mjs patch", diff --git a/scripts/publish.mjs b/scripts/publish.mjs new file mode 100644 index 000000000..513ab36f5 --- /dev/null +++ b/scripts/publish.mjs @@ -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(); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 59b515095..c3ff6c7a8 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,11 +10,12 @@ * 1. Check for uncommitted changes * 2. Bump version via npm run version:xxx or set an explicit version * 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date - * 4. Generate the coding-agent npm-shrinkwrap.json - * 5. Commit and tag - * 6. Publish to npm + * 4. Regenerate release artifacts + * 5. Run checks + * 6. Commit and tag the release * 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"; @@ -91,7 +92,7 @@ function bumpOrSetVersion(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(); } @@ -163,23 +164,25 @@ console.log("Updating CHANGELOG.md files..."); updateChangelogsForRelease(version); console.log(); -// 4. Generate publish shrinkwrap -console.log("Generating coding-agent shrinkwrap..."); +// 4. Regenerate release artifacts +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"); 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..."); stageChangedFiles(); run(`git commit -m "Release v${version}"`); run(`git tag v${version}`); console.log(); -// 6. Publish -console.log("Publishing to npm..."); -run("npm run publish"); -console.log(); - // 7. Add new [Unreleased] sections console.log("Adding [Unreleased] sections for next cycle..."); addUnreleasedSection(); @@ -197,4 +200,4 @@ run("git push origin main"); run(`git push origin v${version}`); console.log(); -console.log(`=== Released v${version} ===`); +console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`);