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
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ 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
with:
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
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/publish-alpha.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -29,15 +33,15 @@
"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",
"typescript": "^5.9.0",
"vitest": "^4.0.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22 <27"
},
"packageManager": "pnpm@11.10.0"
}
191 changes: 191 additions & 0 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading