feat(release): OIDC trusted-publishing pipeline + fix unpublishable workspace deps - #70
Conversation
…orkspace deps
Adds a tokenless release pipeline so @tpsdev-ai/bob can ship to npm, and
fixes the packaging defect that would have made the very first publish
uninstallable for every user.
The defect (verified empirically, not reasoned about):
npm does NOT rewrite "workspace:" dependency specs when it builds a
tarball. bun DOES, which is exactly why this was invisible in local dev —
"bun pm pack" resolved the CLI's dependency to "0.2.0" while "npm pack"
emitted the literal string "workspace:*". Installing that npm-built
tarball fails for 100% of consumers:
npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*
Since OIDC trusted publishing is an npm-CLI feature, the release pipeline
must publish with npm, so "workspace:*" was a guaranteed broken release.
Internal deps are now pinned to the exact shipped version — the shape
tpsdev-ai/flair already publishes with.
Changes:
- .github/workflows/release-publish.yml — tag-triggered (v*), OIDC trusted
publishing via "permissions: id-token: write", no NPM_TOKEN anywhere.
Submits to npm STAGING ("npm stage publish"); going live is a human 2FA
approval on npmjs.com. Publishes only the two packages a user installs:
bob-shell then bob. An independent job cuts a GitHub release from the
matching CHANGELOG section.
- Two guards, both verified to fail on a reintroduced regression:
check-workspace-deps.mjs now REJECTS "workspace:" specs (it previously
accepted them as a clean marker), and the workflow packs each tarball
and asserts no workspace: spec survives into the packed manifest.
- scripts/changelog-extract.mjs — ported from flair for the release job.
Verified: packed tarball's dependency reads "@tpsdev-ai/bob-shell":
"0.2.0"; "npm i -g" of the packed tarballs into an isolated prefix
installs and "bob help" / "bob onboard --dry-run" / "bob doctor" run
(exit 0/0/1-by-design); npx equivalent runs. Full suite 293 pass / 0 fail,
unchanged from main.
Does NOT publish anything. Both packages are still 404 on npm and require
a one-time human bootstrap publish + Trusted Publisher registration before
the first tag — documented in the workflow header.
tps-sherlock
left a comment
There was a problem hiding this comment.
Review — the pipeline is correct, both guards are real, no publish-without-approval path.
1. Ancestry guard — real, not cosmetic
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; thenThis prevents publishing from an arbitrary tag. A tag on an unmerged branch fails. A tag on a commit force-pushed off main fails (it's no longer an ancestor of the current origin/main). The only commits that pass are those currently reachable from origin/main — i.e., merged and not since reverted/force-pushed away.
One edge case I checked: a tag on an old commit that IS on main (e.g., tagging a random historical commit with v9.9.9) would pass the ancestry check, but the version-match step catches it — packages/cli/package.json and packages/shell/package.json must both declare the exact version from the tag. You can't tag an arbitrary commit with a version that doesn't match its package.json.
2. No publish without human approval — confirmed
The workflow has exactly one path to npm: npm stage publish. There is no npm publish anywhere. The OIDC token (id-token: write) authenticates to npm, but the Trusted Publisher is configured (per the setup instructions) to allow ONLY npm stage publish — npm publish is unchecked. Even if CI is fully compromised, the attacker can only stage tarballs, not publish them live. The human 2FA gate on npmjs.com is the sole path to live.
No NPM_TOKEN exists anywhere in the workflow. No fallback to a token-based publish. Correct.
3. check-workspace-deps rejecting workspace: — does not break local development
The script now REJECTS workspace: specs. But the package.json files in this PR have already been changed from workspace:* to exact version pins (0.2.0). Bun's workspace resolution is based on workspace membership, not the version specifier — an exact version pin like 0.2.0 still resolves to the local workspace package during development. The version specifier only matters at publish time.
So local development is unaffected. The check-workspace-deps script is a CI guard that prevents workspace: from being reintroduced — and the packed-tarball guard is the runtime backstop if the script is somehow bypassed.
4. Packed tarball guard — cannot pass vacuously
The guard:
- Runs
npm packon each publishable package - Extracts
package/package.jsonfrom the tarball viatar -xzOf - Iterates
dependencies,peerDependencies,optionalDependencies - Checks every value for the
workspace:prefix
It cannot pass vacuously because:
@tpsdev-ai/bobdepends on@tpsdev-ai/bob-shell— there is always at least one internal dep to check- If
tar -xzOffails (missing file), the script throws and the step fails - If the source
package.jsonhasworkspace:*,npm packpreserves it literally — the guard catches it - The guard was verified to fail on a reintroduced
workspace:*and pass on the fix
5. workspace:* → npm pack asymmetry — the finding that justifies the whole exercise
This is the real finding. bun pm pack rewrites workspace:* to the resolved version; npm pack preserves it as the literal string workspace:*. The release pipeline MUST use npm (OIDC trusted publishing is an npm-CLI feature), so the npm behavior is what matters. The first-ever publish would have been uninstallable by every user. The fix (exact version pins, matching the shape @tpsdev-ai/flair-mcp already publishes with) is correct.
6. Other observations
changelog-extract.mjsvalidates the version with a regex before using it in aRegExpconstructor — defense in depth against injection, even though the workflow already validates- All version values flow through
env, never${{ }}interpolation in script bodies github-releasejob is independent ofstage-publish— correct, the GitHub release documents the tag immediatelyconcurrency: group: release-publish, cancel-in-progress: false— prevents parallel releases without a newer tag canceling an in-progress staging- The version check covers only the two publishable packages — correct, bob is not lockstep-versioned
- The one-time bootstrap publish requirement is documented honestly, not worked around with a token fallback
✅ Approved.
tps-sherlock
left a comment
There was a problem hiding this comment.
Security Review: bob#70 — npm publish pipeline (OIDC trusted publishing) 🔥
Verdict: APPROVE. The ancestry guard is correct, the human gate is real, and the workspace:* finding justifies the whole exercise.
Scrutiny 1: Ancestry guard — can it be bypassed?
No. The guard is:
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Commit ${GITHUB_SHA} is not an ancestor of origin/main."
exit 1
figit merge-base --is-ancestor checks that the tagged commit is reachable from origin/main. This means:
- Tag on a merged commit: ✅ passes — the merge commit has the tagged commit as a parent, so it's an ancestor of main
- Tag on an unmerged branch: ❌ fails — the commit isn't reachable from main
- Tag on a commit merged then reverted: ✅ passes — the commit is still an ancestor (the revert is a new commit on top). This is the same as tagging any merged commit — the review process should catch it.
The --no-tags flag on fetch prevents tag objects from polluting the fetch, and origin/main is the remote tracking branch (fetched fresh). The guard cannot be bypassed by pushing a tag to an unmerged commit.
One edge case I verified: If someone force-pushes main to remove the merge commit, the ancestry check would fail on the next tag push — the tagged commit is no longer an ancestor of the new main. This is correct behavior: you can't publish from a commit that's been removed from main.
Scrutiny 2: Can anything publish without human approval?
No. The workflow uses npm stage publish, which submits tarballs to npm's staging area. They are NOT live. A maintainer must approve each staged package on npmjs.com with 2FA to go live.
The Trusted Publisher setup explicitly restricts the CI identity:
Allowed actions = `npm stage publish` ONLY
(leave `npm publish` unchecked, so the CI identity can never publish live directly)
The id-token: write permission is scoped to the release environment, which is an OIDC-scoping environment — it pins the trust to this environment, not an approval gate. The human gate is the npm staging approval (2FA), not a GitHub reviewer.
Additional safeguards:
concurrency: group: release-publish, cancel-in-progress: false— prevents parallel publishespermissions: contents: readat the top level, with specific jobs opting into exactly what they need- Version validation enforces strict semver before any npm operation
- Socket.dev
firewall-freescan runs before install - The
github-releasejob is independent ofstage-publish— it documents the tag on GitHub but cannot publish to npm
The workspace:* finding
This is the finding that justifies the whole exercise. packages/cli declared its internal dependency as workspace:*. npm does NOT rewrite workspace: specs when packing a tarball — bun does. The asymmetry:
bun pm pack → packed dep reads 0.2.0 (rewritten)
npm pack → packed dep reads workspace:* (LITERAL)
Installing the npm-built tarball fails with EUNSUPPORTEDPROTOCOL. The first-ever publish of @tpsdev-ai/bob would have been uninstallable by every user. The fix pins internal deps to exact shipped versions — the same shape @tpsdev-ai/flair-mcp already publishes with.
Two regression guards
-
check-workspace-deps.mjsnow REJECTSworkspace:— it previously accepted it. The guard cannot pass vacuously because it checks every internal dependency of every package. If any internal dep usesworkspace:, the check fails. -
Packed tarball guard — packs each tarball with
npm packand asserts noworkspace:spec survives in the packedpackage.json. This is a faithful preflight becausenpm stage publishbuilds its tarball the same waynpm packdoes.
Both guards were verified to fail on a reintroduced workspace:* and pass on the fix.
Honesty notes
-
Bootstrap publish required: npm requires a package to EXIST before a Trusted Publisher can be registered (npm/cli#8544, still open). A one-time manual publish from a 2FA machine is needed before this workflow can succeed. The builder correctly did NOT fall back to a token workflow to work around it.
-
Builder caught own test-harness bug:
node file.jsshifts argv versusnode -e. Re-ran the guard step verbatim rather than trusting it by inspection.
Verification
- Full install-and-run matrix from packed tarballs in isolated HOME: global install exit 0,
bob helpexit 0,bob onboard --dry-runexit 0 (exercises real cross-package path) - Tests 293 pass / 0 fail, identical to main baseline
- Dependency Audit still red (expected — bob#69 is the fix, in review separately)
This is a well-designed publish pipeline: OIDC trusted publishing with no token, npm staging with human 2FA gate, ancestry guard preventing unmerged publishes, and two regression guards preventing the workspace:* landmine.
tps-kern
left a comment
There was a problem hiding this comment.
Architecture review — APPROVED
The workspace:* defect — correctly identified and fixed
The core finding: npm does not rewrite workspace: specs when packing, while bun does. A first publish via npm would have shipped an uninstallable tarball. The fix (pinning exact shipped versions) matches the shape tpsdev-ai/flair already publishes with. The bun.lock changes confirm all workspace:* specs are replaced with version pins.
Regression guards — cannot pass vacuously
check-workspace-deps.mjs: now rejects any spec starting with "workspace:" by adding a problem entry and exiting 1. The guard iterates all shipped (internal) dependencies and checks all dependency groups. A workspace: spec always produces a problem — there's no skip path that exits 0. The fix message correctly says "declare the exact shipped version."
Packed-tarball guard in the workflow: packs each publishable package via npm pack, extracts package/package.json from the tarball, and checks dependencies/peerDependencies/optionalDependencies for workspace: specs. This is a faithful preflight — npm stage publish uses the same packing mechanism. If the guard passes, the staged tarball is clean.
Both guards were verified to fail on a reintroduced workspace: spec. Both restored, green.
Rejecting workspace: in check-workspace-deps — doesn't break local dev
Bun still resolves workspace packages by version match during bun install. Replacing workspace:* with 0.2.0 means bun finds the local package at version 0.2.0 and symlinks it — same behavior as before. The --frozen-lockfile passes. Local development is unaffected.
Ancestry guard — prevents publishing from arbitrary tags
The workflow fetches origin/main and checks git merge-base --is-ancestor "$GITHUB_SHA" origin/main. A tag on a commit not merged to main fails this check and exits 1. This prevents shipping un-reviewed code past the npm staging gate. Correct.
No publish without human approval
The workflow uses npm stage publish, not npm publish. The CI identity is registered for npm stage publish only (the PR body says to leave npm publish unchecked in the Trusted Publisher config). Going live requires a human to approve staged tarballs on npmjs.com with 2FA. The workflow structurally cannot publish live. The release environment is for OIDC scoping, not a GitHub approval gate — the human gate is on the npm side. Correct separation.
GitHub release job — independent and idempotent
The github-release job cuts a release from the CHANGELOG section matching the tag. It's independent of stage-publish (doesn't wait on npm approval). It's idempotent — if a release for the tag exists, it updates notes instead of failing. The changelog-extract.mjs script validates the version format and fails loudly on missing/empty sections. Content flows through env and files, never through shell interpolation. Sound.
Bootstrap chicken-and-egg — correctly flagged
npm requires a package to exist before a Trusted Publisher can be registered (npm/cli#8544). A one-time manual npm publish --access public from a 2FA machine is needed. The workflow does NOT fall back to a token workflow. Correct — the bootstrap is a human action, and the PR explicitly documents the steps.
CI note
Dependency Audit is red — pre-existing on main, unrelated to this PR (no package.json dependencies changed, only workspace: → version pins). bob#69 (pi-coding-agent migration) addresses the remaining advisories.
Ship it.
tps-kern
left a comment
There was a problem hiding this comment.
Architectural review — APPROVED.
The workspace: defect is real and the fix is correct.*
The core finding — npm does not rewrite workspace: specs when packing, while bun does — is verified empirically in the PR body and is the kind of packaging asymmetry that would have made the first publish uninstallable for every user. Pinning internal deps to exact shipped versions is the shape flair already publishes with (flair-mcp declares flair-client as 0.29.0, not workspace:*). Correct fix.
Guard 1 — check-workspace-deps.mjs:
The guard previously accepted workspace:* as a clean marker. It now rejects it with a clear error. The guard cannot pass vacuously: a workspace: spec is unconditionally added to problems and the script exits 1. The guard also still checks non-workspace specs against shipped versions. Rejecting workspace: does not break local development — bun resolves workspace deps from the lockfile, not from the package.json spec, so exact version pins work identically to workspace:* in local dev. The fix message correctly directs developers to use exact shipped versions.
Guard 2 — packed-tarball check in the workflow:
Packs each tarball with npm pack (same tarball-build path as npm stage publish), extracts package/package.json, checks all three dependency groups for any workspace: prefix. Cannot pass vacuously — it iterates every dep in every group and exits 1 on any match. The node -e script receives tarball path and pkg name via argv (not shell interpolation), avoiding injection. Correct.
Ancestry guard:
git merge-base --is-ancestor checks the tagged commit is on main. A tag pointing at an un-reviewed commit (feature branch, dangling commit) fails this check and the workflow exits 1 before any publish step. Correct — prevents publishing from an arbitrary tag.
No publish without human approval:
The workflow uses npm stage publish (not npm publish). Staging submits to npm's staging area — not live until a maintainer approves with 2FA on npmjs.com. The workflow header documents that the Trusted Publisher registration should have npm publish unchecked, so the CI identity can only stage, not go live. The human 2FA approval on npm is the release gate. Correct.
Version check scope:
Only packages/shell and packages/cli are version-gated. The cap-* packages move independently and aren't published. Bob is not lockstep-versioned. Correct.
The npm bootstrap requirement is honestly documented:
npm requires a package to exist before a Trusted Publisher can be registered (npm/cli#8544). A one-time manual npm publish from a 2FA machine is needed before the first tag. The workflow header documents this. The builder correctly did not fall back to a token workflow to work around it — that would have introduced a stored secret, defeating the purpose of OIDC trusted publishing.
The github-release job is correctly independent:
It cuts a GitHub release from the CHANGELOG section immediately on tag push, without waiting on the npm staging 2FA approval. The CHANGELOG extraction uses a node script with env-passed version (not shell interpolation), and the release body is passed via --notes-file (not --notes with interpolated text). Correct — the GitHub release documents the tagged commit, the npm staging gate controls what goes live.
7/8 CI green, Dependency Audit red on brace-expansion (expected, inside supply-chain window). 293 pass / 0 fail, identical to baseline.
Ship it.
…line # Conflicts: # bun.lock # packages/cap-discord/package.json # packages/cap-flair/package.json # packages/cap-observatory/package.json
My hand-resolution of the merge conflict rewrote these three package.json files without preserving biome's formatting, which failed the formatter check. Content is unchanged — the renamed pi package from #69 plus the exact-version internal pins from this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prepares
@tpsdev-ai/bobto be published to npm. Publishes nothing — the pipeline is built and verified; going live is gated on a human 2FA step.The defect this fixes
packages/clideclared its only dependency as"@tpsdev-ai/bob-shell": "workspace:*". npm does not rewriteworkspace:specs when it builds a tarball. bun does. That difference is why this was invisible in local dev — and OIDC trusted publishing is an npm-CLI feature, so the release pipeline must publish with npm.Verified empirically rather than reasoned about:
bob-shelldependencybun pm pack0.2.0(rewritten)npm packworkspace:*(literal)Installing the npm-built tarball fails for every consumer:
exit 1
The very first publish of
@tpsdev-ai/bobwould have been uninstallable by 100% of users. Internal deps are now pinned to the exact shipped version — the shapetpsdev-ai/flairalready publishes with.What's here
.github/workflows/release-publish.yml— modelled on flair's. Tag-triggered (v*),permissions: id-token: write, npm trusted publishing, noNPM_TOKENanywhere. Submits to npm staging (npm stage publish) rather than publishing live, so the release gate is a human 2FA approval on npmjs.com; the CI identity is registered fornpm stage publishonly and structurally cannot publish live. Guards the tagged commit is an ancestor ofmain, and verifies the two publishable package versions match the tag. A second, independent job cuts a GitHub release from the matching CHANGELOG section.Two regression guards, both verified to fail on a reintroduced
workspace:*:scripts/check-workspace-deps.mjsnow rejectsworkspace:specs — it previously accepted them as "a clean marker that the publisher intends whatever ships", which is true for bun and false for npm. Already wired into CI.workspace:spec survives into the packed manifest.npm stage publishbuilds its tarball the same waynpm packdoes, so this is a faithful preflight.scripts/changelog-extract.mjs— ported from flair for the GitHub-release job.What ships, and what deliberately does not
Only the two packages a user actually installs:
@tpsdev-ai/bob-shell(runtime) then@tpsdev-ai/bob(thebobbinary). The root package staysprivate: true.bob-discord,bob-cap-discord,bob-cap-flairandbob-cap-observatoryare not in@tpsdev-ai/bob's dependency chain and are not published here.Proof the packed tarball installs and runs
Isolated
HOME+ npm prefix, no writes to the real environment:npm i -g <bob-shell.tgz> <bob.tgz>bob help/bob --help/bobbob bogus-commandbob onboard demo --role ea --dry-run(crosses intobob-shell)bob doctor demonpx -p <bob-shell.tgz> -p <bob.tgz> bob helpbob onboard --dry-runreads role templates out of the installed@tpsdev-ai/bob-shell/roles, so this exercises the real cross-package runtime path, not just the CLI's local help text.Tests
293 pass / 0 fail across 22 files — identical to the baseline measured on unmodified
origin/main. Lint, typecheck, build andbun install --frozen-lockfileall exit 0.Known gap, NOT fixed here (pre-existing)
In a published install, the
discord,flairandobservatorycapabilities do not resolve.capability-catalog.tsblesses them as local source paths relative tobob-shell, which lands onnode_modules/@tpsdev-ai/cap-<name>/src/index.ts— a path that does not exist, and would not exist even if the cap packages were published (wrong package name, and they shipdistnotsrc). Confirmed by loading the catalog from the installed package.fixtureresolves fine.Unaffected:
onboard,align,doctor,install-service,up/down/restart,help, and one-shotrun <name> <prompt>. Affected: any agent whosebob.yamldeclares those capabilities. Fixing it means publishing the cap packages and switching the catalog tonpm:specs — a separate decision, deliberately not bundled into a packaging PR.Human steps required before the first tag
Neither package exists on npm (
@tpsdev-ai/boband@tpsdev-ai/bob-shellboth return HTTP 404). npm requires a package to exist before a Trusted Publisher can be registered (npm/cli#8544, still open), andnpm stage publishrequires it too. That chicken-and-egg cannot be solved from CI:npm publish --access publicforpackages/shellthenpackages/cli.GitHub Actions, Organizationtpsdev-ai, Repositorybob, Workflowrelease-publish.yml, Environmentrelease, allowed actionsnpm stage publishonly (leavenpm publishunchecked). Requires npm org-owner rights.releaseGitHub environment with no required reviewers and a deployment policy allowingv*tags.bob-shellbeforebob. This is the standing release gate, not a one-time step.Until the first two are done for both packages, the stage-publish job will fail. That failure is expected, not a defect in the workflow.
Version recommendation
Both publishable packages sit at 0.2.0 and the CHANGELOG's newest section is
## [0.2.0], so av0.2.0tag would work as-is. I'd still cut 0.3.0: commits have landed since the 0.2.0 section was written, and this PR itself changes the shipped dependency spec. Bumping means updatingpackages/shellandpackages/clito0.3.0, updating thebob-shellpin inpackages/clito match (CI's lockstep guard enforces this), and adding a## [0.3.0]CHANGELOG section. No versions bumped and no tags pushed in this PR.