From 9ec72afa8ad8e9f5032d8628fbf646e7d200ea5c Mon Sep 17 00:00:00 2001 From: localhost41 Date: Thu, 9 Jul 2026 23:38:04 -0700 Subject: [PATCH] Harden production readiness gates --- .github/workflows/ci.yml | 7 +- .github/workflows/publish-alpha.yml | 38 ++++++ package.json | 8 +- scripts/verify-package.mjs | 191 ++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/publish-alpha.yml create mode 100644 scripts/verify-package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb094dc..762d367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [22, 24, 26] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -15,7 +19,8 @@ jobs: version: 11.10.0 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.node-version }} + cache: pnpm - name: Install dependencies run: | if [ -f pnpm-lock.yaml ]; then diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-alpha.yml new file mode 100644 index 0000000..92412a9 --- /dev/null +++ b/.github/workflows/publish-alpha.yml @@ -0,0 +1,38 @@ +name: Publish Alpha + +on: + workflow_dispatch: + inputs: + ref: + description: Git ref or tag to publish + required: true + default: main + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@v4 + with: + version: 11.10.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Verify alpha package + run: | + node -e "const p=require('./package.json'); if (!/-alpha\\./.test(p.version)) throw new Error('Refusing to publish non-alpha version ' + p.version);" + pnpm verify + - name: Publish alpha + run: npm publish --access public --tag alpha --provenance diff --git a/package.json b/package.json index eec4378..6bb9f80 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "type": "git", "url": "git+https://github.com/localhost41/create-qvac-app.git" }, + "bugs": { + "url": "https://github.com/localhost41/create-qvac-app/issues" + }, + "homepage": "https://github.com/localhost41/create-qvac-app#readme", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,7 +33,7 @@ "lint": "tsc -p tsconfig.json --noEmit", "prepack": "pnpm build", "verify": "pnpm lint && pnpm test && pnpm pack:check", - "pack:check": "pnpm build && vitest run test/pack.test.ts" + "pack:check": "pnpm build && node scripts/verify-package.mjs && vitest run test/pack.test.ts" }, "devDependencies": { "@types/node": "^26.0.0", @@ -37,7 +41,7 @@ "vitest": "^4.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22 <27" }, "packageManager": "pnpm@11.10.0" } diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..f0aefce --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")); + +function fail(message) { + console.error(`Package verification failed: ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? rootDir, + encoding: "utf8", + env: { + ...process.env, + ...options.env, + }, + }); + + if (result.error) { + fail(`${command} could not start: ${result.error.message}`); + } + + if (result.status !== 0) { + fail( + `${command} ${args.join(" ")} exited with ${result.status}\n${result.stderr.trim()}`, + ); + } + + return result; +} + +const binEntries = + typeof packageJson.bin === "string" + ? [[packageJson.name, packageJson.bin]] + : Object.entries(packageJson.bin ?? {}); + +for (const [name, binPath] of binEntries) { + if (!existsSync(join(rootDir, binPath))) { + fail(`bin "${name}" points to missing built file: ${binPath}`); + } +} + +const requiredPublishedFiles = new Set([ + "package.json", + "README.md", + "CHANGELOG.md", + "LICENSE", + packageJson.main, + packageJson.types, + ...binEntries.map(([, binPath]) => binPath), +]); + +const forbiddenPathFragments = [ + "node_modules/", + "test/", + "tests/", + "src/", + ".git/", + ".github/", + "tsconfig", + "vitest", +]; + +const npmCacheDir = mkdtempSync(join(tmpdir(), "create-qvac-app-npm-cache-")); +const installRootDir = mkdtempSync(join(tmpdir(), "create-qvac-app-install-")); + +try { + const dryRun = run("npm", ["pack", "--dry-run", "--json"], { + env: { npm_config_cache: npmCacheDir }, + }); + + let dryRunOutput; + try { + dryRunOutput = JSON.parse(dryRun.stdout); + } catch { + fail(`npm pack dry-run returned invalid JSON\n${dryRun.stdout.trim()}`); + } + + const packedPackage = dryRunOutput[0]; + const packedFiles = new Set((packedPackage?.files ?? []).map((file) => file.path)); + + for (const requiredFile of requiredPublishedFiles) { + if (!packedFiles.has(requiredFile)) { + fail(`packed package is missing ${requiredFile}`); + } + } + + for (const file of packedFiles) { + if (forbiddenPathFragments.some((fragment) => file.includes(fragment))) { + fail(`packed package unexpectedly includes ${file}`); + } + } + + if (![...packedFiles].some((file) => file.endsWith(".d.ts"))) { + fail("packed package does not include type declarations"); + } + + const packResult = run("npm", ["pack", rootDir, "--json"], { + cwd: installRootDir, + env: { npm_config_cache: npmCacheDir }, + }); + + let packOutput; + try { + packOutput = JSON.parse(packResult.stdout); + } catch { + fail(`npm pack returned invalid JSON\n${packResult.stdout.trim()}`); + } + + const tarballName = packOutput[0]?.filename; + if (typeof tarballName !== "string") { + fail("npm pack did not report a tarball filename"); + } + + const tarballPath = join(installRootDir, tarballName); + const installProjectDir = join(installRootDir, "consumer"); + mkdirSync(installProjectDir); + writeFileSync( + join(installProjectDir, "package.json"), + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + ); + + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarballPath], { + cwd: installProjectDir, + env: { npm_config_cache: npmCacheDir }, + }); + + const importResult = run( + process.execPath, + [ + "--input-type=module", + "--eval", + [ + `import { name } from ${JSON.stringify(packageJson.name)};`, + "if (name() !== 'create-qvac-app') throw new Error('unexpected package name export');", + ].join("\n"), + ], + { cwd: installProjectDir }, + ); + + if (importResult.stderr.trim() !== "") { + fail(`installed package import emitted stderr\n${importResult.stderr.trim()}`); + } + + const helpResult = run("npx", ["--no-install", "create-qvac-app", "--help"], { + cwd: installProjectDir, + env: { npm_config_cache: npmCacheDir }, + }); + + if ( + !helpResult.stdout.includes("Usage:") || + !helpResult.stdout.includes("--template node-chat") + ) { + fail(`installed CLI help output was unexpected\n${helpResult.stdout.trim()}`); + } + + const appDir = join(installRootDir, "smoke-qvac-chat"); + run("npx", ["--no-install", "create-qvac-app", appDir, "--template", "node-chat"], { + cwd: installProjectDir, + env: { npm_config_cache: npmCacheDir }, + }); + + for (const generatedFile of ["package.json", "README.md", "src/index.ts"]) { + if (!existsSync(join(appDir, generatedFile))) { + fail(`generated app is missing ${generatedFile}`); + } + } + + run("pnpm", ["install"], { cwd: appDir }); + run("pnpm", ["build"], { cwd: appDir }); + + console.log(`Package verification passed: ${packedPackage.filename}`); +} finally { + rmSync(npmCacheDir, { recursive: true, force: true }); + rmSync(installRootDir, { recursive: true, force: true }); +}