From 66c0842afffc1e93acfe9be216f3937a48d5c6eb Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Wed, 29 Apr 2026 18:02:24 -0500 Subject: [PATCH] Harden release publishing --- .github/workflows/release.yml | 48 ++++++++- PUBLISHING.md | 33 ++++--- package.json | 1 + packages/ci/package.json | 3 + packages/classify/package.json | 3 + packages/cli/package.json | 1 + packages/drift/package.json | 3 + packages/git/package.json | 3 + packages/validate/package.json | 3 + scripts/assert-packages-publishable.mjs | 124 ++++++++++++++++++++++++ scripts/ensure-pnpm-publish.mjs | 36 +++++++ 11 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 scripts/assert-packages-publishable.mjs create mode 100644 scripts/ensure-pnpm-publish.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94df503..17e1296 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,11 +126,17 @@ jobs: - name: Build run: pnpm run build - # Enforces unified workspace versioning — every packages/*/package.json must match the tag. - - name: Verify workspace versions match tag + # Enforces unified workspace versioning: every packages/*/package.json must match the tag. + # If Charter adopts independent package versions, replace this with per-package release metadata. + - name: Verify tag and workspace versions shell: bash run: | TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid tag format: ${TAG}. Expected v.." + exit 1 + fi + EXPECTED="${TAG#v}" FAIL=0 for p in packages/*/package.json; do @@ -143,7 +149,43 @@ jobs: done if [[ $FAIL -ne 0 ]]; then exit 1; fi + - name: Verify packed manifests + run: pnpm run publish:check + # Auth is OIDC via npm trusted publishers — no NPM_TOKEN needed. # See: https://docs.npmjs.com/trusted-publishers - name: Publish to npm - run: npm publish --workspaces --access public --provenance + shell: bash + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + mkdir -p release-tarballs + + pack_package() { + local package_dir="$1" + (cd "${package_dir}" && pnpm pack --pack-destination ../../release-tarballs) + } + + pack_package packages/types + pack_package packages/core + pack_package packages/adf + pack_package packages/git + pack_package packages/classify + pack_package packages/validate + pack_package packages/drift + pack_package packages/blast + pack_package packages/surface + pack_package packages/ci + pack_package packages/cli + + npm publish "release-tarballs/stackbilt-types-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-core-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-adf-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-git-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-classify-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-validate-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-drift-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-blast-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-surface-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-ci-${VERSION}.tgz" --access public --provenance + npm publish "release-tarballs/stackbilt-cli-${VERSION}.tgz" --access public --provenance diff --git a/PUBLISHING.md b/PUBLISHING.md index 738d0d8..c2c19b1 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -41,6 +41,7 @@ pnpm run clean pnpm run typecheck pnpm run build pnpm run test +pnpm run publish:check ``` ## Phase 2: Version Bump @@ -62,23 +63,29 @@ done ## Phase 3: Artifact Validation (Required) -1. Dry-run packed contents per package: +1. Verify packed package manifests do not contain `workspace:` dependency specifiers: ```bash -pnpm --filter @stackbilt/types pack --dry-run -pnpm --filter @stackbilt/core pack --dry-run -pnpm --filter @stackbilt/adf pack --dry-run -pnpm --filter @stackbilt/git pack --dry-run -pnpm --filter @stackbilt/classify pack --dry-run -pnpm --filter @stackbilt/validate pack --dry-run -pnpm --filter @stackbilt/drift pack --dry-run -pnpm --filter @stackbilt/blast pack --dry-run -pnpm --filter @stackbilt/surface pack --dry-run -pnpm --filter @stackbilt/ci pack --dry-run -pnpm --filter @stackbilt/cli pack --dry-run +pnpm run publish:check ``` -2. Verify CLI behavior before publish: +2. Dry-run packed contents per package: + +```bash +(cd packages/types && pnpm pack --dry-run) +(cd packages/core && pnpm pack --dry-run) +(cd packages/adf && pnpm pack --dry-run) +(cd packages/git && pnpm pack --dry-run) +(cd packages/classify && pnpm pack --dry-run) +(cd packages/validate && pnpm pack --dry-run) +(cd packages/drift && pnpm pack --dry-run) +(cd packages/blast && pnpm pack --dry-run) +(cd packages/surface && pnpm pack --dry-run) +(cd packages/ci && pnpm pack --dry-run) +(cd packages/cli && pnpm pack --dry-run) +``` + +3. Verify CLI behavior before publish: ```bash node packages/cli/dist/bin.js --version diff --git a/package.json b/package.json index 8c7ecd1..c93c1c0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "docs:oss:check": "node scripts/docs-sync.mjs --check --config .docsync.oss.json", "docs:oss:auto": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json", "docs:oss:auto:dry-run": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json --dry-run --no-push", + "publish:check": "node scripts/assert-packages-publishable.mjs", "verify:adf": "bash -lc \"node packages/cli/dist/bin.js doctor --adf-only --ci --format json && node packages/cli/dist/bin.js adf evidence --auto-measure --ci --format json\"", "charter:detect": "charter setup --detect-only --format json", "charter:setup": "charter setup --preset fullstack --ci github --yes", diff --git a/packages/ci/package.json b/packages/ci/package.json index 4c35dc2..5ef2be2 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/classify/package.json b/packages/classify/package.json index 2237235..b56115f 100644 --- a/packages/classify/package.json +++ b/packages/classify/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 61b2fa5..cc4a38b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "access": "public" }, "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs", "build": "pnpm exec tsc -p tsconfig.json" }, "dependencies": { diff --git a/packages/drift/package.json b/packages/drift/package.json index f5c8ef3..fcc0a72 100644 --- a/packages/drift/package.json +++ b/packages/drift/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/git/package.json b/packages/git/package.json index 0754a70..d31bef2 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/packages/validate/package.json b/packages/validate/package.json index dddf198..cbda9cd 100644 --- a/packages/validate/package.json +++ b/packages/validate/package.json @@ -31,6 +31,9 @@ "dependencies": { "@stackbilt/types": "workspace:^" }, + "scripts": { + "prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs" + }, "publishConfig": { "access": "public" }, diff --git a/scripts/assert-packages-publishable.mjs b/scripts/assert-packages-publishable.mjs new file mode 100644 index 0000000..f3dad09 --- /dev/null +++ b/scripts/assert-packages-publishable.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageGlobs = [ + "packages/types", + "packages/core", + "packages/adf", + "packages/git", + "packages/classify", + "packages/validate", + "packages/drift", + "packages/blast", + "packages/surface", + "packages/ci", + "packages/cli", +]; +const dependencyFields = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const tempDir = mkdtempSync(join(tmpdir(), "charter-publish-check-")); +const failures = []; + +function run(command, args, options) { + const result = spawnSync(command, args, { + ...options, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error( + [ + `${command} ${args.join(" ")} failed in ${options.cwd}`, + result.stdout.trim(), + result.stderr.trim(), + ] + .filter(Boolean) + .join("\n"), + ); + } + + return result.stdout; +} + +function packedPackageJson(tarball) { + return JSON.parse(run("tar", ["-xOf", tarball, "package/package.json"], { cwd: root })); +} + +function workspaceDependencyEntries(manifest) { + const entries = []; + + for (const field of dependencyFields) { + const dependencies = manifest[field] ?? {}; + for (const [name, specifier] of Object.entries(dependencies)) { + if (typeof specifier === "string" && specifier.startsWith("workspace:")) { + entries.push(`${field}.${name}=${specifier}`); + } + } + } + + return entries; +} + +function tarballsIn(directory) { + return new Set(readdirSync(directory).filter((file) => file.endsWith(".tgz"))); +} + +function packedFilename(packageDir, output, beforePack) { + if (output.trim().length > 0) { + const packResult = JSON.parse(output); + if (typeof packResult.filename === "string") { + return packResult.filename; + } + } + + const createdTarballs = readdirSync(tempDir) + .filter((file) => file.endsWith(".tgz") && !beforePack.has(file)); + + if (createdTarballs.length !== 1) { + throw new Error( + `Expected one tarball from pnpm pack for ${packageDir}, found ${createdTarballs.length}.`, + ); + } + + return join(tempDir, createdTarballs[0]); +} + +try { + for (const packageDir of packageGlobs) { + const cwd = join(root, packageDir); + readFileSync(join(cwd, "package.json"), "utf8"); + const beforePack = tarballsIn(tempDir); + const output = run("pnpm", ["pack", "--json", "--pack-destination", tempDir], { cwd }); + const filename = packedFilename(packageDir, output, beforePack); + const packedManifest = packedPackageJson(filename); + const workspaceEntries = workspaceDependencyEntries(packedManifest); + + if (workspaceEntries.length > 0) { + failures.push(`${packedManifest.name}: ${workspaceEntries.join(", ")}`); + } + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} + +if (failures.length > 0) { + console.error("Packed package manifests contain workspace protocol dependencies:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + console.error("Publish with pnpm from the workspace root so workspace:^ is rewritten."); + process.exit(1); +} + +console.log("All packed package manifests are publishable; no workspace: dependency specifiers found."); diff --git a/scripts/ensure-pnpm-publish.mjs b/scripts/ensure-pnpm-publish.mjs new file mode 100644 index 0000000..5da855d --- /dev/null +++ b/scripts/ensure-pnpm-publish.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const packageJson = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); +const dependencyFields = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const usesWorkspaceProtocol = dependencyFields.some((field) => + Object.values(packageJson[field] ?? {}).some( + (specifier) => typeof specifier === "string" && specifier.startsWith("workspace:"), + ), +); + +if (!usesWorkspaceProtocol) { + process.exit(0); +} + +const userAgent = process.env.npm_config_user_agent ?? ""; +const execPath = process.env.npm_execpath ?? ""; +const invokedByPnpm = userAgent.includes("pnpm/") || execPath.includes("pnpm"); + +if (!invokedByPnpm) { + console.error( + [ + `${packageJson.name} uses workspace: dependency specifiers in source package.json.`, + "Direct npm publish can leak those specifiers into the public tarball.", + "Publish with pnpm from the workspace root and run `pnpm run publish:check` before publishing.", + ].join("\n"), + ); + process.exit(1); +}