diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da11bc6..181188b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,15 @@ on: # issues/pull-requests: write lets @semantic-release/github comment on the # issues and PRs each release closes; contents: write covers the tag, the # release, and the changelog/version commit pushed back to main. +# +# packages: write lets the npm job publish to the GitHub Packages registry with +# the run's own GITHUB_TOKEN, so there is no long-lived npm credential to store +# or rotate. permissions: contents: write issues: write pull-requests: write + packages: write jobs: # semantic-release cuts a release directly from every releasable push to main @@ -131,3 +136,36 @@ jobs: files: | dist/agentforge-* dist/SHA256SUMS + + # The npm packages are a second distribution channel, not a replacement: the + # release assets above stay the anonymous path, because the GitHub Packages + # npm registry requires a token even for a public package. This job serves the + # repos that already have one. + # + # It runs after `publish` rather than beside it so a failure here never leaves + # a tagged release with no downloadable binary — the assets are attached + # first, and npm is the part allowed to fail late. + publish-npm: + needs: [release, publish] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release.outputs.tag_name }} + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + registry-url: https://npm.pkg.github.com + scope: '@jdh313' + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Publish npm packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AGENTFORGE_VERSION: ${{ needs.release.outputs.tag_name }} + run: node scripts/publish-npm.mjs dist diff --git a/.gitignore b/.gitignore index 70a0dec..25dd9e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +npm-stage/ .bun/ .*.bun-build *.log diff --git a/npm/agentforge.cjs b/npm/agentforge.cjs new file mode 100755 index 0000000..f437243 --- /dev/null +++ b/npm/agentforge.cjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Wrapper entry point for the `@jdh313/agentforge` npm package. +// +// The package itself carries no binary. Each platform's binary ships in its own +// package (`@jdh313/agentforge--`) declared as an +// optionalDependency and guarded by `os`/`cpu`, so a package manager installs +// exactly the one matching the host and skips the rest. This is the shape +// @biomejs/biome uses; the alternative — a postinstall script that downloads +// the right binary — breaks in hardened CI where install scripts are disabled, +// and offline. +// +// The platform package name is computed rather than looked up in a table: the +// release matrix names every asset `agentforge--` using Node's +// own spellings, so a table would be a second copy of that fact that can drift. +const { spawnSync } = require('node:child_process'); + +const target = `${process.platform}-${process.arch}`; +const packageName = `@jdh313/agentforge-${target}`; + +let binary; +try { + binary = require.resolve(`${packageName}/agentforge`); +} catch { + // An unsupported host and a broken install look identical from here, so say + // both: the release page is the answer to the first, reinstalling to the + // second. + console.error( + `agentforge: no binary for ${target}.\n` + + `Expected the optional dependency ${packageName} to be installed.\n` + + 'If this platform is unsupported, download a binary from\n' + + 'https://github.com/jdh313/agentforge/releases and put it on PATH.', + ); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' }); +// A signalled child has a null status; reporting 0 there would tell a CI job +// the run succeeded. +if (result.error) { + console.error(`agentforge: failed to execute ${binary}: ${result.error.message}`); + process.exit(1); +} +process.exit(result.status === null ? 1 : result.status); diff --git a/scripts/publish-npm.mjs b/scripts/publish-npm.mjs new file mode 100644 index 0000000..e1126f8 --- /dev/null +++ b/scripts/publish-npm.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Publishes the compiled binaries to the GitHub Packages npm registry as one +// wrapper package plus one package per platform. +// +// Ordering is load-bearing: platform packages publish FIRST, wrapper LAST. The +// wrapper's optionalDependencies pin exact platform versions, so a wrapper that +// lands before its platforms is a version consumers can install and cannot run. +// Publishing in this order means a mid-run failure leaves orphan platform +// packages — inert, since nothing references them — rather than a broken entry +// point. +// +// The platform list is derived from the built assets rather than declared here: +// `release.yml`'s matrix names each asset `agentforge--` using +// Node's own `process.platform` / `process.arch` spellings, which is also what +// the wrapper shim computes at runtime. One source, three readers. +import { chmodSync, copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const SCOPE = '@jdh313'; +const REGISTRY = 'https://npm.pkg.github.com'; +const REPOSITORY = 'https://github.com/jdh313/agentforge'; + +const version = process.env.AGENTFORGE_VERSION?.replace(/^v/, ''); +if (!version) throw new Error('AGENTFORGE_VERSION is required (e.g. v0.3.0)'); + +const distDir = resolve(process.argv[2] ?? 'dist'); +const stageDir = resolve('npm-stage'); +const dryRun = process.env.DRY_RUN === '1'; + +const assets = readdirSync(distDir) + .filter((name) => name.startsWith('agentforge-') && !name.endsWith('SHA256SUMS')) + .sort(); +if (assets.length === 0) throw new Error(`no agentforge-* binaries found in ${distDir}`); + +const common = { + version, + license: 'Apache-2.0', + author: { name: 'Jacob Hoehler' }, + repository: { type: 'git', url: `git+${REPOSITORY}.git` }, + homepage: REPOSITORY, + publishConfig: { registry: `${REGISTRY}/` }, +}; + +function publish(directory) { + const args = dryRun ? ['publish', '--dry-run'] : ['publish']; + execFileSync('npm', args, { cwd: directory, stdio: 'inherit' }); +} + +// --- platform packages, first --- +const optionalDependencies = {}; +for (const asset of assets) { + const target = asset.replace(/^agentforge-/, ''); + const [platform, arch] = target.split('-'); + if (!platform || !arch) throw new Error(`cannot derive platform/arch from ${asset}`); + + const name = `${SCOPE}/agentforge-${target}`; + const directory = join(stageDir, `agentforge-${target}`); + mkdirSync(directory, { recursive: true }); + copyFileSync(join(distDir, asset), join(directory, 'agentforge')); + chmodSync(join(directory, 'agentforge'), 0o755); + writeFileSync( + join(directory, 'package.json'), + `${JSON.stringify( + { + name, + ...common, + description: `agentforge binary for ${platform} ${arch}`, + os: [platform], + cpu: [arch], + // No "exports": the wrapper resolves the binary by subpath + // (`/agentforge`), which an exports map would forbid. + files: ['agentforge'], + }, + null, + 2, + )}\n`, + ); + optionalDependencies[name] = version; + console.log(`publishing ${name}@${version}`); + publish(directory); +} + +// --- wrapper, last --- +const wrapperDir = join(stageDir, 'agentforge'); +mkdirSync(join(wrapperDir, 'bin'), { recursive: true }); +copyFileSync(resolve('npm/agentforge.cjs'), join(wrapperDir, 'bin', 'agentforge.cjs')); +chmodSync(join(wrapperDir, 'bin', 'agentforge.cjs'), 0o755); +copyFileSync(resolve('README.md'), join(wrapperDir, 'README.md')); +writeFileSync( + join(wrapperDir, 'package.json'), + `${JSON.stringify( + { + name: `${SCOPE}/agentforge`, + ...common, + description: 'Render canonical AI agent artifacts for multiple harnesses', + bin: { agentforge: 'bin/agentforge.cjs' }, + files: ['bin', 'README.md'], + optionalDependencies, + }, + null, + 2, + )}\n`, +); +console.log(`publishing ${SCOPE}/agentforge@${version}`); +publish(wrapperDir); +console.log(`published ${assets.length + 1} packages at ${version}`);