diff --git a/.changeset/config.json b/.changeset/config.json index 1a0d200..ec98e35 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@workspacejson/cli"] + "ignore": [] } diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..635a2ab --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,127 @@ +name: Publish @workspacejson/cli + +# --------------------------------------------------------------------------- +# `cli-v*.*.*` tags publish ONLY @workspacejson/cli. +# +# The compatibility package released from this repository has its own publish +# authority (see OWNERSHIP.md) and must never be published by this workflow. +# That is enforced structurally rather than by review: the tag namespaces are +# disjoint, and the publish step is filtered to a single package directory. +# +# Release flow: +# pnpm changeset version # bumps packages/cli/package.json + CHANGELOG +# git commit -am "release: ..." && git push +# git tag cli-v0.1.0 && git push --tags +# +# The tag is both the trigger and the source of truth for the version: it is +# validated as clean semver and asserted equal to the manifest before anything +# reaches the registry. A tag that disagrees with the manifest fails the run +# instead of publishing a version nobody named. +# +# Required repository secret: +# NPM_TOKEN npm automation token with publish rights on @workspacejson/cli +# --------------------------------------------------------------------------- + +on: + push: + tags: + - "cli-v*.*.*" + +permissions: + contents: read + id-token: write # required for the npm provenance attestation + +concurrency: + group: publish-cli-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + # The tag name is attacker-influenceable, so it is read from an env var + # rather than inlined into the shell, and rejected unless it is clean + # semver — which also guarantees it carries no shell metacharacters for + # any later step that interpolates it. + - name: Derive and validate release version + id: version + env: + REF_NAME: ${{ github.ref_name }} + run: | + VERSION="${REF_NAME#cli-v}" + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'; then + echo "::error::tag '$REF_NAME' is not a valid cli-vX.Y.Z version" + exit 1 + fi + MANIFEST="$(node -p "require('./packages/cli/package.json').version")" + if [ "$MANIFEST" != "$VERSION" ]; then + echo "::error::tag version $VERSION does not match packages/cli/package.json ($MANIFEST)" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Publishing @workspacejson/cli v$VERSION" + + - run: pnpm install --no-frozen-lockfile + + - name: Architecture and clean-room guards + run: pnpm run check:architecture + + # Build precedes typecheck for the reason recorded in ci.yml: the + # compatibility package consumes this package's emitted declarations, so + # on a clean checkout those must exist before tsc can resolve them. + - name: Build all packages + run: pnpm -r build + + - name: Typecheck all packages + run: pnpm -r typecheck + + - name: Run tests + run: pnpm -r test + + # Packs, parses the PACKED manifest, rejects workspace:/file: leakage and + # unpinned contract dependencies, asserts the declared bin target is + # actually in the archive, then installs the tarball into a clean project + # and runs the binary end to end. This measures the bytes that will ship + # rather than the state of the branch. + - name: Verify the release tarball + run: pnpm --filter @workspacejson/cli exec node ../../scripts/verify-package-tarball.mjs + + - name: Confirm the publish credential reached the runner + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/verify-npm-publish-access.mjs + + # 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. + # + # `prepublishOnly` runs again here. That repetition is deliberate: it + # keeps the gate attached to the package, so a local or future manual + # publish cannot bypass what CI checks. + - name: Publish to npm + working-directory: packages/cli + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public + + # A publish that npm accepts can still be unusable — wrong files, broken + # bin, unresolvable dependency. This installs the real published artifact + # from the registry and runs it, retrying through propagation lag. + - name: Verify the published package installs and runs + env: + WORKSPACEJSON_RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: node scripts/verify-published.mjs @workspacejson/cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50f9b1c..1e7f81e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,8 +17,11 @@ name: Release (DISABLED — non-authoritative) # complete, old workflow disabled, old token revoked, then a least-privilege # token granted here for `agents-audit` only. # -# `@workspacejson/cli` must NOT be added to this workflow. It is `private: true` -# and its identity is undecided until META-236. +# `@workspacejson/cli` must NOT be added to this workflow. Its identity was +# settled under META-236 and it is now published by `publish-cli.yml`, on its own +# `cli-v*.*.*` tag namespace and its own least-privilege credential. Adding it +# here would give one workflow authority over two packages with different +# cutover states, which is the coupling the tag namespaces exist to prevent. # --------------------------------------------------------------------------- on: diff --git a/OWNERSHIP.md b/OWNERSHIP.md index 869a397..5223f15 100644 --- a/OWNERSHIP.md +++ b/OWNERSHIP.md @@ -8,7 +8,7 @@ script is what enforces it. | Directory | Package | Distribution | Role | | -- | -- | -- | -- | -| `packages/cli/` | `@workspacejson/cli` | public, **not yet published** | the neutral workspace.json producer and its `workspacejson` binary | +| `packages/cli/` | `@workspacejson/cli` | public, published from here on `cli-v*` tags | the neutral workspace.json producer and its `workspacejson` binary | | `packages/agents-audit-compat/` | `agents-audit` | public, published `0.4.4` | frozen compatibility bridge for the historical `agents-audit` command and API | ## Owns @@ -122,11 +122,15 @@ Guard: `neutral-producer-purity`. | Package | Publishable from here | Current authority | | -- | -- | -- | | `agents-audit` | metadata says yes; **workflow disabled** | `workspace-json/agents-audit` until META-243 | -| `@workspacejson/cli` | metadata says yes; **workflow disabled, never published** | none yet — META-243 | +| `@workspacejson/cli` | **Yes** — `.github/workflows/publish-cli.yml`, on `cli-v*.*.*` tags | this repository (META-236 settled the name; no prior authority existed) | | `@workspacejson/datahub-adapter` | **No** — extracted under META-248; redefining it here is a guard failure | `workspacejson/datahub-agent` (internal module, unpublished) | | `@workspacejson/spec`, `@workspacejson/rules` | **Never** — not owned here | `workspacejson/standard` | -This repository holds no publish-capable secret. +This repository holds one publish-capable secret, `NPM_TOKEN`, scoped to +`@workspacejson/cli`. `agents-audit` remains published by +`workspace-json/agents-audit` until META-243, so the two packages release on +disjoint tag namespaces (`cli-v*` and, later, its own) and no single workflow +holds authority over both. ## Migration source and provenance diff --git a/scripts/verify-package-tarball.mjs b/scripts/verify-package-tarball.mjs index 5578f9e..ec03533 100644 --- a/scripts/verify-package-tarball.mjs +++ b/scripts/verify-package-tarball.mjs @@ -112,11 +112,13 @@ function normalizeArchivePath(file) { return file.replace(/^\.\//, "").replaceAll("\\", "/").replace(/\/{2,}/g, "/"); } -// Packs a workspace sibling this tarball depends on but which is not yet on the -// registry, so the smoke install can resolve it. `agents-audit` depends on -// @workspacejson/cli, which is deliberately unpublished until the authority -// cutover (META-243) — without this the smoke test would fail on a package that -// is simply not released yet, rather than on a real defect. +// Packs a workspace sibling this tarball depends on, so the smoke install +// resolves the sibling bytes in THIS working tree rather than whatever is on the +// registry. `agents-audit` depends on @workspacejson/cli; originally this +// existed because the CLI was unpublished, but it is load-bearing beyond that. +// The two packages release on independent tags, so at any moment the registry +// may hold a CLI older than the one `agents-audit` was built and tested against. +// Resolving the sibling from disk is what makes this a test of the candidate. function packUnpublishedSiblings(manifest, destinationDirectory) { const packagesRoot = resolve(packageDirectory, ".."); const tarballs = []; diff --git a/scripts/verify-published.mjs b/scripts/verify-published.mjs index 77f4484..fa359b0 100644 --- a/scripts/verify-published.mjs +++ b/scripts/verify-published.mjs @@ -7,18 +7,51 @@ import { spawnSync } from "node:child_process"; // Migration note (META-240): in the monorepo this script verified the whole // fixed group and derived the release version from packages/spec. This -// repository publishes neither spec nor rules, so verifying them here would -// assert authority it does not hold — and would pass even if this repository's -// own release had failed. It verifies exactly what this repository publishes, -// and takes its version from that package. +// repository publishes neither of the standard contract packages, so verifying +// them here would assert authority it does not hold — and would pass even if +// this repository's own release had failed. It verifies exactly what this +// repository publishes, and takes its version from the package under release. // -// @workspacejson/cli is deliberately absent: it is `private: true` and must not -// appear on the registry. scripts/check-architecture.mjs is what asserts that. -const version = process.env.WORKSPACEJSON_RELEASE_VERSION - ?? JSON.parse(readFileSync(new URL("../packages/agents-audit-compat/package.json", import.meta.url), "utf8")).version; -const packages = [ - { name: "agents-audit", check: ["npx", "--no-install", "agents-audit", "--help"] }, -]; +// Each package is released independently under its own tag namespace, so this +// takes the package name as an argument rather than verifying everything: a +// `cli-v*` release must not be reported green because a previously published +// sibling still installs. With no argument it verifies every publishable +// package, which is only meaningful when their versions genuinely agree. +const RELEASABLE = { + "@workspacejson/cli": { + manifest: "../packages/cli/package.json", + check: ["npx", "--no-install", "workspacejson", "--help"], + }, + "agents-audit": { + manifest: "../packages/agents-audit-compat/package.json", + check: ["npx", "--no-install", "agents-audit", "--help"], + }, +}; + +const requested = process.argv.slice(2); +for (const name of requested) { + if (!(name in RELEASABLE)) { + console.error(`Unknown package ${JSON.stringify(name)}; expected one of: ${Object.keys(RELEASABLE).join(", ")}`); + process.exit(1); + } +} +const selected = requested.length > 0 ? requested : Object.keys(RELEASABLE); + +// The version comes from the released package's own manifest, never from a +// sibling's. WORKSPACEJSON_RELEASE_VERSION overrides it so the publish workflow +// can assert the exact version its tag named, rather than whatever the manifest +// happens to say by the time this step runs. +const packages = selected.map((name) => { + const entry = RELEASABLE[name]; + const version = process.env.WORKSPACEJSON_RELEASE_VERSION + ?? JSON.parse(readFileSync(new URL(entry.manifest, import.meta.url), "utf8")).version; + return { name, version, check: entry.check }; +}); + +if (requested.length !== 1 && process.env.WORKSPACEJSON_RELEASE_VERSION) { + console.error("WORKSPACEJSON_RELEASE_VERSION pins one version and cannot apply to multiple packages; name exactly one package."); + process.exit(1); +} // npm registry propagation lags publish by seconds to low minutes. A single // immediate post-publish check has no way to tell "not actually published" @@ -34,7 +67,7 @@ for (const pkg of packages) { writeFileSync(join(directory, "package.json"), JSON.stringify({ private: true, type: "module" })); await installWithRetry(pkg, directory); run(pkg.check[0], pkg.check.slice(1), directory); - console.log(`Verified registry install and runtime entry point: ${pkg.name}@${version}`); + console.log(`Verified registry install and runtime entry point: ${pkg.name}@${pkg.version}`); } finally { rmSync(directory, { recursive: true, force: true }); } @@ -42,7 +75,7 @@ for (const pkg of packages) { async function installWithRetry(pkg, directory) { for (let attempt = 1; attempt <= REGISTRY_PROPAGATION_RETRIES; attempt++) { - const result = spawnSync("npm", ["install", "--ignore-scripts", "--no-package-lock", `${pkg.name}@${version}`], { + const result = spawnSync("npm", ["install", "--ignore-scripts", "--no-package-lock", `${pkg.name}@${pkg.version}`], { cwd: directory, encoding: "utf8", env: { ...process.env, npm_config_cache: join(directory, ".npm-cache") }, @@ -58,7 +91,7 @@ async function installWithRetry(pkg, directory) { process.exit(result.status ?? 1); } const delayMs = REGISTRY_PROPAGATION_BASE_DELAY_MS * attempt; - console.log(`${pkg.name}@${version} not yet visible on the registry (attempt ${attempt}/${REGISTRY_PROPAGATION_RETRIES}) — retrying in ${delayMs}ms`); + console.log(`${pkg.name}@${pkg.version} not yet visible on the registry (attempt ${attempt}/${REGISTRY_PROPAGATION_RETRIES}) — retrying in ${delayMs}ms`); await new Promise((resolve) => setTimeout(resolve, delayMs)); } }