Publish Packages #129
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |