diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08207ab..f2b59d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 9422a51..4cf74aa 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -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 diff --git a/README.md b/README.md index a7fb3c1..8f755ea 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) | diff --git a/package.json b/package.json index 955f0bb..e6341c2 100644 --- a/package.json +++ b/package.json @@ -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:*" } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d8e023e..c586ebe 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 4eaff69..b95f01d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -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", @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 395ab21..dace7af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.1(@types/node@22.19.17) + '@workspacejson/mining-core': + specifier: workspace:* + version: link:packages/mining-core packages/agents-audit-compat: dependencies: @@ -89,9 +92,6 @@ importers: '@types/node': specifier: 22.19.17 version: 22.19.17 - '@workspacejson/mining-core': - specifier: workspace:* - version: link:../mining-core tsup: specifier: ^8.0.0 version: 8.5.1(postcss@8.5.23)(typescript@5.9.3) diff --git a/scripts/verify-package-tarball.mjs b/scripts/verify-package-tarball.mjs index ec03533..32fd022 100644 --- a/scripts/verify-package-tarball.mjs +++ b/scripts/verify-package-tarball.mjs @@ -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.`); @@ -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); @@ -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 diff --git a/scripts/verify-package-tarball.test.mjs b/scripts/verify-package-tarball.test.mjs new file mode 100644 index 0000000..e70450c --- /dev/null +++ b/scripts/verify-package-tarball.test.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node + +// Red tests for scripts/verify-package-tarball.mjs (META-297). +// +// These exist because this guard shipped a green verdict over an invalid +// release. `@workspacejson/cli@0.6.0` was tagged, and its publish run failed at +// this gate with a private workspace package in the packed manifest. The gate +// was correct; what was missing was proof that it stays correct under the OTHER +// packer, where the same defect wears a different spelling. +// +// Two properties are asserted here, and neither was covered before: +// +// 1. The invariant is about IDENTITY, not syntax. `@workspacejson/mining-core` +// is private and unpublished. Packed with npm it appears as +// `"workspace:*"`; packed with pnpm the same broken reference is rewritten +// to `"0.0.0"`, which looks like an ordinary version. A syntactic +// `workspace:` check passes the second one. The guard must reject both. +// +// 2. The packer is DETERMINISTIC. It used to be inferred from `npm_execpath`, +// so `pnpm run release:verify-packs` packed with pnpm while CI's +// `pnpm --filter ... exec ...` packed with npm — the same commit verifying +// green locally and red in CI, with the green run measuring bytes nobody +// publishes. Release verification must always measure npm, because +// `publish-cli.yml` publishes with `npm publish`. + +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const read = (p) => readFileSync(p, "utf8"); +const write = (p, s) => writeFileSync(p, s); + +// The exact defect that failed the 0.6.0 publish: the public CLI declaring the +// private mining core. Reintroduced deliberately so the guard is measured +// against the real thing rather than a synthetic stand-in. +function declarePrivateWorkspaceDependency(spelling) { + return (root) => { + const p = join(root, "packages/cli/package.json"); + const manifest = JSON.parse(read(p)); + manifest.devDependencies = { + ...manifest.devDependencies, + "@workspacejson/mining-core": spelling, + }; + write(p, `${JSON.stringify(manifest, null, 2)}\n`); + }; +} + +// The two spellings the same defect wears. `workspace:*` is what a developer +// writes and what npm packs verbatim; `0.0.0` is what pnpm rewrites it into, +// taken from the private package's own manifest. Asserting both is the point: +// the guard must key on the NAME, because only one of these looks wrong. +const WORKSPACE_PROTOCOL = "workspace:*"; +const PNPM_REWRITTEN = "0.0.0"; + +const cases = [ + { + name: "private-package/npm: workspace:* is rejected on the release path", + mutate: declarePrivateWorkspaceDependency(WORKSPACE_PROTOCOL), + env: {}, + expect: ["is a PRIVATE workspace package", "npm preserves the workspace: protocol verbatim"], + }, + { + // The case that motivated the redesign. This is the manifest pnpm produces + // from `workspace:*`, packed by pnpm, and it is the shape a syntactic + // `workspace:` check waves through. Declared already-rewritten so the case + // needs no installed workspace for pnpm to resolve against. + name: "private-package/pnpm: the 0.0.0 rewrite cannot disguise it either", + mutate: declarePrivateWorkspaceDependency(PNPM_REWRITTEN), + env: { WORKSPACEJSON_PACKER: "pnpm" }, + expect: ["is a PRIVATE workspace package", "a version string is therefore not evidence the package is published"], + }, + { + // Spelling-independence on the packer that actually publishes: a concrete + // version buys no amnesty either. + name: "private-package/npm: a concrete version is rejected just as workspace:* is", + mutate: declarePrivateWorkspaceDependency(PNPM_REWRITTEN), + env: {}, + expect: ["is a PRIVATE workspace package"], + }, + { + // Regression guard for the packer-selection defect itself. Under the old + // inference this environment produced a pnpm pack; it must now still pack + // with npm, which the npm-specific wording proves. + name: "packer determinism: npm_execpath pointing at pnpm no longer changes the packer", + mutate: declarePrivateWorkspaceDependency(WORKSPACE_PROTOCOL), + env: { npm_execpath: "/opt/homebrew/lib/node_modules/pnpm/bin/pnpm.cjs" }, + expect: ["npm preserves the workspace: protocol verbatim"], + }, +]; + +function runVerifier(root, env) { + return spawnSync(process.execPath, [join(root, "scripts", "verify-package-tarball.mjs")], { + cwd: join(root, "packages", "cli"), + encoding: "utf8", + env: { ...process.env, WORKSPACEJSON_PACKER: undefined, npm_execpath: undefined, ...env }, + }); +} + +function scratchCopy() { + const directory = mkdtempSync(join(tmpdir(), "wjson-pack-guard-")); + const root = join(directory, "repo"); + cpSync(repoRoot, root, { + recursive: true, + filter: (src) => !src.includes("/node_modules") && !src.includes("/.git/") && !src.endsWith("/.git"), + }); + return { directory, root }; +} + +let passed = 0; +let failed = 0; + +for (const testCase of cases) { + const { directory, root } = scratchCopy(); + try { + testCase.mutate(root); + const result = runVerifier(root, testCase.env); + const output = `${result.stdout}${result.stderr}`; + const missing = testCase.expect.filter((fragment) => !output.includes(fragment)); + if (result.status !== 0 && missing.length === 0) { + console.log(`PASS rejected — ${testCase.name}`); + passed += 1; + } else if (result.status !== 0) { + console.error(`FAIL ${testCase.name}: rejected, but the reason did not mention ${JSON.stringify(missing)}`); + console.error(output); + failed += 1; + } else { + console.error(`FAIL ${testCase.name}: guard ACCEPTED a private workspace package in a published manifest`); + console.error(output); + failed += 1; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +// Baseline last: a guard that rejects everything is not a guard. This runs the +// full verification, including the packed-bin smoke install, so it also proves +// the release path is green on the unmodified repository. +{ + const { directory, root } = scratchCopy(); + try { + const result = runVerifier(root, {}); + const output = `${result.stdout}${result.stderr}`; + if (result.status === 0 && output.includes("with npm: packed manifest and runtime files are release-safe")) { + console.log("PASS accepted — the unmodified repository, packed with npm"); + passed += 1; + } else { + console.error("FAIL the unmodified repository was REJECTED, or did not pack with npm"); + console.error(output); + failed += 1; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log(`\nPackage tarball guard red tests: ${passed} passed, ${failed} failed.`); +if (failed > 0) process.exit(1);