Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,23 @@ jobs:
- name: Run tests
run: pnpm -r test

- name: Verify package tarballs
# npm is the packer that publishes, so it is the one release verification
# measures. This step used to inherit its packer from `npm_execpath`,
# which made the verdict depend on the invocation rather than on what
# ships — see verify-package-tarball.test.mjs.
- name: Verify package tarballs (npm — the release path)
run: pnpm run release:verify-packs

# pnpm rewrites `workspace:` to a concrete version at pack time, so it can
# turn an invalid private reference into a plausible-looking one. We do
# not publish with pnpm; this runs so that rewriting is proven incapable
# of disguising a defect the npm path would catch.
- name: Verify package tarballs (pnpm — diagnostic, must also be clean)
run: pnpm run release:verify-packs:pnpm

- name: Package tarball guard red tests
run: pnpm run release:verify-packs:test

# This repository owns @workspacejson/cli and agents-audit.
# @workspacejson/spec and @workspacejson/rules are consumed as released
# dependencies and are verified by workspacejson/standard, not here.
Expand Down
22 changes: 17 additions & 5 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,23 @@ jobs:

# npm rather than pnpm, for two reasons: pnpm 9 exposes no --provenance
# flag, and `pnpm publish` refuses to run on a detached tag checkout
# unless git checks are disabled wholesale. That substitution is only
# safe because @workspacejson/cli declares no `workspace:` dependencies,
# so there is nothing for pnpm to rewrite at pack time — and the step
# above asserts that invariant against the packed manifest, so this stays
# true by gate rather than by assumption.
# unless git checks are disabled wholesale.
#
# This substitution was previously justified by "@workspacejson/cli
# declares no `workspace:` dependencies, so there is nothing for pnpm to
# rewrite". That claim became FALSE under META-297, which added the
# private `@workspacejson/mining-core` as a devDependency, and it stayed
# false until the 0.6.0 publish failed on it at the gate above. The claim
# was also the wrong shape: it described a string, and pnpm's rewrite
# turns that string into a version that reads as legitimate.
#
# The invariant the gate now asserts is stronger and packer-independent:
#
# the npm-produced manifest is inspected directly, and may contain no
# reference to a private workspace package under ANY spelling.
#
# That is what makes publishing with npm safe here — not an assumption
# about which dependencies happen to be declared today.
#
# `prepublishOnly` runs again here. That repetition is deliberate: it
# keeps the gate attached to the package, so a local or future manual
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ consumed here as released packages.
> **Status.** The architecture below landed in META-247 and is the ratified
> target shape.
>
> **Registry snapshot, verified 2026-08-18:** the published versions are
> **Registry snapshot, verified 2026-08-19:** the published versions are
> `@workspacejson/cli@0.5.2` and `agents-audit@0.4.4`. The manifest table below
> declares `0.6.0` for the CLI, which is the authority-migration release
> prepared but not yet tagged; the registry does not carry it until
> `publish-cli.yml` runs on its `cli-v0.6.0` tag.
> declares `0.6.1`. `0.6.0` was tagged but never published — its publish run
> stopped at the tarball gate on a packaging defect (see the CLI changelog) —
> so the registry skips from `0.5.2` to `0.6.1`.
>
> The versions shown in the table below are the versions declared by this
> repository's package manifests. `pnpm run check:package-docs` keeps those
Expand All @@ -33,7 +33,7 @@ consumed here as released packages.

| Directory | Package | Version | Role |
| -- | -- | -- | -- |
| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | `0.6.0` | the neutral producer and its `workspacejson` binary |
| [`packages/cli/`](./packages/cli/) | `@workspacejson/cli` | `0.6.1` | the neutral producer and its `workspacejson` binary |
| [`packages/agents-audit-compat/`](./packages/agents-audit-compat/) | `agents-audit` | `0.4.4` | frozen compatibility bridge; preserves the historical command and API |
| [`packages/mining-core/`](./packages/mining-core/) | `@workspacejson/mining-core` | `0.0.0`, private | L0 commit-graph mining core — extraction, path identity, completeness semantics (META-297 Phases 1–2) |

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
"check:architecture": "node scripts/check-architecture.mjs",
"check:package-docs": "node scripts/check-package-docs.mjs",
"check:package-docs:test": "node scripts/check-package-docs.test.mjs",
"release:verify-packs": "pnpm --filter @workspacejson/cli exec node ../../scripts/verify-package-tarball.mjs && pnpm --filter agents-audit exec node ../../scripts/verify-package-tarball.mjs",
"release:verify-packs": "pnpm --filter @workspacejson/cli exec node ../../scripts/verify-package-tarball.mjs && WORKSPACEJSON_PACKER=pnpm pnpm --filter agents-audit exec node ../../scripts/verify-package-tarball.mjs",
"release:verify-packs:test": "node scripts/verify-package-tarball.test.mjs",
"release:verify-packs:pnpm": "WORKSPACEJSON_PACKER=pnpm pnpm run release:verify-packs",
"release:verify-published": "node scripts/verify-published.mjs"
},
"devDependencies": {
"@changesets/cli": "^2.31.0"
"@changesets/cli": "^2.31.0",
"@workspacejson/mining-core": "workspace:*"
}
}
57 changes: 57 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# Changelog — `@workspacejson/cli`

