Skip to content

Publish Packages

Publish Packages #22

Workflow file for this run

name: Publish Package
on:
workflow_dispatch:
inputs:
package:
description: 'Which package(s) to publish'
required: true
default: cli
type: choice
options:
- all
- workload-router
- harness-kit
- cli
- agentworkforce
version:
description: 'Version bump type (ignored if custom_version is set)'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
- prepatch
- preminor
- premajor
- prerelease
- none
custom_version:
description: 'Exact version (e.g. 0.1.0). Overrides version type when set.'
required: false
prerelease_id:
description: 'Prerelease identifier for pre* bumps (e.g. "next", "beta")'
required: false
default: next
tag:
description: 'npm dist-tag'
required: true
default: latest
type: choice
options:
- latest
- next
- beta
- alpha
dry_run:
description: 'Dry run (no actual publish, no version commit, no git tag)'
required: true
default: false
type: boolean
permissions:
contents: write
id-token: write
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.bump.outputs.versions }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
- name: Install deps
run: pnpm install --frozen-lockfile
# Build + test everything regardless of which package is being released —
# the CLI depends on harness-kit which depends on workload-router via
# workspace:*. `pnpm publish` rewrites those specs to concrete versions
# at pack time, and we want every package's dist/ to be fresh when that
# happens.
- name: Build workspace
run: pnpm -r run build
- name: Run tests
run: pnpm -r run test
- name: Resolve target packages (dep order)
id: targets
run: |
case "${{ github.event.inputs.package }}" in
all)
# Must be in dependency order: router → harness-kit → cli →
# agentworkforce (the wrapper depends on cli).
echo "packages=workload-router harness-kit cli agentworkforce" >> "$GITHUB_OUTPUT"
;;
workload-router|harness-kit|cli|agentworkforce)
echo "packages=${{ github.event.inputs.package }}" >> "$GITHUB_OUTPUT"
;;
*)
echo "Unknown package: ${{ github.event.inputs.package }}" >&2
exit 1
;;
esac
# Catches the failure mode that bit us on 2026-04-23: a previous
# publish run shipped @agentworkforce/*@0.3.0 to npm but failed at the
# final `git push origin HEAD --follow-tags` step (a concurrent PR
# merge made main non-fast-forwardable), so neither the *-v0.3.0 tags
# nor the chore(release) commit landed on main. A naive re-run would
# clone at 0.2.x, bump to 0.3.0 again, and hit npm's "cannot publish
# over previously published versions: 0.3.0."
#
# If npm's highest stable version is greater than what package.json
# has, abort here with a clear remediation message rather than
# letting the bump produce a doomed version downstream.
- name: Verify local versions are in sync with npm
run: |
set -euo pipefail
for pkg in ${{ steps.targets.outputs.packages }}; do
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
LOCAL=$(node -p "require('./packages/$pkg/package.json').version")
REMOTE=$(npm view "$NPM_NAME" versions --json 2>/dev/null \
| node -e '
const raw = require("fs").readFileSync(0, "utf8").trim() || "[]";
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed : [parsed];
const stable = arr.filter((v) => typeof v === "string" && !v.includes("-"));
stable.sort((a, b) => {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
});
process.stdout.write(stable.length ? stable[stable.length - 1] : "");
' || echo "")
if [ -z "$REMOTE" ]; then
echo "$NPM_NAME: no stable releases on npm yet — OK"
continue
fi
CMP=$(node -e '
const [a, b] = process.argv.slice(1);
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
const da = pa[i] || 0;
const db = pb[i] || 0;
if (da !== db) { console.log(da < db ? -1 : 1); process.exit(0); }
}
console.log(0);
' "$LOCAL" "$REMOTE")
if [ "$CMP" = "-1" ]; then
echo "::error title=npm/git version drift::$NPM_NAME: package.json is at $LOCAL but npm has $REMOTE published. A previous publish run likely succeeded on npm but failed before tagging. Bump packages/$pkg/package.json to >= $REMOTE on a branch, push, then re-run this workflow. (You may also want to tag the prior release commit as $pkg-v$REMOTE so the changelog generator picks the right baseline.)"
exit 1
fi
echo "$NPM_NAME: local=$LOCAL, npm=$REMOTE — OK"
done
- name: Bump versions
id: bump
run: |
VERSIONS=""
CUSTOM='${{ github.event.inputs.custom_version }}'
BUMP='${{ github.event.inputs.version }}'
PREID='${{ github.event.inputs.prerelease_id }}'
for pkg in ${{ steps.targets.outputs.packages }}; do
pushd "packages/$pkg" > /dev/null
if [ -n "$CUSTOM" ]; then
npm version "$CUSTOM" --no-git-tag-version --allow-same-version
elif [ "$BUMP" = "none" ]; then
: # keep existing version (useful for first publish or re-publish)
elif [[ "$BUMP" == pre* ]]; then
npm version "$BUMP" --no-git-tag-version --preid="$PREID"
else
npm version "$BUMP" --no-git-tag-version
fi
NEW=$(node -p "require('./package.json').version")
VERSIONS+=" $pkg:$NEW"
popd > /dev/null
done
echo "versions=${VERSIONS# }" >> "$GITHUB_OUTPUT"
# Belt-and-suspenders alongside the parity check above: even if the
# local→npm baseline is in sync, the computed bump might still collide
# with an existing version (e.g. a one-off publish from another
# branch). Catch it before we waste a build + before npm rejects with
# a less specific error.
- name: Verify new versions are not yet published
run: |
set -euo pipefail
for entry in ${{ steps.bump.outputs.versions }}; do
pkg="${entry%%:*}"
ver="${entry##*:}"
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
EXISTS=$(npm view "$NPM_NAME@$ver" version 2>/dev/null || true)
if [ -n "$EXISTS" ]; then
echo "::error title=Version already published::$NPM_NAME@$ver is already on npm. Pick a different bump type or set custom_version to a higher version."
exit 1
fi
echo "$NPM_NAME@$ver: unpublished — OK"
done
# Per-package CHANGELOG.md generation. For each package being published,
# finds the last `<pkg>-v*` tag, collects Conventional Commits since then
# that touched `packages/<pkg>/**`, buckets them, and prepends a new
# versioned block. Skips silently for prereleases (version contains `-`)
# and for first publishes (no prior tag).
- name: Generate changelogs
if: ${{ github.event.inputs.version != 'none' || github.event.inputs.custom_version != '' }}
run: |
TODAY=$(date -u +%Y-%m-%d)
cat > /tmp/gen-changelog.mjs << 'GENEOF'
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const [,, pkg, newVersion, today] = process.argv;
const path = `packages/${pkg}/CHANGELOG.md`;
const npmName = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf-8')).name;
if (newVersion.includes('-')) {
console.log(`prerelease ${pkg}@${newVersion}: skipping`);
process.exit(0);
}
const tagPrefix = `${pkg}-v`;
const tags = execSync(`git tag -l '${tagPrefix}*' --sort=-v:refname`, { encoding: 'utf-8' })
.trim().split('\n').filter(Boolean);
const semverRe = new RegExp(`^${tagPrefix.replace(/\./g, '\\.')}\\d+\\.\\d+\\.\\d+$`);
const lastTag = tags.find(t => semverRe.test(t));
if (!lastTag) {
console.log(`${pkg}: no prior stable tag, skipping`);
process.exit(0);
}
const existing = existsSync(path) ? readFileSync(path, 'utf-8') : '';
if (existing.includes(`## [${newVersion}]`)) {
console.log(`${path} already has ${newVersion}, skipping`);
process.exit(0);
}
const log = execSync(
`git log ${lastTag}..HEAD --pretty=format:"%H|%s|%b%x00" --no-merges -- packages/${pkg}`,
{ encoding: 'utf-8' }
).trim();
if (!log) {
console.log(`${pkg}: no commits since ${lastTag}, skipping`);
process.exit(0);
}
const commits = log.split('\0').filter(Boolean).map(record => {
const idx = record.indexOf('|');
const idx2 = record.indexOf('|', idx + 1);
return {
subject: record.slice(idx + 1, idx2).trim(),
body: record.slice(idx2 + 1).trim(),
};
});
const extractPR = (subject, body) => {
const m = (subject + ' ' + body).match(/#(\d+)/);
return m ? `(#${m[1]})` : '';
};
const formatTitle = (subject) => {
const cleaned = subject
.replace(/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?!?:\s*/i, '')
.replace(/\s*\(#\d+\)\s*$/, '');
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
};
const getType = (subject) => {
const m = subject.match(/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?(!)?:/i);
if (!m) return 'other';
const type = m[1].toLowerCase();
const scope = (m[2] || '').replace(/[()]/g, '');
if (m[3] === '!') return 'breaking';
if (type === 'feat') return 'feat';
if (type === 'fix') return 'fix';
if (type === 'refactor' || type === 'perf' || type === 'build') return 'changed';
if (type === 'test' || type === 'ci') return 'reliability';
if (type === 'chore' && scope === 'release') return 'release';
if (type === 'chore') return 'deps';
return 'other';
};
const cats = { breaking: [], feat: [], fix: [], changed: [], reliability: [], deps: [], release: [], other: [] };
for (const c of commits) {
const type = getType(c.subject);
cats[type].push({ title: formatTitle(c.subject), pr: extractPR(c.subject, c.body) });
}
const sections = [
['Breaking Changes', cats.breaking, true],
['Added', cats.feat, true],
['Fixed', cats.fix, false],
['Changed', cats.changed, false],
['Reliability', cats.reliability, false],
['Dependencies', cats.deps, false],
];
const lines = [`## [${newVersion}] - ${today}`, ''];
let anyContent = false;
for (const [header, bucket, bold] of sections) {
if (bucket.length === 0) continue;
anyContent = true;
lines.push(`### ${header}`, '');
for (const c of bucket) {
const title = bold ? `**${c.title}**` : c.title;
lines.push(`- ${title}${c.pr ? ' ' + c.pr : ''}`);
}
lines.push('');
}
if (!anyContent) {
lines.push('### Released', '', `- v${newVersion}`, '');
}
const newEntry = lines.join('\n');
if (!existing) {
const header = `# Changelog\n\nAll notable changes to \`${npmName}\` will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n`;
writeFileSync(path, header + newEntry + '\n');
} else {
// Insert after the [Unreleased] block, before the first versioned entry.
const match = existing.match(/\n## \[\d/);
const insertAt = match ? match.index : -1;
if (insertAt === -1) {
writeFileSync(path, existing.trimEnd() + '\n\n' + newEntry + '\n');
} else {
writeFileSync(path, existing.slice(0, insertAt + 1) + newEntry + '\n' + existing.slice(insertAt + 1));
}
}
console.log(`${path} updated with ${newVersion}`);
GENEOF
for entry in ${{ steps.bump.outputs.versions }}; do
pkg="${entry%%:*}"
version="${entry##*:}"
node /tmp/gen-changelog.mjs "$pkg" "$version" "$TODAY"
done
- name: Commit version bumps
if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/*/package.json packages/*/CHANGELOG.md
if git diff --cached --quiet; then
echo "No version changes to commit."
else
MSG="chore(release):"
for entry in ${{ steps.bump.outputs.versions }}; do
pkg="${entry%%:*}"
version="${entry##*:}"
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
MSG+=" $NPM_NAME@$version"
done
git commit -m "$MSG"
fi
# npm >= 11.5.1 is required for the OIDC trusted-publisher flow.
- name: Install latest npm
run: npm install -g npm@latest
# Authentication note: this workflow does NOT use an NPM_TOKEN. It relies
# on npm's OIDC trusted-publisher flow — the `id-token: write` permission
# above lets `npm publish --provenance` exchange the GitHub workflow's
# OIDC identity for a short-lived publish token. Each package must be
# registered as a trusted publisher on npmjs.com under this
# repo/workflow path for the first publish.
#
# Pipeline: `pnpm pack` rewrites workspace:* deps to concrete versions
# inside the tarball's package.json, then `npm publish <tarball>`
# uploads it using npm's native auth. This decouples workspace-aware
# packing from publish-time auth and matches the agent-relay pattern.
- name: Pack + publish
run: |
set -euo pipefail
PACK_DIR="$RUNNER_TEMP/packs"
mkdir -p "$PACK_DIR"
COMMON_FLAGS="--access public --tag ${{ github.event.inputs.tag }}"
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
COMMON_FLAGS+=" --dry-run"
else
COMMON_FLAGS+=" --provenance"
fi
for pkg in ${{ steps.targets.outputs.packages }}; do
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
VERSION=$(node -p "require('./packages/$pkg/package.json').version")
# `pnpm pack` writes <name-without-@>-<ver>.tgz with `/` → `-`.
# @agentworkforce/cli@0.4.0 → agentworkforce-cli-0.4.0.tgz
# agentworkforce@0.4.0 → agentworkforce-0.4.0.tgz
TARBALL_BASENAME="$(echo "${NPM_NAME#@}" | tr '/' '-')-${VERSION}.tgz"
echo "==> Packing $NPM_NAME"
pnpm --filter "$NPM_NAME" pack --pack-destination "$PACK_DIR"
TARBALL="$PACK_DIR/$TARBALL_BASENAME"
if [ ! -f "$TARBALL" ]; then
echo "::error::could not find packed tarball $TARBALL_BASENAME in $PACK_DIR" >&2
ls -la "$PACK_DIR" >&2 || true
exit 1
fi
echo "==> Publishing $TARBALL $COMMON_FLAGS"
npm publish "$TARBALL" $COMMON_FLAGS
done
# Annotated tags (-a) so `git push --follow-tags` actually pushes them;
# lightweight tags are skipped by --follow-tags.
- name: Tag + push
if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }}
run: |
for entry in ${{ steps.bump.outputs.versions }}; do
pkg="${entry%%:*}"
version="${entry##*:}"
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
git tag -a "$pkg-v$version" -m "$NPM_NAME@$version"
done
git push origin HEAD --follow-tags
- name: Summary
run: |
{
echo "### Published"
echo ""
for entry in ${{ steps.bump.outputs.versions }}; do
pkg="${entry%%:*}"
version="${entry##*:}"
NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
echo "- \`$NPM_NAME@$version\`"
done
echo ""
echo "- **dist-tag**: \`${{ github.event.inputs.tag }}\`"
echo "- **dry run**: \`${{ github.event.inputs.dry_run }}\`"
echo ""
if [ "${{ github.event.inputs.dry_run }}" != "true" ]; then
echo "Next step: verify the published artifact by running the \`Verify Publish\` workflow."
fi
} >> "$GITHUB_STEP_SUMMARY"
# One GitHub Release per publish run. Per-package git tags are still pushed
# above (so the next publish's changelog generator can find them), but the
# public GitHub Release is anchored to a single canonical tag — `agentworkforce`
# if the wrapper was bumped, otherwise the first package in the run. The
# release body lists every published package and inlines each one's
# CHANGELOG block, so the releases page has one item per version instead of
# 4 near-duplicate entries per `all` publish.
create-release:
name: Create GitHub Release
needs: publish
if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Resolve canonical release
id: release
run: |
set -euo pipefail
VERSIONS='${{ needs.publish.outputs.versions }}'
canonical=""
for entry in $VERSIONS; do
pkg="${entry%%:*}"
if [ -z "$canonical" ]; then
canonical="$entry"
fi
if [ "$pkg" = "agentworkforce" ]; then
canonical="$entry"
break
fi
done
if [ -z "$canonical" ]; then
echo "::error title=Missing release target::publish job did not report any package versions"
exit 1
fi
pkg="${canonical%%:*}"
ver="${canonical##*:}"
echo "canonical_pkg=$pkg" >> "$GITHUB_OUTPUT"
echo "version=$ver" >> "$GITHUB_OUTPUT"
echo "tag_name=$pkg-v$ver" >> "$GITHUB_OUTPUT"
if [[ "$ver" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@v4
with:
# Need the canonical tag that the publish job just pushed — it
# points at the chore(release) commit with all bumped CHANGELOGs.
ref: ${{ steps.release.outputs.tag_name }}
- name: Build combined release notes
id: notes
run: |
cat > /tmp/build-release-notes.mjs << 'GENEOF'
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
const versionsRaw = process.env.VERSIONS || '';
const canonicalPkg = process.env.CANONICAL_PKG;
const canonicalVersion = process.env.CANONICAL_VERSION;
const packageOrder = ['workload-router', 'harness-kit', 'cli', 'agentworkforce'];
const entries = versionsRaw.trim().split(/\s+/).filter(Boolean).map((entry) => {
const idx = entry.indexOf(':');
return { pkg: entry.slice(0, idx), ver: entry.slice(idx + 1) };
}).sort((a, b) => packageOrder.indexOf(a.pkg) - packageOrder.indexOf(b.pkg));
if (entries.length === 0) {
throw new Error('publish job did not report any package versions');
}
const packageInfo = entries.map(({ pkg, ver }) => {
const pkgJson = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf8'));
return {
pkg,
ver,
npmName: pkgJson.name,
tag: `${pkg}-v${ver}`,
};
});
const canonical =
packageInfo.find((entry) => entry.pkg === canonicalPkg && entry.ver === canonicalVersion) ||
packageInfo[0];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractChangelogBody(path, version) {
if (!existsSync(path)) return '';
const raw = readFileSync(path, 'utf8');
const headerRe = new RegExp(`^## \\[${escapeRegExp(version)}\\][^\\n]*\\n`, 'm');
const header = raw.match(headerRe);
if (!header || header.index === undefined) return '';
const start = header.index + header[0].length;
const tail = raw.slice(start);
const next = tail.match(/^## \[/m);
const end = next && next.index !== undefined ? start + next.index : raw.length;
return raw.slice(start, end).trim();
}
// Per-package changelogs use ### headings; nest them one level deeper
// so they live under the ### <package> heading we add below.
function nestChangelogHeadings(notes) {
return notes.replace(/^(#{3,5}) /gm, '#$1 ');
}
const lines = ['## Packages', ''];
for (const entry of packageInfo) {
lines.push(`- \`${entry.npmName}@${entry.ver}\` (tag: \`${entry.tag}\`)`);
}
const packageNotes = packageInfo
.map((entry) => ({
...entry,
notes: extractChangelogBody(`packages/${entry.pkg}/CHANGELOG.md`, entry.ver),
}))
.filter((entry) => entry.notes.length > 0);
if (packageNotes.length > 0) {
lines.push('', '## Package Changelogs', '');
for (const entry of packageNotes) {
lines.push(`### ${entry.npmName}`, '', nestChangelogHeadings(entry.notes), '');
}
} else {
lines.push('', '## Release Notes', '', '_No changelog entries were generated for this release._');
}
writeFileSync('/tmp/release-notes.md', `${lines.join('\n').trimEnd()}\n`);
// Display name on the releases page. Always anchor to the canonical
// package so consumers see e.g. `agentworkforce@0.5.0` rather than
// a bare scoped package version.
const releaseName = `${canonical.npmName}@${canonical.ver}`;
appendFileSync(process.env.GITHUB_OUTPUT, `release_name=${releaseName}\n`);
GENEOF
VERSIONS='${{ needs.publish.outputs.versions }}' \
CANONICAL_PKG='${{ steps.release.outputs.canonical_pkg }}' \
CANONICAL_VERSION='${{ steps.release.outputs.version }}' \
node /tmp/build-release-notes.mjs
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release.outputs.tag_name }}
name: ${{ steps.notes.outputs.release_name }}
body_path: /tmp/release-notes.md
prerelease: ${{ steps.release.outputs.prerelease }}