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
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@workspacejson/cli"]
"ignore": []
}
127 changes: 127 additions & 0 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions OWNERSHIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions scripts/verify-package-tarball.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
61 changes: 47 additions & 14 deletions scripts/verify-published.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,15 +67,15 @@ 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 });
}
}

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") },
Expand All @@ -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));
}
}
Expand Down
Loading