create-stitchkit 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/UPGRADING.md +117 -0
  3. package/dist/cli.js +5 -1
  4. package/examples/repository/scripts/runtime-smoke.ts +14 -2
  5. package/package.json +4 -2
  6. package/template/AGENTS.md +15 -5
  7. package/template/README.md +46 -8
  8. package/template/_env.example +8 -0
  9. package/template/_gitignore +1 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +115 -98
  12. package/template/package.json +9 -8
  13. package/template/packages/backend/package.json +2 -2
  14. package/template/packages/backend/src/cleanup.ts +121 -0
  15. package/template/packages/backend/src/index.ts +12 -3
  16. package/template/packages/config/package.json +3 -1
  17. package/template/packages/config/src/declaration.ts +1 -1
  18. package/template/packages/config/src/shutdown.ts +20 -0
  19. package/template/packages/db/package.json +2 -2
  20. package/template/packages/frontend/package.json +11 -11
  21. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  22. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  23. package/template/packages/shared/package.json +1 -1
  24. package/template/scripts/acceptance-database.test.ts +73 -0
  25. package/template/scripts/acceptance-database.ts +92 -0
  26. package/template/scripts/acceptance-local.ts +144 -0
  27. package/template/scripts/build-inputs.test.ts +1 -1
  28. package/template/scripts/build-inputs.ts +4 -3
  29. package/template/scripts/build-stamp.test.ts +151 -0
  30. package/template/scripts/build-stamp.ts +169 -0
  31. package/template/scripts/client-boundary.test.ts +117 -0
  32. package/template/scripts/client-boundary.ts +148 -0
  33. package/template/scripts/declaration.ts +10 -7
  34. package/template/scripts/deployment-preflight.ts +41 -0
  35. package/template/scripts/dev.ts +8 -6
  36. package/template/scripts/local-env.ts +9 -3
  37. package/template/scripts/readiness.ts +92 -0
  38. package/template/scripts/release-steps.ts +5 -1
  39. package/template/scripts/release.ts +8 -0
  40. package/template/scripts/runtime-smoke.test.ts +178 -0
  41. package/template/scripts/runtime-smoke.ts +15 -2
  42. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  43. package/template/scripts/shutdown-budget.test.ts +164 -0
  44. package/template/scripts/surface-conformance.ts +8 -1
  45. package/template/scripts/tooling-env.ts +30 -1
  46. package/template/scripts/web-surface-smoke.ts +125 -14
  47. package/template/packages/config/src/project-declaration.generated.ts +0 -611
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The runtime gates, against a deployment this script owns.
3
+ *
4
+ * `runtime:smoke` and `e2e` check a RUNNING deployment, so a gate list has to
5
+ * bring one up. The wrong way to do that — and the way this replaced — is
6
+ * `pm2:prod`: it applies the declared migrations and reloads the developer's
7
+ * own PM2 daemon, so "run these before handing work off" quietly meant "deploy".
8
+ * A gate may not mutate a deployment; it may only create and destroy its own.
9
+ *
10
+ * So everything here is this run's and nothing else's: its own `PM2_HOME`, its
11
+ * own ephemeral ports, its own public-host allowlist, its own database, and a
12
+ * stop that names the roles the declaration declares rather than deleting
13
+ * whatever the daemon happened to hold.
14
+ *
15
+ * The database is the last thing this owned. It used to inherit `DATABASE_URL`,
16
+ * which made a GATE a writer: the repository example's smoke posts a refresh,
17
+ * and that upserts. It now runs against `ACCEPTANCE_DATABASE_URL` and refuses to
18
+ * start when that is unset or names the deployment's own database — and the
19
+ * deployment's URL is not in the child environment at all, so no role here can
20
+ * reach it even by asking.
21
+ */
22
+ import { mkdtemp, rm } from 'node:fs/promises';
23
+ import { tmpdir } from 'node:os';
24
+ import { join, resolve } from 'node:path';
25
+ import { appDeclaration } from '../packages/config/src/declaration';
26
+ import { resolveAcceptanceDatabase } from './acceptance-database';
27
+ import { awaitRolesAnswering, declaredRoleReadiness } from './readiness';
28
+ import { assertBuildArtifacts, runDeclaredReleaseSteps } from './release-steps';
29
+ import { deploymentEnvironment } from './tooling-env';
30
+
31
+ const root = resolve(import.meta.dir, '..');
32
+
33
+ function freePort(): number {
34
+ const listener = Bun.listen({
35
+ hostname: '127.0.0.1',
36
+ port: 0,
37
+ socket: { data: () => undefined },
38
+ });
39
+ const port = listener.port;
40
+ listener.stop(true);
41
+ return port;
42
+ }
43
+
44
+ async function run(command: string[], env: Record<string, string>): Promise<void> {
45
+ const child = Bun.spawn(command, {
46
+ cwd: root,
47
+ env,
48
+ stdin: 'inherit',
49
+ stdout: 'inherit',
50
+ stderr: 'inherit',
51
+ });
52
+ const exitCode = await child.exited;
53
+ if (exitCode !== 0) throw new Error(`${command.join(' ')} failed with exit code ${exitCode}`);
54
+ }
55
+
56
+ if (!Bun.which('pm2')) {
57
+ throw new Error(
58
+ 'pm2 is required to run the local acceptance gate. Install it with `bun add --global pm2`, then rerun.',
59
+ );
60
+ }
61
+
62
+ assertBuildArtifacts();
63
+
64
+ const home = await mkdtemp(join(tmpdir(), 'acceptance-local-'));
65
+ const apiPort = freePort();
66
+ const webPort = freePort();
67
+ const apiOrigin = `http://127.0.0.1:${apiPort}`;
68
+ const webOrigin = `http://127.0.0.1:${webPort}`;
69
+ const base = deploymentEnvironment(root);
70
+ // Before anything starts, and before the deployment's own URL is copied into
71
+ // the child environment: a refusal here costs a message, a missing one costs
72
+ // rows in someone's database.
73
+ const acceptanceDatabaseUrl = resolveAcceptanceDatabase(base);
74
+ const environment: Record<string, string> = {};
75
+ for (const [name, value] of Object.entries(base)) {
76
+ if (value !== undefined) environment[name] = value;
77
+ }
78
+ delete environment.ACCEPTANCE_DATABASE_URL;
79
+ Object.assign(environment, {
80
+ NODE_ENV: 'production',
81
+ // The one address every role, every migration and every gate here will see.
82
+ DATABASE_URL: acceptanceDatabaseUrl,
83
+ PM2_HOME: home,
84
+ API_PORT: String(apiPort),
85
+ WEB_PORT: String(webPort),
86
+ INTERNAL_API_URL: apiOrigin,
87
+ SMOKE_API_ORIGIN: apiOrigin,
88
+ SMOKE_WEB_ORIGIN: webOrigin,
89
+ PLAYWRIGHT_BASE_URL: webOrigin,
90
+ // The portability proof asks this deployment to answer as addresses other
91
+ // than the one it is dialled on. Those addresses belong to the CHECK, so
92
+ // they are supplied here rather than written into the project's own
93
+ // `.env.example`, where they would be policy a deployment carries for real.
94
+ PUBLIC_WEB_HOSTS: `127.0.0.1:${webPort},alpha.example,beta.example:8443`,
95
+ });
96
+
97
+ // Boxed, so a thrown `undefined` is still recorded as a failure.
98
+ let failure: { error: unknown } | undefined;
99
+ try {
100
+ // The declared migrations, against the acceptance database. A deployment is
101
+ // brought to its source before its roles start — that is a property of this
102
+ // deployment too, and skipping it here would mean the gates run against
103
+ // whatever schema the throwaway database happened to have.
104
+ await runDeclaredReleaseSteps(environment);
105
+ await run(['pm2', 'start', 'ecosystem.config.cjs', '--update-env'], environment);
106
+ await awaitRolesAnswering(declaredRoleReadiness(appDeclaration, environment));
107
+ await run(['bun', 'run', 'runtime:smoke'], environment);
108
+ await run(['bun', 'run', 'e2e'], environment);
109
+ console.log(
110
+ `Local acceptance passed: ${appDeclaration.roles.map((role) => role.name).join(' and ')} answered on ephemeral ports, and both runtime gates are green`,
111
+ );
112
+ } catch (error) {
113
+ failure = { error };
114
+ }
115
+
116
+ // Only what this run started, named from the declaration. `pm2 delete all`
117
+ // would delete every application in the daemon it is pointed at — and pointing
118
+ // it at the wrong home is one forgotten variable away.
119
+ const cleanupFailures: unknown[] = [];
120
+ for (const step of [
121
+ () =>
122
+ run(
123
+ [
124
+ 'pm2',
125
+ 'delete',
126
+ ...appDeclaration.roles.map((role) => `${appDeclaration.identity.slug}-${role.name}`),
127
+ ],
128
+ environment,
129
+ ),
130
+ () => run(['pm2', 'kill'], environment),
131
+ () => rm(home, { recursive: true, force: true }),
132
+ ]) {
133
+ try {
134
+ await step();
135
+ } catch (error) {
136
+ cleanupFailures.push(error);
137
+ }
138
+ }
139
+
140
+ const failures = [...(failure ? [failure.error] : []), ...cleanupFailures];
141
+ if (failures.length === 1) throw failures[0];
142
+ if (failures.length > 1) {
143
+ throw new AggregateError(failures, 'Local acceptance failed, and so did its cleanup');
144
+ }
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto';
3
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
+ import type { ProjectDeclaration } from 'stitchkit/declaration';
6
7
  import { appDeclaration } from '../packages/config/src/declaration';
