Skip to content

feat(release): OIDC trusted-publishing pipeline + fix unpublishable workspace deps - #70

Merged
tps-flint merged 3 commits into
mainfrom
feat/npm-publish-pipeline
Jul 27, 2026
Merged

feat(release): OIDC trusted-publishing pipeline + fix unpublishable workspace deps#70
tps-flint merged 3 commits into
mainfrom
feat/npm-publish-pipeline

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

Prepares @tpsdev-ai/bob to 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/cli declared its only dependency as "@tpsdev-ai/bob-shell": "workspace:*". npm does not rewrite workspace: 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:

packer packed bob-shell dependency
bun pm pack 0.2.0 (rewritten)
npm pack workspace:* (literal)

Installing the npm-built tarball fails for every consumer:

npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*

exit 1

The very first publish of @tpsdev-ai/bob would have been uninstallable by 100% of users. Internal deps are now pinned to the exact shipped version — the shape tpsdev-ai/flair already publishes with.

What's here

.github/workflows/release-publish.yml — modelled on flair's. Tag-triggered (v*), permissions: id-token: write, npm trusted publishing, no NPM_TOKEN anywhere. 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 for npm stage publish only and structurally cannot publish live. Guards the tagged commit is an ancestor of main, 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.mjs now rejects workspace: 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.
  • The workflow packs each tarball and asserts no workspace: spec survives into the packed manifest. npm stage publish builds its tarball the same way npm pack does, 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 (the bob binary). The root package stays private: true. bob-discord, bob-cap-discord, bob-cap-flair and bob-cap-observatory are 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:

step exit
npm i -g <bob-shell.tgz> <bob.tgz> 0
bob help / bob --help / bob 0
bob bogus-command 2 (by design)
bob onboard demo --role ea --dry-run (crosses into bob-shell) 0
bob doctor demo 1 (by design — no agent scaffolded)
npx -p <bob-shell.tgz> -p <bob.tgz> bob help 0

bob onboard --dry-run reads 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 and bun install --frozen-lockfile all exit 0.

Known gap, NOT fixed here (pre-existing)

In a published install, the discord, flair and observatory capabilities do not resolve. capability-catalog.ts blesses them as local source paths relative to bob-shell, which lands on node_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 ship dist not src). Confirmed by loading the catalog from the installed package. fixture resolves fine.

Unaffected: onboard, align, doctor, install-service, up/down/restart, help, and one-shot run <name> <prompt>. Affected: any agent whose bob.yaml declares those capabilities. Fixing it means publishing the cap packages and switching the catalog to npm: 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/bob and @tpsdev-ai/bob-shell both return HTTP 404). npm requires a package to exist before a Trusted Publisher can be registered (npm/cli#8544, still open), and npm stage publish requires it too. That chicken-and-egg cannot be solved from CI:

  • Bootstrap publish, per package, from a 2FA-logged-in machine: npm publish --access public for packages/shell then packages/cli.
  • Register the Trusted Publisher, per package, on npmjs.com → Settings → Trusted Publisher: Provider GitHub Actions, Organization tpsdev-ai, Repository bob, Workflow release-publish.yml, Environment release, allowed actions npm stage publish only (leave npm publish unchecked). Requires npm org-owner rights.
  • Create the release GitHub environment with no required reviewers and a deployment policy allowing v* tags.
  • Decide the release version (see below).
  • Approve the staged tarballs with 2FA on npmjs.com — approve bob-shell before bob. 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 a v0.2.0 tag 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 updating packages/shell and packages/cli to 0.3.0, updating the bob-shell pin in packages/cli to 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.

…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 tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; then

This 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 publishnpm 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:

  1. Runs npm pack on each publishable package
  2. Extracts package/package.json from the tarball via tar -xzOf
  3. Iterates dependencies, peerDependencies, optionalDependencies
  4. Checks every value for the workspace: prefix

It cannot pass vacuously because:

  • @tpsdev-ai/bob depends on @tpsdev-ai/bob-shell — there is always at least one internal dep to check
  • If tar -xzOf fails (missing file), the script throws and the step fails
  • If the source package.json has workspace:*, npm pack preserves 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.mjs validates the version with a regex before using it in a RegExp constructor — defense in depth against injection, even though the workflow already validates
  • All version values flow through env, never ${{ }} interpolation in script bodies
  • github-release job is independent of stage-publish — correct, the GitHub release documents the tag immediately
  • concurrency: 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 tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
fi

git 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 publishes
  • permissions: contents: read at the top level, with specific jobs opting into exactly what they need
  • Version validation enforces strict semver before any npm operation
  • Socket.dev firewall-free scan runs before install
  • The github-release job is independent of stage-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

  1. check-workspace-deps.mjs now REJECTS workspace: — it previously accepted it. The guard cannot pass vacuously because it checks every internal dependency of every package. If any internal dep uses workspace:, the check fails.

  2. Packed tarball guard — packs each tarball with npm pack and asserts no workspace: spec survives in the packed package.json. This is a faithful preflight because npm stage publish builds its tarball the same way npm pack does.

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.js shifts argv versus node -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 help exit 0, bob onboard --dry-run exit 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 tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

tps-flint and others added 2 commits July 26, 2026 20:34
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants