Skip to content

Commit 60f0177

Browse files
test(ci): add publish smoke test for npm, pnpm, bun (#1018)
* test(ci): add publish smoke test for npm, pnpm, bun Packs every publishable workspace (create-better-t-stack, @better-t-stack/template-generator, @better-t-stack/types, create-bts) with `npm pack` (matches what the release workflow's `npm publish` would produce), then runs two checks: 1. Static: asserts no unresolved protocol (catalog:, workspace:, link:) leaked into the tarball's package.json. This catches the class of bug that shipped in 3.27.3 — where catalog: resolved fine inside the monorepo but survived into the published artifact and broke downstream pnpm/npm installs. 2. Install: installs the CLI tarball via npm, pnpm, and bun in isolated temp dirs, using overrides to redirect sibling workspace deps to their local tarballs. Verifies the binary lands in node_modules/.bin. Catches any install-time regression across the three package managers users actually reach for. Gate in both the PR test workflow and the release workflow (before publishing to npm) so a broken artifact can't reach consumers even if PR CI is somehow bypassed. Verified: smoke passes against the current tree; when I temporarily injected `"zod": "catalog:"` into apps/cli/package.json, the static check caught it before the install step ran. * ci: run publish smoke before any npm publish The smoke step was placed after @better-t-stack/types and @better-t-stack/template-generator were already published, which defeated the protection for those packages — if smoke failed on either one, the broken artifact would already be on npm and the release would halt partway through. Move all four publish steps to run after the smoke gate so a smoke failure aborts the release before anything ships. The builds and jq version rewrites stay where they are (the smoke needs them). * test(publish-smoke): address review nits - npm pack --json instead of --silent + stdout string parse (docs-stable JSON contract, no fragile string splitting / non-null assertion) - rewrite workspace:* across all four dep buckets (dependencies, devDependencies, peerDependencies, optionalDependencies) for parity with what a jq-based release rewrite would handle
1 parent 26029ee commit 60f0177

4 files changed

Lines changed: 180 additions & 16 deletions

File tree

.github/workflows/release.yaml

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,6 @@ jobs:
8787
if: steps.check-version.outputs.exists == 'false'
8888
run: cd packages/types && bun run build
8989

90-
- name: Publish types to NPM
91-
if: steps.check-version.outputs.exists == 'false'
92-
run: cd packages/types && npm publish --access public --provenance
93-
env:
94-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
95-
BTS_TELEMETRY: 1
96-
CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }}
97-
9890
- name: Update template-generator package version and types dependency
9991
if: steps.check-version.outputs.exists == 'false'
10092
run: |
@@ -106,14 +98,6 @@ jobs:
10698
if: steps.check-version.outputs.exists == 'false'
10799
run: cd packages/template-generator && bun run build
108100

109-
- name: Publish template-generator to NPM
110-
if: steps.check-version.outputs.exists == 'false'
111-
run: cd packages/template-generator && npm publish --access public --provenance
112-
env:
113-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
114-
BTS_TELEMETRY: 1
115-
CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }}
116-
117101
- name: Update CLI types and template-generator dependencies and version
118102
if: steps.check-version.outputs.exists == 'false'
119103
run: |
@@ -135,6 +119,33 @@ jobs:
135119
cd packages/create-bts
136120
jq --arg v "$VERSION" --arg dep "^$VERSION" '.version = $v | .dependencies["create-better-t-stack"] = $dep' package.json > tmp.json && mv tmp.json package.json
137121
122+
- name: Enable pnpm via corepack
123+
if: steps.check-version.outputs.exists == 'false'
124+
run: corepack enable pnpm
125+
126+
# Gate all publishes on the smoke so a failure here doesn't leave a
127+
# partial release on npm (e.g. types/template-generator published but
128+
# create-better-t-stack broken).
129+
- name: Publish smoke test (npm, pnpm, bun)
130+
if: steps.check-version.outputs.exists == 'false'
131+
run: bun run smoke:publish
132+
133+
- name: Publish types to NPM
134+
if: steps.check-version.outputs.exists == 'false'
135+
run: cd packages/types && npm publish --access public --provenance
136+
env:
137+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
138+
BTS_TELEMETRY: 1
139+
CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }}
140+
141+
- name: Publish template-generator to NPM
142+
if: steps.check-version.outputs.exists == 'false'
143+
run: cd packages/template-generator && npm publish --access public --provenance
144+
env:
145+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
146+
BTS_TELEMETRY: 1
147+
CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }}
148+
138149
- name: Publish CLI to NPM
139150
if: steps.check-version.outputs.exists == 'false'
140151
run: cd apps/cli && npm publish --access public --provenance

