Skip to content

Publish Packages

Publish Packages #129

Workflow file for this run

name: Publish Packages
on:
workflow_dispatch:
inputs:
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 }}
release_version: ${{ steps.bump.outputs.release_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
- name: Install deps
run: pnpm install --frozen-lockfile
# Packages publish in lockstep. Build + test the whole workspace so every
# package's dist/ is fresh before `pnpm pack` rewrites workspace:* deps
# to concrete versions at pack time.
- name: Build workspace
run: pnpm -r run build
- name: Run tests
run: pnpm -r run test
- name: Resolve target packages
id: targets
run: |
# Dependency order (topological):
# persona-kit (leaf — consumed by everyone)
# runtime (→ persona-kit; consumed by deploy + mcp-workforce)
# delivery (→ runtime; consumed by external agents)
# workload-router (→ persona-kit; consumed by cli)
# deploy (→ persona-kit + runtime; consumed by cli)
# mcp-workforce (→ persona-kit + runtime)
# daytona-runner (no workspace deps)
# cli (→ persona-kit + workload-router + deploy)
# agentworkforce (→ cli — umbrella wrapper, must publish last)
# personas-core publishes via the separate publish-personas.yml workflow.
echo "packages=persona-kit runtime delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT"
# Lockstep baseline heal. The workspace publishes every package at the
# same version, so if any package's local version lags either its own
# npm `latest` or another workspace package, pull it up to the highest
# stable version across the whole set before the bump step runs. This
# absorbs two failure modes:
#
# 1. A previous publish run shipped @agentworkforce/*@X to npm but
# failed at the Tag + push step, so main did not receive the
# release commit or tags.
# 2. Packages drifted because older releases were allowed to publish
# only one package at a time.
#
# The downstream "Verify new versions are not yet published" step still
# catches the case where the post-bump version collides with an existing
# npm version.
- name: Heal local versions to lockstep baseline
run: |
set -euo pipefail
cat > /tmp/lockstep-heal.mjs << 'HEALEOF'
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const packages = process.argv.slice(2);
const cmp = (a, b) => {
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) return da - db;
}
return 0;
};
const isStable = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
const info = packages.map((pkg) => {
const json = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf8'));
let npmHighest = null;
try {
const raw = execSync(`npm view ${json.name} versions --json`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim() || '[]';
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed : [parsed];
const stable = arr.filter(isStable).sort(cmp);
if (stable.length) npmHighest = stable[stable.length - 1];
} catch {
// unpublished package: leave npmHighest null
}
return { pkg, name: json.name, local: json.version, npmHighest };
});
const candidates = info.flatMap((e) => [e.local, e.npmHighest]).filter(isStable);
if (candidates.length === 0) {
console.log('Lockstep baseline: (no stable versions yet, skipping heal)');
process.exit(0);
}
candidates.sort(cmp);
const baseline = candidates[candidates.length - 1];
console.log(`Lockstep baseline: ${baseline}`);
const heals = [];
for (const e of info) {
const remote = e.npmHighest ?? 'unpublished';
if (isStable(e.local) && cmp(e.local, baseline) < 0) {
heals.push(e);
console.log(` ${e.name}: local=${e.local} npm=${remote} - healing to ${baseline}`);
} else {
console.log(` ${e.name}: local=${e.local} npm=${remote} - OK`);
}
}
for (const e of heals) {
// --workspaces-update=false: with any npm `workspaces` config in scope,
// `npm version` otherwise tries to reify the root lockfile and dies on
// pnpm's workspace:* protocol (EUNSUPPORTEDPROTOCOL).
execSync(`npm version ${baseline} --no-git-tag-version --allow-same-version --workspaces-update=false`, {
cwd: `packages/${e.pkg}`,
stdio: 'inherit',
});
}
if (heals.length === 0) {
console.log('All packages at baseline - no heal needed.');
} else {
console.log(`Healed ${heals.length} package(s) up to ${baseline}.`);
}
HEALEOF
node /tmp/lockstep-heal.mjs ${{ steps.targets.outputs.packages }}
- name: Bump versions
id: bump
run: |
VERSIONS=""
RELEASE_VERSION=""
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
# --workspaces-update=false keeps `npm version` from reifying the root
# lockfile, which fails on pnpm's workspace:* protocol if an npm
# `workspaces` field ever appears in the root package.json.
if [ -n "$CUSTOM" ]; then
npm version "$CUSTOM" --no-git-tag-version --allow-same-version --workspaces-update=false
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" --workspaces-update=false
else
npm version "$BUMP" --no-git-tag-version --workspaces-update=false
fi
NEW=$(node -p "require('./package.json').version")
VERSIONS+=" $pkg:$NEW"
# `agentworkforce` is the umbrella; every package ships in lockstep
# at the same version, so the umbrella's stamp is the canonical
# release version anchored on the GitHub Release tag and on the
# root CHANGELOG promotion.
if [ "$pkg" = "agentworkforce" ]; then
RELEASE_VERSION="$NEW"
fi
popd > /dev/null
done
echo "versions=${VERSIONS# }" >> "$GITHUB_OUTPUT"
echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
# Belt-and-suspenders alongside the baseline heal above: even if the
# local and npm baselines are aligned, 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:
#
# 1. If `## [Unreleased]` contains hand-curated content, promote it
# verbatim into the new `## [x.y.z] - DATE` block, then reset
# `## [Unreleased]` to empty. This is the authoritative path —
# curated narrative beats anything inferred from commits.
# 2. Otherwise, fall back to inferring a block from `git log` since
# the last `<pkg>-v*` tag. Bucketing prefers Conventional Commits
# prefixes (feat:/fix:/refactor:) and falls back to imperative-verb
# inference. Unclassified commits land in `Changed` so nothing
# gets silently dropped.
# 3. Skips silently for prereleases (version contains `-`) and for
# first publishes where neither Unreleased nor a prior tag exists.
- 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 existing = existsSync(path) ? readFileSync(path, 'utf-8') : '';
if (existing.includes(`## [${newVersion}]`)) {
console.log(`${path} already has ${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));
// --- Step 1: extract any hand-curated [Unreleased] content. ---
// Slice from the header line to the next `## [` (or EOF) so the
// whole contiguous block is a single span we can reset.
function splitAtUnreleased(raw) {
const headerRe = /^## \[Unreleased\][^\n]*\n/m;
const headerMatch = raw.match(headerRe);
if (!headerMatch) return null;
const headerStart = headerMatch.index;
const afterHeader = headerStart + headerMatch[0].length;
const tail = raw.slice(afterHeader);
const nextMatch = tail.match(/^## \[/m);
const bodyEnd = nextMatch ? afterHeader + nextMatch.index : raw.length;
return {
before: raw.slice(0, headerStart),
headerLine: headerMatch[0],
body: raw.slice(afterHeader, bodyEnd),
after: raw.slice(bodyEnd),
};
}
const parts = splitAtUnreleased(existing);
const unreleasedBody = parts ? parts.body.trim() : '';
// --- Step 2: build the new version block body. ---
let newEntryBody = '';
if (unreleasedBody.length > 0) {
// Promote Unreleased verbatim. Curated text wins over commit log.
newEntryBody = unreleasedBody + '\n';
console.log(`${pkg}: promoting [Unreleased] content into ${newVersion}`);
} else if (!lastTag) {
console.log(`${pkg}: empty [Unreleased] and no prior stable tag, skipping`);
process.exit(0);
} else {
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}: empty [Unreleased] and 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) {
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';
if (type === 'docs') return 'docs';
return 'other';
}
const trimmed = subject.trim();
if (/^(add|implement|introduce|create|support|enable|expose|wire|allow)\b/i.test(trimmed)) return 'feat';
if (/^(fix|resolve|correct|patch|prevent|guard|stop)\b/i.test(trimmed)) return 'fix';
if (
/^(refactor|rename|extract|reorganize|restructure|simplify|move|split|consolidate|rewrite|replace)\b/i.test(trimmed) ||
/^(update|bump|upgrade|migrate|switch|tighten|loosen|tweak|adjust|improve|clarify|polish|cleanup|clean\s+up|harden)\b/i.test(trimmed)
) return 'changed';
if (/^(test|cover|verify)\b/i.test(trimmed)) return 'reliability';
if (/^(document|docs?\b|readme)\b/i.test(trimmed)) return 'docs';
return 'other';
};
const cats = { breaking: [], feat: [], fix: [], changed: [], reliability: [], deps: [], release: [], docs: [], 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, ...cats.other], false],
['Reliability', cats.reliability, false],
['Documentation', cats.docs, false],
['Dependencies', cats.deps, false],
];
const bodyLines = [];
let anyContent = false;
for (const [header, bucket, bold] of sections) {
if (bucket.length === 0) continue;
anyContent = true;
bodyLines.push(`### ${header}`, '');
for (const c of bucket) {
const title = bold ? `**${c.title}**` : c.title;
bodyLines.push(`- ${title}${c.pr ? ' ' + c.pr : ''}`);
}
bodyLines.push('');
}
if (!anyContent) {
bodyLines.push('### Released', '', `- v${newVersion}`, '');
}
newEntryBody = bodyLines.join('\n');
console.log(`${pkg}: inferred ${newVersion} body from git log`);
}
const newEntry = `## [${newVersion}] - ${today}\n\n${newEntryBody}`;
// --- Step 3: write the file with Unreleased reset + new block. ---
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 if (parts) {
// Reset Unreleased body to empty (single blank line after header)
// and insert the new version block directly after.
const rebuilt =
parts.before +
parts.headerLine +
'\n' +
newEntry +
(parts.after.startsWith('\n') ? '' : '\n') +
parts.after;
writeFileSync(path, rebuilt);
} else {
// No [Unreleased] section in existing file — insert before first
// versioned entry, or append if none.
const firstVer = existing.match(/\n## \[\d/);
if (firstVer && firstVer.index !== undefined) {
writeFileSync(
path,
existing.slice(0, firstVer.index + 1) + newEntry + '\n' + existing.slice(firstVer.index + 1),
);
} else {
writeFileSync(path, existing.trimEnd() + '\n\n' + newEntry + '\n');
}
}
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
# Root CHANGELOG.md gets the same Unreleased→[x.y.z] promotion the
# per-package files just got, anchored on the umbrella version (every
# package ships at the same version, so the umbrella is the canonical
# release stamp). No git-log fallback — the root file is a hand-
# curated cross-package narrative, so an empty [Unreleased] means
# "no narrative-worthy changes this release" and we leave the file
# alone rather than inventing bullets.
- name: Generate root changelog
if: ${{ github.event.inputs.version != 'none' || github.event.inputs.custom_version != '' }}
env:
RELEASE_VERSION: ${{ steps.bump.outputs.release_version }}
run: |
TODAY=$(date -u +%Y-%m-%d)
cat > /tmp/gen-root-changelog.mjs << 'GENEOF'
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const [,, newVersion, today] = process.argv;
const path = 'CHANGELOG.md';
if (!newVersion) {
console.log('root changelog: no release version supplied, skipping');
process.exit(0);
}
if (newVersion.includes('-')) {
console.log('root changelog: prerelease bump, skipping');
process.exit(0);
}
if (!existsSync(path)) {
console.log(`root changelog: ${path} not found, skipping`);
process.exit(0);
}
const existing = readFileSync(path, 'utf-8');
if (existing.includes(`## [${newVersion}]`)) {
console.log(`root changelog: already has ${newVersion}, skipping`);
process.exit(0);
}
// Same slicer as the per-package generator.
function splitAtUnreleased(raw) {
const headerRe = /^## \[Unreleased\][^\n]*\n/m;
const headerMatch = raw.match(headerRe);
if (!headerMatch) return null;
const headerStart = headerMatch.index;
const afterHeader = headerStart + headerMatch[0].length;
const tail = raw.slice(afterHeader);
const nextMatch = tail.match(/^## \[/m);
const bodyEnd = nextMatch ? afterHeader + nextMatch.index : raw.length;
return {
before: raw.slice(0, headerStart),
headerLine: headerMatch[0],
body: raw.slice(afterHeader, bodyEnd),
after: raw.slice(bodyEnd),
};
}
const parts = splitAtUnreleased(existing);
if (!parts) {
console.log('root changelog: no [Unreleased] header, skipping');
process.exit(0);
}
const body = parts.body.trim();
if (body.length === 0) {
console.log(`root changelog: [Unreleased] is empty, skipping ${newVersion} stamp`);
process.exit(0);
}
const newEntry = `## [${newVersion}] - ${today}\n\n${body}\n`;
const rebuilt =
parts.before +
parts.headerLine +
'\n' +
newEntry +
(parts.after.startsWith('\n') ? '' : '\n') +
parts.after;
writeFileSync(path, rebuilt);
console.log(`root changelog: promoted [Unreleased] into ${newVersion}`);
GENEOF
node /tmp/gen-root-changelog.mjs "$RELEASE_VERSION" "$TODAY"
- 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 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. Pinned
# to the npm 11 line: @latest broke 2026-07-13 when npm 12 dropped
# support for the runner's node 22.14 (EBADENGINE).
- name: Install npm 11
run: npm install -g npm@11
# 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 the `agentworkforce` tag for lockstep
# publishes. The release body lists every published package and inlines each
# one's CHANGELOG block, so the releases page has one item per version.
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@v6
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
env:
VERSIONS: ${{ needs.publish.outputs.versions }}
CANONICAL_PKG: ${{ steps.release.outputs.canonical_pkg }}
CANONICAL_VERSION: ${{ steps.release.outputs.version }}
RELEASE_VERSION: ${{ needs.publish.outputs.release_version }}
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;
// Falls back to the canonical package version when the bump step
// didn't publish an umbrella stamp (e.g. version: none re-runs).
const releaseVersion = process.env.RELEASE_VERSION || canonicalVersion;
// Mirrors the topological order used in the "Resolve target
// packages" step. Sorting release-notes entries by this order
// ensures missing packages don't all collapse to indexOf=-1.
const packageOrder = [
'persona-kit',
'runtime',
'delivery',
'workload-router',
'deploy',
'mcp-workforce',
'daytona-runner',
'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 rootNotes = releaseVersion ? extractChangelogBody('CHANGELOG.md', releaseVersion) : '';
const packageNotes = packageInfo
.map((entry) => ({
...entry,
notes: extractChangelogBody(`packages/${entry.pkg}/CHANGELOG.md`, entry.ver),
}))
.filter((entry) => entry.notes.length > 0);
if (rootNotes.length > 0) {
lines.push('', '## Release Notes', '', rootNotes);
}
if (packageNotes.length > 0) {
lines.push('', '## Package Changelogs', '');
for (const entry of packageNotes) {
lines.push(`### ${entry.npmName}`, '', nestChangelogHeadings(entry.notes), '');
}
}
if (rootNotes.length === 0 && packageNotes.length === 0) {
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
node /tmp/build-release-notes.mjs
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
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 }}