1- name : Publish Package
1+ name : Publish Packages
22
33on :
44 workflow_dispatch :
55 inputs :
6- package :
7- description : ' Which package(s) to publish'
8- required : true
9- default : cli
10- type : choice
11- options :
12- - all
13- - workload-router
14- - harness-kit
15- - cli
16- - agentworkforce
176 version :
187 description : ' Version bump type (ignored if custom_version is set)'
198 required : true
@@ -66,15 +55,15 @@ jobs:
6655 versions : ${{ steps.bump.outputs.versions }}
6756 steps :
6857 - name : Checkout
69- uses : actions/checkout@v4
58+ uses : actions/checkout@v6
7059 with :
7160 fetch-depth : 0
7261
7362 - name : Setup pnpm
74- uses : pnpm/action-setup@v4
63+ uses : pnpm/action-setup@v5
7564
7665 - name : Setup Node
77- uses : actions/setup-node@v4
66+ uses : actions/setup-node@v6
7867 with :
7968 node-version : ' 22.14.0'
8069 registry-url : ' https://registry.npmjs.org'
@@ -83,89 +72,111 @@ jobs:
8372 - name : Install deps
8473 run : pnpm install --frozen-lockfile
8574
86- # Build + test everything regardless of which package is being released —
87- # the CLI depends on harness-kit which depends on workload-router via
88- # workspace:*. `pnpm publish` rewrites those specs to concrete versions
89- # at pack time, and we want every package's dist/ to be fresh when that
90- # happens.
75+ # Packages publish in lockstep. Build + test the whole workspace so every
76+ # package's dist/ is fresh before `pnpm pack` rewrites workspace:* deps
77+ # to concrete versions at pack time.
9178 - name : Build workspace
9279 run : pnpm -r run build
9380
9481 - name : Run tests
9582 run : pnpm -r run test
9683
97- - name : Resolve target packages (dep order)
84+ - name : Resolve target packages
9885 id : targets
9986 run : |
100- case "${{ github.event.inputs.package }}" in
101- all)
102- # Must be in dependency order: router → harness-kit → cli →
103- # agentworkforce (the wrapper depends on cli).
104- echo "packages=workload-router harness-kit cli agentworkforce" >> "$GITHUB_OUTPUT"
105- ;;
106- workload-router|harness-kit|cli|agentworkforce)
107- echo "packages=${{ github.event.inputs.package }}" >> "$GITHUB_OUTPUT"
108- ;;
109- *)
110- echo "Unknown package: ${{ github.event.inputs.package }}" >&2
111- exit 1
112- ;;
113- esac
114-
115- # Catches the failure mode that bit us on 2026-04-23: a previous
116- # publish run shipped @agentworkforce/*@0.3.0 to npm but failed at the
117- # final `git push origin HEAD --follow-tags` step (a concurrent PR
118- # merge made main non-fast-forwardable), so neither the *-v0.3.0 tags
119- # nor the chore(release) commit landed on main. A naive re-run would
120- # clone at 0.2.x, bump to 0.3.0 again, and hit npm's "cannot publish
121- # over previously published versions: 0.3.0."
87+ # Dependency order: workload-router → harness-kit → cli → agentworkforce.
88+ # The top-level `agentworkforce` wrapper depends on `@agentworkforce/cli`,
89+ # so it must publish last.
90+ echo "packages=workload-router harness-kit cli agentworkforce" >> "$GITHUB_OUTPUT"
91+
92+ # Lockstep baseline heal. The workspace publishes every package at the
93+ # same version, so if any package's local version lags either its own
94+ # npm `latest` or another workspace package, pull it up to the highest
95+ # stable version across the whole set before the bump step runs. This
96+ # absorbs two failure modes:
12297 #
123- # If npm's highest stable version is greater than what package.json
124- # has, abort here with a clear remediation message rather than
125- # letting the bump produce a doomed version downstream.
126- - name : Verify local versions are in sync with npm
98+ # 1. A previous publish run shipped @agentworkforce/*@X to npm but
99+ # failed at the Tag + push step, so main did not receive the
100+ # release commit or tags.
101+ # 2. Packages drifted because older releases were allowed to publish
102+ # only one package at a time.
103+ #
104+ # The downstream "Verify new versions are not yet published" step still
105+ # catches the case where the post-bump version collides with an existing
106+ # npm version.
107+ - name : Heal local versions to lockstep baseline
127108 run : |
128109 set -euo pipefail
129- for pkg in ${{ steps.targets.outputs.packages }}; do
130- NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name")
131- LOCAL=$(node -p "require('./packages/$pkg/package.json').version")
132- REMOTE=$(npm view "$NPM_NAME" versions --json 2>/dev/null \
133- | node -e '
134- const raw = require("fs").readFileSync(0, "utf8").trim() || "[]";
135- const parsed = JSON.parse(raw);
136- const arr = Array.isArray(parsed) ? parsed : [parsed];
137- const stable = arr.filter((v) => typeof v === "string" && !v.includes("-"));
138- stable.sort((a, b) => {
139- const pa = a.split(".").map(Number);
140- const pb = b.split(".").map(Number);
141- for (let i = 0; i < 3; i++) {
142- if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
143- }
144- return 0;
145- });
146- process.stdout.write(stable.length ? stable[stable.length - 1] : "");
147- ' || echo "")
148- if [ -z "$REMOTE" ]; then
149- echo "$NPM_NAME: no stable releases on npm yet — OK"
150- continue
151- fi
152- CMP=$(node -e '
153- const [a, b] = process.argv.slice(1);
154- const pa = a.split(".").map(Number);
155- const pb = b.split(".").map(Number);
156- for (let i = 0; i < 3; i++) {
157- const da = pa[i] || 0;
158- const db = pb[i] || 0;
159- if (da !== db) { console.log(da < db ? -1 : 1); process.exit(0); }
160- }
161- console.log(0);
162- ' "$LOCAL" "$REMOTE")
163- if [ "$CMP" = "-1" ]; then
164- 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.)"
165- exit 1
166- fi
167- echo "$NPM_NAME: local=$LOCAL, npm=$REMOTE — OK"
168- done
110+ cat > /tmp/lockstep-heal.mjs << 'HEALEOF'
111+ import { execSync } from 'node:child_process';
112+ import { readFileSync } from 'node:fs';
113+
114+ const packages = process.argv.slice(2);
115+ const cmp = (a, b) => {
116+ const pa = a.split('.').map(Number);
117+ const pb = b.split('.').map(Number);
118+ for (let i = 0; i < 3; i++) {
119+ const da = pa[i] || 0;
120+ const db = pb[i] || 0;
121+ if (da !== db) return da - db;
122+ }
123+ return 0;
124+ };
125+ const isStable = (v) => typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v);
126+
127+ const info = packages.map((pkg) => {
128+ const json = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf8'));
129+ let npmHighest = null;
130+ try {
131+ const raw = execSync(`npm view ${json.name} versions --json`, {
132+ encoding: 'utf8',
133+ stdio: ['ignore', 'pipe', 'ignore'],
134+ }).trim() || '[]';
135+ const parsed = JSON.parse(raw);
136+ const arr = Array.isArray(parsed) ? parsed : [parsed];
137+ const stable = arr.filter(isStable).sort(cmp);
138+ if (stable.length) npmHighest = stable[stable.length - 1];
139+ } catch {
140+ // unpublished package: leave npmHighest null
141+ }
142+ return { pkg, name: json.name, local: json.version, npmHighest };
143+ });
144+
145+ const candidates = info.flatMap((e) => [e.local, e.npmHighest]).filter(isStable);
146+ if (candidates.length === 0) {
147+ console.log('Lockstep baseline: (no stable versions yet, skipping heal)');
148+ process.exit(0);
149+ }
150+ candidates.sort(cmp);
151+ const baseline = candidates[candidates.length - 1];
152+ console.log(`Lockstep baseline: ${baseline}`);
153+
154+ const heals = [];
155+ for (const e of info) {
156+ const remote = e.npmHighest ?? 'unpublished';
157+ if (isStable(e.local) && cmp(e.local, baseline) < 0) {
158+ heals.push(e);
159+ console.log(` ${e.name}: local=${e.local} npm=${remote} - healing to ${baseline}`);
160+ } else {
161+ console.log(` ${e.name}: local=${e.local} npm=${remote} - OK`);
162+ }
163+ }
164+
165+ for (const e of heals) {
166+ execSync(`npm version ${baseline} --no-git-tag-version --allow-same-version`, {
167+ cwd: `packages/${e.pkg}`,
168+ stdio: 'inherit',
169+ });
170+ }
171+
172+ if (heals.length === 0) {
173+ console.log('All packages at baseline - no heal needed.');
174+ } else {
175+ console.log(`Healed ${heals.length} package(s) up to ${baseline}.`);
176+ }
177+ HEALEOF
178+
179+ node /tmp/lockstep-heal.mjs ${{ steps.targets.outputs.packages }}
169180
170181 - name : Bump versions
171182 id : bump
@@ -191,11 +202,11 @@ jobs:
191202 done
192203 echo "versions=${VERSIONS# }" >> "$GITHUB_OUTPUT"
193204
194- # Belt-and-suspenders alongside the parity check above: even if the
195- # local→npm baseline is in sync , the computed bump might still collide
196- # with an existing version (e.g. a one-off publish from another
197- # branch). Catch it before we waste a build + before npm rejects with
198- # a less specific error.
205+ # Belt-and-suspenders alongside the baseline heal above: even if the
206+ # local and npm baselines are aligned , the computed bump might still
207+ # collide with an existing version (e.g. a one-off publish from another
208+ # branch). Catch it before we waste a build + before npm rejects with a
209+ # less specific error.
199210 - name : Verify new versions are not yet published
200211 run : |
201212 set -euo pipefail
@@ -452,11 +463,9 @@ jobs:
452463
453464 # One GitHub Release per publish run. Per-package git tags are still pushed
454465 # above (so the next publish's changelog generator can find them), but the
455- # public GitHub Release is anchored to a single canonical tag — `agentworkforce`
456- # if the wrapper was bumped, otherwise the first package in the run. The
457- # release body lists every published package and inlines each one's
458- # CHANGELOG block, so the releases page has one item per version instead of
459- # 4 near-duplicate entries per `all` publish.
466+ # public GitHub Release is anchored to the `agentworkforce` tag for lockstep
467+ # publishes. The release body lists every published package and inlines each
468+ # one's CHANGELOG block, so the releases page has one item per version.
460469 create-release :
461470 name : Create GitHub Release
462471 needs : publish
@@ -500,7 +509,7 @@ jobs:
500509 fi
501510
502511 - name : Checkout
503- uses : actions/checkout@v4
512+ uses : actions/checkout@v6
504513 with :
505514 # Need the canonical tag that the publish job just pushed — it
506515 # points at the chore(release) commit with all bumped CHANGELOGs.
@@ -599,7 +608,7 @@ jobs:
599608 node /tmp/build-release-notes.mjs
600609
601610 - name : Create GitHub Release
602- uses : softprops/action-gh-release@v2
611+ uses : softprops/action-gh-release@v3
603612 with :
604613 tag_name : ${{ steps.release.outputs.tag_name }}
605614 name : ${{ steps.notes.outputs.release_name }}
0 commit comments