Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
npm-stage/
.bun/
.*.bun-build
*.log
Expand Down
43 changes: 43 additions & 0 deletions npm/agentforge.cjs
Original file line number Diff line number Diff line change
@@ -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-<platform>-<arch>`) 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-<platform>-<arch>` 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);
107 changes: 107 additions & 0 deletions scripts/publish-npm.mjs
Original file line number Diff line number Diff line change
@@ -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-<platform>-<arch>` 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
// (`<name>/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}`);