## 0.6.1

### Patch Changes

- Repair the release boundary so a private workspace package cannot reach the
published manifest. **`0.6.0` was tagged but never published**; this is the same
release with the packaging defect that stopped it corrected.

No mining, retrieval, provenance or artifact semantics change. The `0.5.0`
standard authority migration that `0.6.0` carried is unchanged and ships here.

**What stopped `0.6.0`.** `publish-cli.yml` failed at the tarball gate, two
steps before `npm publish`, with:

```
package.devDependencies.@workspacejson/mining-core leaks "workspace:*" into the packed manifest.
```

`@workspacejson/mining-core` is private and unpublished. The CLI declared it as
a `workspace:*` devDependency under META-297, which broke the invariant
`publish-cli.yml` relied on to publish with npm. The last release predates that
commit, so this was the first publish attempt since the invariant became false.
**It was not caused by the standard authority migration**, which only touched
`dependencies` and `version`.

**Why the gate had not caught it earlier.** The verifier chose its packer from
`npm_execpath`, so `pnpm run release:verify-packs` packed with pnpm while CI
packed with npm — the same commit verifying green locally and red in CI, with
the green run measuring bytes nobody publishes. The packer is now `npm`
unconditionally, because that is what `npm publish` ships. `WORKSPACEJSON_PACKER=pnpm`
remains as an explicit diagnostic mode.

**The invariant was also the wrong shape.** It tested for the literal
`workspace:` string, which is syntactic and packer-dependent:

```
npm pack → "@workspacejson/mining-core": "workspace:*" ← caught
pnpm pack → "@workspacejson/mining-core": "0.0.0" ← waved through
```

`0.0.0` is a dangling reference to a package that exists nowhere, wearing a
version that reads as legitimate. Switching packers would have published it with
a green gate. The invariant is now identity-based and packer-independent:

> a public package's packed manifest must not reference a private workspace
> package at all, under any spelling.

Private packages are discovered by name from the workspace, so the rule needs no
maintenance when one is added and cannot be evaded by a version rewrite.

**The build relationship is unchanged.** `mining-core` is still compiled into
`dist/` by tsup; only its declaration moved to the private root workspace, where
repository build infrastructure belongs. Because that removed the dependency
edge pnpm used to order `pnpm -r build`, the CLI's own build script now builds
its bundle input first — the guarantee travels with the package that needs it,
rather than depending on how the build was invoked.

## 0.6.0