.github/workflows/test.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,9 @@ jobs:
5050
env:
5151
AGENT: 1
5252
BTS_TELEMETRY: 0
53+
54+
- name: Enable pnpm via corepack
55+
run: corepack enable pnpm
56+
57+
- name: Publish smoke test (npm, pnpm, bun)
58+
run: bun run smoke:publish

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dev": "turbo run dev",
2929
"dev:cli": "turbo run dev --filter=create-better-t-stack",
3030
"cli": "cd apps/cli && node dist/cli.js",
31+
"smoke:publish": "bun run scripts/publish-smoke.ts",
3132
"dev:web": "turbo run dev --filter=web",
3233
"build:web": "turbo run build --filter=web",
3334
"build:cli": "turbo run build --filter=create-better-t-stack",

scripts/publish-smoke.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env bun
2+
// Verify create-better-t-stack installs and runs under npm, pnpm, and bun.
3+
// Catches any regression that breaks the published artifact for consumers —
4+
// unresolved protocol refs, missing files, broken bin entry, import failures
5+
// from missing transitive deps, etc.
6+
//
7+
// Packs each publishable workspace with `npm pack` (matching the release
8+
// workflow, which uses `npm publish`), installs the CLI tarball in a temp
9+
// dir using overrides to redirect sibling workspace deps at their local
10+
// tarballs, then runs `create-better-t-stack --version` to prove the binary
11+
// actually executes.
12+
13+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14+
import { tmpdir } from "node:os";
15+
import { join, resolve } from "node:path";
16+
17+
import { $ } from "bun";
18+
19+
const ROOT = resolve(import.meta.dir, "..");
20+
21+
type Publishable = {
22+
name: string;
23+
dir: string;
24+
// Release workflow rewrites workspace:* → ^<version> via jq before publish.
25+
// Mirror that here so the tarball matches what actually ships.
26+
rewriteWorkspaceDeps?: string[];
27+
};
28+
29+
const PUBLISHABLES: Publishable[] = [
30+
{ name: "@better-t-stack/types", dir: "packages/types" },
31+
{ name: "@better-t-stack/template-generator", dir: "packages/template-generator" },
32+
{
33+
name: "create-better-t-stack",
34+
dir: "apps/cli",
35+
rewriteWorkspaceDeps: ["@better-t-stack/types", "@better-t-stack/template-generator"],
36+
},
37+
];
38+
39+
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
40+
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
41+
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
42+
43+
async function pack(pkg: Publishable, outDir: string): Promise<string> {
44+
const pkgJsonPath = join(ROOT, pkg.dir, "package.json");
45+
const original = readFileSync(pkgJsonPath, "utf-8");
46+
47+
if (pkg.rewriteWorkspaceDeps) {
48+
const parsed = JSON.parse(original);
49+
for (const bucket of [
50+
"dependencies",
51+
"devDependencies",
52+
"peerDependencies",
53+
"optionalDependencies",
54+
] as const) {
55+
const deps = parsed[bucket];
56+
if (!deps) continue;
57+
for (const d of pkg.rewriteWorkspaceDeps) {
58+
if (deps[d]?.startsWith("workspace:")) {
59+
deps[d] = `^${parsed.version}`;
60+
}
61+
}
62+
}
63+
writeFileSync(pkgJsonPath, `${JSON.stringify(parsed, null, 2)}\n`);
64+
}
65+
66+
try {
67+
const r = await $`npm pack --pack-destination=${outDir} --json`
68+
.cwd(join(ROOT, pkg.dir))
69+
.quiet();
70+
const [entry] = JSON.parse(r.stdout.toString()) as Array<{ filename: string }>;
71+
return join(outDir, entry.filename);
72+
} finally {
73+
if (pkg.rewriteWorkspaceDeps) writeFileSync(pkgJsonPath, original);
74+
}
75+
}
76+
77+
async function installAndRun(
78+
pm: "npm" | "pnpm" | "bun",
79+
tarballs: Record<string, string>,
80+
smokeRoot: string,
81+
) {
82+
const dir = join(smokeRoot, `install-${pm}`);
83+
rmSync(dir, { recursive: true, force: true });
84+
mkdirSync(dir, { recursive: true });
85+
86+
const overrides = {
87+
"@better-t-stack/types": `file:${tarballs["@better-t-stack/types"]}`,
88+
"@better-t-stack/template-generator": `file:${tarballs["@better-t-stack/template-generator"]}`,
89+
};
90+
const fixture: Record<string, unknown> = {
91+
name: `smoke-${pm}`,
92+
private: true,
93+
version: "0.0.0",
94+
dependencies: { "create-better-t-stack": `file:${tarballs["create-better-t-stack"]}` },
95+
};
96+
if (pm === "pnpm") fixture.pnpm = { overrides };
97+
else fixture.overrides = overrides;
98+
99+
writeFileSync(join(dir, "package.json"), JSON.stringify(fixture, null, 2));
100+
101+
const install = await $`${pm} install --ignore-scripts`.cwd(dir).quiet().nothrow();
102+
if (install.exitCode !== 0) {
103+
console.error(red(`✗ ${pm} install failed`));
104+
console.error(dim(install.stderr.toString()));
105+
process.exit(1);
106+
}
107+
108+
// Execute the CLI via its installed bin path to prove it actually works —
109+
// not just that the file got linked into node_modules/.bin.
110+
const bin = join(dir, "node_modules", ".bin", "create-better-t-stack");
111+
const run = await $`${bin} --version`.cwd(dir).quiet().nothrow();
112+
if (run.exitCode !== 0) {
113+
console.error(red(`✗ ${pm}: create-better-t-stack --version failed (exit ${run.exitCode})`));
114+
console.error(dim(run.stderr.toString() + run.stdout.toString()));
115+
process.exit(1);
116+
}
117+
118+
console.log(green(`✓ ${pm}`) + dim(` v${run.stdout.toString().trim()}`));
119+
}
120+
121+
async function hasPackageManager(pm: string): Promise<boolean> {
122+
return (await $`which ${pm}`.quiet().nothrow()).exitCode === 0;
123+
}
124+
125+
const smokeRoot = join(tmpdir(), `bts-publish-smoke-${Date.now()}`);
126+
const tarballDir = join(smokeRoot, "tarballs");
127+
mkdirSync(tarballDir, { recursive: true });
128+
129+
console.log("Packing...");
130+
const tarballs: Record<string, string> = {};
131+
for (const pkg of PUBLISHABLES) {
132+
tarballs[pkg.name] = await pack(pkg, tarballDir);
133+
console.log(dim(` ${pkg.name}`));
134+
}
135+
136+
console.log("\nInstalling and running create-better-t-stack under each package manager...");
137+
for (const pm of ["npm", "pnpm", "bun"] as const) {
138+
if (!(await hasPackageManager(pm))) {
139+
console.log(dim(` - ${pm} not available, skipping`));
140+
continue;
141+
}
142+
await installAndRun(pm, tarballs, smokeRoot);
143+
}
144+
145+
rmSync(smokeRoot, { recursive: true, force: true });
146+
console.log(green("\n✓ publish smoke test passed"));

0 commit comments

Comments
 (0)