7
- import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
8
8
  import { assertDeclaredBuildInputs } from './build-inputs';
9
9
 
10
10
  function digestOf(text: string): string {
@@ -1,8 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
+ import type { ProjectDeclaration } from 'stitchkit/declaration';
4
5
  import { appDeclaration } from '../packages/config/src/declaration';
5
- import type { ProjectDeclaration } from '../packages/config/src/project-declaration.generated';
6
6
 
7
7
  const root = resolve(import.meta.dir, '..');
8
8
 
@@ -20,8 +20,9 @@ const root = resolve(import.meta.dir, '..');
20
20
  * runtime (the default, and what this template does), read a frozen export
21
21
  * whose digest is declared here, or generate the bytes as a release step. This
22
22
  * file owns the second one. It is deliberately a no-op for a project that
23
- * declares no inputs — absent means "this build reads no data", which is an
24
- * answer, not a gap.
23
+ * declares no inputs — absent means "this build reads no DECLARED data", which
24
+ * is an answer rather than a gap. It is the author's statement, not a traced
25
+ * fact: nothing here sandboxes the build to see what it really opened.
25
26
  */
26
27
  export function assertDeclaredBuildInputs(
27
28
  declaration: ProjectDeclaration = appDeclaration,
@@ -0,0 +1,151 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { appDeclaration } from '../packages/config/src/declaration';
6
+ import {
7
+ assertArtifactMatchesSource,
8
+ BUILD_STAMP_PATH,
9
+ sourceDigest,
10
+ writeBuildStamp,
11
+ } from './build-stamp';
12
+
13
+ const created: string[] = [];
14
+ afterAll(async () => {
15
+ for (const path of created) await rm(path, { recursive: true, force: true });
16
+ });
17
+
18
+ async function tree(files: Record<string, string>): Promise<string> {
19
+ const root = await mkdtemp(join(tmpdir(), 'build-stamp-'));
20
+ created.push(root);
21
+ for (const [relative, contents] of Object.entries(files)) {
22
+ const path = join(root, relative);
23
+ await mkdir(join(path, '..'), { recursive: true });
24
+ await writeFile(path, contents);
25
+ }
26
+ return root;
27
+ }
28
+
29
+ describe('a release starts this source, not a previous one', () => {
30
+ test('a stamped tree passes its own check', async () => {
31
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n' });
32
+ writeBuildStamp(root, []);
33
+ expect(() => assertArtifactMatchesSource(root, [])).not.toThrow();
34
+ });
35
+
36
+ test('an edited source file makes the artifact refuse to start', async () => {
37
+ // The failure this exists for: `pm2:prod` without a build applies THIS
38
+ // source's migrations and starts the previous source's `dist`. The schema
39
+ // moves ahead of the code, and rolling the code back does not undo it.
40
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n' });
41
+ writeBuildStamp(root, []);
42
+ await writeFile(join(root, 'src/index.ts'), 'export const a = 2;\n');
43
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
44
+ });
45
+
46
+ test('a new source file counts, without anyone listing it', async () => {
47
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n' });
48
+ writeBuildStamp(root, []);
49
+ await writeFile(join(root, 'src/added.ts'), 'export const b = 2;\n');
50
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
51
+ });
52
+
53
+ test('a moved file changes the digest even with every byte unchanged', async () => {
54
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n' });
55
+ const before = sourceDigest(root, []);
56
+ await rm(join(root, 'src/index.ts'));
57
+ await writeFile(join(root, 'src/moved.ts'), 'export const a = 1;\n');
58
+ expect(sourceDigest(root, [])).not.toBe(before);
59
+ });
60
+
61
+ test('a binding is not an input — editing `.env` does not refuse the artifact', async () => {
62
+ // Deliberate, and the one exclusion that is a JUDGEMENT rather than a fact
63
+ // about outputs. This project forbids build-time environment reads —
64
+ // `check-authored` refuses direct environment access outside its declared
65
+ // boundaries, and the packed lane builds against a database that accepts
66
+ // nothing — so hashing
67
+ // `.env` would refuse a correct artifact every time a deployment edited its
68
+ // own environment.
69
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n', '.env': 'PORT=1\n' });
70
+ writeBuildStamp(root, []);
71
+ await writeFile(join(root, '.env'), 'PORT=2\n');
72
+ expect(() => assertArtifactMatchesSource(root, [])).not.toThrow();
73
+ });
74
+
75
+ test('a Markdown file counts, because a project may import one', async () => {
76
+ // The digest used to skip every `.md`, every test file and every directory
77
+ // called `generated`. All three are true of THIS project today and none is
78
+ // true by construction: MDX is an import, and a checked-in directory may be
79
+ // called anything. A digest that answers "fresh" about a stale build is the
80
+ // failure the stamp exists to prevent.
81
+ const root = await tree({
82
+ 'src/index.ts': 'export const a = 1;\n',
83
+ 'content/post.md': 'before\n',
84
+ });
85
+ writeBuildStamp(root, []);
86
+ await writeFile(join(root, 'content/post.md'), 'after\n');
87
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
88
+ });
89
+
90
+ test('a directory merely NAMED generated is source', async () => {
91
+ const root = await tree({
92
+ 'src/index.ts': 'export const a = 1;\n',
93
+ 'src/generated/checked-in.ts': 'export const kept = 1;\n',
94
+ });
95
+ writeBuildStamp(root, []);
96
+ await writeFile(join(root, 'src/generated/checked-in.ts'), 'export const kept = 2;\n');
97
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
98
+ });
99
+
100
+ test('a test file counts too', async () => {
101
+ const root = await tree({
102
+ 'src/index.ts': 'export const a = 1;\n',
103
+ 'src/index.test.ts': 'test("x", () => {});\n',
104
+ });
105
+ writeBuildStamp(root, []);
106
+ await writeFile(join(root, 'src/index.test.ts'), 'test("y", () => {});\n');
107
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
108
+ });
109
+
110
+ test('what the DECLARATION calls output is skipped, by path and not by name', async () => {
111
+ // The one list a deployment tool already reads. `packages/db/src/generated`
112
+ // is skipped because `build.artifacts` says it is produced, not because of
113
+ // the word in it.
114
+ const artifacts = ['packages/db/src/generated', 'packages/frontend/.next'];
115
+ const root = await tree({
116
+ 'src/index.ts': 'export const a = 1;\n',
117
+ 'node_modules/pkg/index.js': 'module.exports = 1;\n',
118
+ 'packages/db/src/generated/client.ts': 'export const generated = 1;\n',
119
+ 'packages/frontend/.next/build-manifest.json': '{}\n',
120
+ });
121
+ writeBuildStamp(root, artifacts);
122
+ await writeFile(join(root, 'node_modules/pkg/index.js'), 'module.exports = 2;\n');
123
+ await writeFile(
124
+ join(root, 'packages/db/src/generated/client.ts'),
125
+ 'export const generated = 2;\n',
126
+ );
127
+ await writeFile(join(root, 'packages/frontend/.next/build-manifest.json'), '{"c":1}\n');
128
+ expect(() => assertArtifactMatchesSource(root, artifacts)).not.toThrow();
129
+ });
130
+
131
+ test('an undeclared build output is NOT skipped', async () => {
132
+ // Falsification for the test above: without it, a helper that skipped every
133
+ // `.next` anywhere would pass it just as well.
134
+ const root = await tree({
135
+ 'src/index.ts': 'export const a = 1;\n',
136
+ 'packages/frontend/.next/build-manifest.json': '{}\n',
137
+ });
138
+ writeBuildStamp(root, []);
139
+ await writeFile(join(root, 'packages/frontend/.next/build-manifest.json'), '{"c":1}\n');
140
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(/not this source's/);
141
+ });
142
+
143
+ test('this project declares its outputs, so the default skips them', async () => {
144
+ expect(appDeclaration.build?.artifacts).toContain('packages/db/src/generated');
145
+ });
146
+
147
+ test('no stamp at all is refused, not assumed fresh', async () => {
148
+ const root = await tree({ 'src/index.ts': 'export const a = 1;\n' });
149
+ expect(() => assertArtifactMatchesSource(root, [])).toThrow(new RegExp(BUILD_STAMP_PATH));
150
+ });
151
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * What the artifact was built FROM, so a release can tell whether it still is.
3
+ *
4
+ * `assertBuildArtifacts()` checked that the declared paths exist. Existence is
5
+ * not freshness: `pm2:prod` run without a build applies the migrations this
6
+ * source declares and then starts the `dist` and `.next` of an earlier one. The
7
+ * schema moves ahead of the code, which is the worst direction — rolling the
8
+ * code back does not roll the schema back with it.
9
+ *
10
+ * So the build leaves a stamp: one digest over the source it read. The release
11
+ * recomputes it and compares. A digest, not a timestamp, because a checkout
12
+ * rewrites every mtime and a formatter rewrites some for no change at all —
13
+ * both would make the gate cry wolf, and a gate nobody believes is worse than
14
+ * none.
15
+ *
16
+ * **Everything is source until something says otherwise, and the something is
17
+ * named.** The first version of this listed what to skip by *kind* — every
18
+ * `.md`, every test file, every directory called `generated` — which reads as
19
+ * "these cannot affect a build" and is only true of the project as it stands
20
+ * today. A project that imports MDX, or keeps checked-in source in a directory
21
+ * it happened to call `generated`, would change its content, keep its digest,
22
+ * and be told its old artifact was current. A digest that answers "fresh" about
23
+ * a stale build is worse than no digest.
24
+ *
25
+ * So the exclusions are now exactly three kinds, each named rather than guessed:
26
+ *
27
+ * - **The build's own OUTPUTS**, read from `build.artifacts` in the project
28
+ * declaration. One source of truth: a project that adds an artifact is
29
+ * covered by declaring it, and `packages/db/src/generated` is skipped because
30
+ * the declaration calls it output — not because of its name.
31
+ * - **Not this project's source at all**: `node_modules`, `.git`. (`bun.lock`
32
+ * is source and is hashed.)
33
+ * - **Runtime state**: `.env*`, logs, and the directories a test run writes.
34
+ * `.env` is deliberate and not an oversight — a binding is not an input to
35
+ * this build, which is the whole point of forbidding `NEXT_PUBLIC_*` env
36
+ * reads and of building the packed lane against a database that accepts
37
+ * nothing. Hashing it would refuse a correct artifact every time a deployment
38
+ * edited its own environment: the same gate, crying wolf in the other
39
+ * direction.
40
+ *
41
+ * The cost is that editing a README or a test now asks for a rebuild before a
42
+ * release. That is the safe direction: the gate can be wrong about "stale", and
43
+ * must never be wrong about "fresh".
44
+ */
45
+ import { createHash } from 'node:crypto';
46
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
47
+ import { join, relative, resolve } from 'node:path';
48
+ import { z } from 'zod';
49
+ import { appDeclaration } from '../packages/config/src/declaration';
50
+
51
+ const root = resolve(import.meta.dir, '..');
52
+
53
+ export const BUILD_STAMP_PATH = '.build-stamp.json';
54
+
55
+ /** Not this project's source, and state a test run leaves behind. */
56
+ const IGNORED_DIRECTORIES = new Set([
57
+ 'node_modules',
58
+ '.git',
59
+ 'coverage',
60
+ 'test-results',
61
+ 'playwright-report',
62
+ ]);
63
+
64
+ function isIgnoredFile(name: string): boolean {
65
+ return (
66
+ name === BUILD_STAMP_PATH ||
67
+ // Written by `next typegen`, which `bun run check` runs — a generated file
68
+ // sitting at a package root rather than inside a declared artifact, and one
69
+ // that exists or not depending on whether anyone has run `check` yet.
70
+ name === 'next-env.d.ts' ||
71
+ // A binding, not an input. See the note above.
72
+ name.startsWith('.env') ||
73
+ name.endsWith('.log')
74
+ );
75
+ }
76
+
77
+ /** The declared outputs, as normalised relative paths. */
78
+ function outputPaths(artifacts: readonly string[]): string[] {
79
+ return artifacts.map((artifact) => artifact.replaceAll('\\', '/').replace(/\/+$/, ''));
80
+ }
81
+
82
+ function sourceFiles(
83
+ from: string,
84
+ outputs: readonly string[],
85
+ directory: string = from,
86
+ found: string[] = [],
87
+ ): string[] {
88
+ for (const entry of readdirSync(directory).sort()) {
89
+ const path = join(directory, entry);
90
+ const relativePath = relative(from, path).replaceAll('\\', '/');
91
+ if (outputs.includes(relativePath)) continue;
92
+ if (statSync(path).isDirectory()) {
93
+ if (IGNORED_DIRECTORIES.has(entry)) continue;
94
+ sourceFiles(from, outputs, path, found);
95
+ } else if (!isIgnoredFile(entry)) {
96
+ found.push(path);
97
+ }
98
+ }
99
+ return found;
100
+ }
101
+
102
+ /**
103
+ * One digest over every file a build of this project reads.
104
+ *
105
+ * `artifacts` defaults to what the declaration says the build produces, so the
106
+ * one list a deployment tool reads is the one this skips.
107
+ */
108
+ export function sourceDigest(
109
+ from: string = root,
110
+ artifacts: readonly string[] = appDeclaration.build?.artifacts ?? [],
111
+ ): string {
112
+ const digest = createHash('sha256');
113
+ for (const path of sourceFiles(from, outputPaths(artifacts))) {
114
+ // The PATH goes in too: moving a file changes the build without changing
115
+ // any byte inside it.
116
+ digest.update(relative(from, path));
117
+ digest.update('\0');
118
+ digest.update(readFileSync(path));
119
+ digest.update('\0');
120
+ }
121
+ return `sha256:${digest.digest('hex')}`;
122
+ }
123
+
124
+ const BuildStampSchema = z.object({
125
+ schemaVersion: z.literal(1),
126
+ source: z.string().startsWith('sha256:'),
127
+ });
128
+
129
+ export function writeBuildStamp(
130
+ from: string = root,
131
+ artifacts: readonly string[] = appDeclaration.build?.artifacts ?? [],
132
+ ): string {
133
+ const source = sourceDigest(from, artifacts);
134
+ writeFileSync(
135
+ join(from, BUILD_STAMP_PATH),
136
+ `${JSON.stringify({ schemaVersion: 1, source }, null, 2)}\n`,
137
+ );
138
+ return source;
139
+ }
140
+
141
+ /**
142
+ * Refuse an artifact that is not this source's.
143
+ *
144
+ * A missing stamp is refused too, and deliberately: it means the artifact was
145
+ * produced by something other than this build, and "we cannot tell" is not a
146
+ * reason to start it.
147
+ */
148
+ export function assertArtifactMatchesSource(
149
+ from: string = root,
150
+ artifacts: readonly string[] = appDeclaration.build?.artifacts ?? [],
151
+ ): void {
152
+ const path = join(from, BUILD_STAMP_PATH);
153
+ if (!existsSync(path)) {
154
+ throw new Error(
155
+ `No build stamp beside the artifacts (${BUILD_STAMP_PATH}). Run \`bun run build\` — a release will not start an artifact it cannot tie to this source.`,
156
+ );
157
+ }
158
+ const stamp = BuildStampSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
159
+ const current = sourceDigest(from, artifacts);
160
+ if (stamp.source !== current) {
161
+ throw new Error(
162
+ `The built artifacts are not this source's: the stamp says ${stamp.source.slice(0, 19)}…, the tree hashes to ${current.slice(0, 19)}…. Run \`bun run build\` before releasing — otherwise the declared migrations of this source are applied to a deployment running the previous one.`,
163
+ );
164
+ }
165
+ }
166
+
167
+ if (import.meta.main) {
168
+ console.log(`Build stamped: ${writeBuildStamp()}`);
169
+ }
@@ -0,0 +1,117 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, relative, resolve } from 'node:path';
5
+ import {
6
+ type ClientBoundaryScan,
7
+ clientEntries,
8
+ findDeclarationLeaks,
9
+ specifiers,
10
+ } from './client-boundary';
11
+
12
+ const root = resolve(import.meta.dir, '..');
13
+ const project: ClientBoundaryScan = {
14
+ root,
15
+ frontendSrc: join(root, 'packages/frontend/src'),
16
+ declaration: join(root, 'packages/config/src/declaration.ts'),
17
+ };
18
+
19
+ describe('the declaration stays out of the browser', () => {
20
+ test('the fixture actually has client components to walk', () => {
21
+ expect(clientEntries(project).length).toBeGreaterThan(0);
22
+ });
23
+
24
+ test('no client graph reaches the project declaration', () => {
25
+ expect(
26
+ findDeclarationLeaks(project).map((chain) =>
27
+ chain.map((file) => relative(root, file)).join(' → '),
28
+ ),
29
+ ).toEqual([]);
30
+ });
31
+ });
32
+
33
+ describe('the scanner fails however the import is written', () => {
34
+ // A check that only recognises the one spelling the leak had the first time
35
+ // is a check that passes while the leak is back. These fixtures write it the
36
+ // two other ways it can come back.
37
+ const created: string[] = [];
38
+ afterAll(() => {
39
+ for (const path of created) rmSync(path, { recursive: true, force: true });
40
+ });
41
+
42
+ function fixture(files: Record<string, string>): ClientBoundaryScan {
43
+ const base = mkdtempSync(join(tmpdir(), 'client-boundary-'));
44
+ created.push(base);
45
+ const write = (relativePath: string, content: string): void => {
46
+ const path = join(base, relativePath);
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, content);
49
+ };
50
+ write(
51
+ 'packages/config/package.json',
52
+ JSON.stringify({
53
+ name: '@app/config',
54
+ exports: { './declaration': './src/declaration.ts' },
55
+ }),
56
+ );
57
+ write('packages/config/src/declaration.ts', 'export const appDeclaration = {};\n');
58
+ for (const [path, content] of Object.entries(files)) write(path, content);
59
+ return {
60
+ root: base,
61
+ frontendSrc: join(base, 'packages/frontend/src'),
62
+ declaration: join(base, 'packages/config/src/declaration.ts'),
63
+ };
64
+ }
65
+
66
+ test('a barrel that re-exports the declaration is a leak', () => {
67
+ const scan = fixture({
68
+ 'packages/frontend/src/page.tsx':
69
+ "'use client';\nimport { appDeclaration } from './lib';\nexport const value = appDeclaration;\n",
70
+ 'packages/frontend/src/lib/index.ts': "export * from '@app/config/declaration';\n",
71
+ });
72
+ expect(findDeclarationLeaks(scan)).toHaveLength(1);
73
+ });
74
+
75
+ test('a relative path into the config package is a leak', () => {
76
+ // No `@app/` prefix anywhere in the file, so a check on the specifier
77
+ // string sees nothing at all.
78
+ const scan = fixture({
79
+ 'packages/frontend/src/page.tsx':
80
+ "'use client';\nimport { appDeclaration } from '../../config/src/declaration';\nexport const value = appDeclaration;\n",
81
+ });
82
+ expect(findDeclarationLeaks(scan)).toHaveLength(1);
83
+ });
84
+
85
+ test('a double-quoted import is a leak too', () => {
86
+ const scan = fixture({
87
+ 'packages/frontend/src/page.tsx':
88
+ '\'use client\';\nimport { appDeclaration } from "@app/config/declaration";\nexport const value = appDeclaration;\n',
89
+ });
90
+ expect(findDeclarationLeaks(scan)).toHaveLength(1);
91
+ });
92
+
93
+ test('a client graph that reaches nothing forbidden is clean', () => {
94
+ // The control: without it every assertion above would also pass with a
95
+ // scanner that reports a leak for anything.
96
+ const scan = fixture({
97
+ 'packages/frontend/src/page.tsx':
98
+ "'use client';\nimport { name } from './lib';\nexport const value = name;\n",
99
+ 'packages/frontend/src/lib/index.ts': "export const name = 'x';\n",
100
+ });
101
+ expect(findDeclarationLeaks(scan)).toEqual([]);
102
+ });
103
+
104
+ test('both quote styles and side-effect imports are read', () => {
105
+ expect(
106
+ specifiers(
107
+ [
108
+ "import a from 'single';",
109
+ 'import b from "double";',
110
+ "import 'side-effect';",
111
+ 'const c = await import("dynamic");',
112
+ "export { d } from 'reexport';",
113
+ ].join('\n'),
114
+ ),
115
+ ).toEqual(['single', 'double', 'reexport', 'side-effect', 'dynamic']);
116
+ });
117
+ });