### Minor Changes
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@workspacejson/cli",
"version": "0.6.0",
"version": "0.6.1",
"description": "The workspace.json producer — scans a repository and generates .agents/workspace.json deterministically, preserving human-authored manual evidence.",
"license": "Apache-2.0",
"author": "workspace.json contributors",
Expand Down Expand Up @@ -51,7 +51,7 @@
"CHANGELOG.md"
],
"scripts": {
"build": "tsup src/cli.ts src/index.ts --format esm --dts && node scripts/add-shebang.js",
"build": "pnpm --filter @workspacejson/mining-core build && tsup src/cli.ts src/index.ts --format esm --dts && node scripts/add-shebang.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
Expand All @@ -66,7 +66,6 @@
},
"devDependencies": {
"@types/node": "22.19.17",
"@workspacejson/mining-core": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 86 additions & 2 deletions scripts/verify-package-tarball.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,34 @@ const packageDirectory = process.cwd();
const sourceManifest = JSON.parse(readFileSync(join(packageDirectory, "package.json"), "utf8"));
const packageName = sourceManifest.name;
const expectedVersion = sourceManifest.version;
const packer = process.env.WORKSPACEJSON_PACKER
?? (process.env.npm_execpath?.includes("pnpm") ? "pnpm" : "npm");
// npm unconditionally, because `publish-cli.yml` publishes with `npm publish`.
// This default used to be inferred from `npm_execpath`, which made the gate's
// verdict depend on HOW it was invoked rather than on what ships: `pnpm run
// release:verify-packs` sets that variable and packed with pnpm, while CI's
// `pnpm --filter ... exec ...` did not and packed with npm. The same commit
// therefore verified green locally and red in CI, and the green one was
// measuring bytes nobody publishes. Release verification must measure the
// packer that actually produces the published artifact.
//
// WORKSPACEJSON_PACKER=pnpm remains available, and has exactly two legitimate
// uses. Neither is "the release default".
//
// 1. Diagnostic. The red tests use it to prove pnpm's `workspace:` rewriting
// cannot disguise an invalid reference — see verify-package-tarball.test.mjs.
//
// 2. Packages this repository does not publish. `agents-audit` is frozen and
// no workflow here publishes it (OWNERSHIP.md), and it depends on its
// sibling `@workspacejson/cli` by `workspace:*`. Verifying it with npm
// would assert an npm-publishability property it does not claim and that
// nothing here acts on. It is verified with pnpm instead, which is how CI
// has always packed it. The packer-independent private-package check below
// still applies to it in full; only the syntactic `workspace:` check, which
// is specifically about npm publishability, is satisfied by the rewrite.
//
// If META-243 ever makes this repository the publisher of `agents-audit`,
// that sibling reference becomes a real release defect and this override
// must go with the same reasoning that removed the CLI's.
const packer = process.env.WORKSPACEJSON_PACKER ?? "npm";

if (!["pnpm", "npm"].includes(packer)) {
throw new Error(`Unsupported packer ${JSON.stringify(packer)}; use pnpm or npm.`);
Expand Down Expand Up @@ -42,6 +68,10 @@ try {
// listings do not guarantee directory entries. Normalize once so every runtime
// asset assertion checks the archive's contents, not a packer formatting detail.
const files = new Set(tar("-tzf", tarballPath).trim().split("\n").filter(Boolean).map(normalizeArchivePath));
// Private-package check FIRST: it is the packer-independent invariant, so a
// private reference reports as what it actually is rather than as whichever
// spelling this packer happened to produce.
assertNoPrivateWorkspacePackages(manifest);
assertNoWorkspaceProtocol(manifest, "package");
assertStandardDependenciesArePinned(manifest);
assertRuntimeFiles(manifest, files);
Expand Down Expand Up @@ -73,6 +103,60 @@ function assertNoWorkspaceProtocol(value, path) {
}
}

// META-297 release-integrity boundary.
//
// `assertNoWorkspaceProtocol` above is SYNTACTIC and packer-dependent: it looks
// for the literal `workspace:` prefix. That is not the invariant that protects a
// consumer, as this repository learned by failing a release on it.
//
// `@workspacejson/mining-core` is private and unpublished, and the CLI declared
// it as a `workspace:*` devDependency. Packed with npm the string survives
// verbatim and the syntactic check catches it. Packed with pnpm the SAME broken
// reference is rewritten to `"0.0.0"` — a plausible-looking version for a
// package that exists nowhere — and the syntactic check waves it through.
// Switching packers would have published a manifest referencing a package no
// consumer can resolve, with a green gate.
//
// So the invariant is not "no `workspace:` string". It is:
//
// a public package's packed manifest must not reference a private workspace
// package at all, under any spelling.
//
// That is packer-independent and states the actual release boundary: what is
// private to this repository must not appear in what we hand to the registry.
// Matching is by NAME, discovered from the workspace, so it cannot be evaded by
// a version rewrite and needs no maintenance when a private package is added.
function privateWorkspacePackageNames() {
const packagesRoot = resolve(packageDirectory, "..");
const names = new Set();
for (const entry of readdirSync(packagesRoot)) {
const manifestPath = join(packagesRoot, entry, "package.json");
if (!existsSync(manifestPath)) continue;
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.private === true && typeof manifest.name === "string") names.add(manifest.name);
}
return names;
}

function assertNoPrivateWorkspacePackages(manifest) {
// A private package is not handed to the registry, so it has no release
// boundary to protect and may legitimately reference its private siblings.
if (sourceManifest.private === true) return;
const privateNames = privateWorkspacePackageNames();
if (privateNames.size === 0) return;
for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
for (const [name, version] of Object.entries(manifest[field] ?? {})) {
if (!privateNames.has(name)) continue;
throw new Error(
`${packageName} packed ${field}.${name}=${JSON.stringify(version)}, but ${name} is a PRIVATE workspace package and is not published. `
+ `A public package's packed manifest must not reference a private workspace package under any spelling — `
+ `${packer === "pnpm" ? "this manifest was packed by pnpm, which rewrites the workspace: protocol to a concrete version — a version string is therefore not evidence the package is published" : "npm preserves the workspace: protocol verbatim, so a private reference survives packing unchanged"}. `
+ `If the code is bundled at build time, declare the package in the private root workspace instead of in this manifest.`,
);
}
}
}

// Migration note (META-240): the monorepo version of this check asserted that
// spec/rules/agents-audit all carried one fixed-group version, because one
// repository released all three. This repository releases only agents-audit and
Expand Down
Loading
Loading