create-stitchkit 0.3.3 → 0.4.1
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.
- package/CHANGELOG.md +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- package/template/packages/config/src/identity.ts +0 -18
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { ProjectDeclaration } from 'stitchkit/declaration';
|
|
7
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
8
|
+
import { assertDeclaredBuildInputs } from './build-inputs';
|
|
9
|
+
|
|
10
|
+
function digestOf(text: string): string {
|
|
11
|
+
return `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withInput(path: string, digest: string): ProjectDeclaration {
|
|
15
|
+
const build = appDeclaration.build;
|
|
16
|
+
if (!build) throw new Error('the template declares a build');
|
|
17
|
+
return {
|
|
18
|
+
...appDeclaration,
|
|
19
|
+
build: { ...build, inputs: [{ name: 'catalogue', path, digest }] },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('this template declares no build inputs, and that is the answer', () => {
|
|
24
|
+
// Not "we forgot to say": the frontend cannot reach a data source at all
|
|
25
|
+
// (`check-authored` refuses the import), so the build is a function of the
|
|
26
|
+
// source alone. If a route ever needs data at build time, it declares it.
|
|
27
|
+
expect(appDeclaration.build?.inputs).toBeUndefined();
|
|
28
|
+
expect(() => assertDeclaredBuildInputs()).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a declared input whose bytes changed is refused by name', async () => {
|
|
32
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(join(directory, 'catalogue.json'), '{"items":2}');
|
|
35
|
+
// The digest of what the author froze — not of what is on disk now. This is
|
|
36
|
+
// the whole failure mode: the filename still resolves, so nothing else in
|
|
37
|
+
// the build notices that two builds of one source now differ.
|
|
38
|
+
const stale = digestOf('{"items":1}');
|
|
39
|
+
expect(() =>
|
|
40
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', stale), directory),
|
|
41
|
+
).toThrow(/"catalogue".*no longer matches its digest/s);
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a declared input that matches its digest passes', async () => {
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
49
|
+
try {
|
|
50
|
+
const frozen = '{"items":2}';
|
|
51
|
+
await writeFile(join(directory, 'catalogue.json'), frozen);
|
|
52
|
+
expect(() =>
|
|
53
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', digestOf(frozen)), directory),
|
|
54
|
+
).not.toThrow();
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(directory, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a declared input that is not there names itself', async () => {
|
|
61
|
+
const directory = await mkdtemp(join(tmpdir(), 'starter-inputs-'));
|
|
62
|
+
try {
|
|
63
|
+
expect(() =>
|
|
64
|
+
assertDeclaredBuildInputs(withInput('catalogue.json', digestOf('{}')), directory),
|
|
65
|
+
).toThrow(/Declared build input "catalogue" is missing/);
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(directory, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import type { ProjectDeclaration } from 'stitchkit/declaration';
|
|
5
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
6
|
+
|
|
7
|
+
const root = resolve(import.meta.dir, '..');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The third kind of input, checked before the build reads it.
|
|
11
|
+
*
|
|
12
|
+
* The boundary rule separates code from the values of a place. Data read while
|
|
13
|
+
* building is neither: it is not in the source and it is not a binding, so a
|
|
14
|
+
* build that reads it is a function of something nobody declared. That is the
|
|
15
|
+
* dependency that never announces itself — it works on the machine that happens
|
|
16
|
+
* to have the database, and the artifact quietly stops being a function of the
|
|
17
|
+
* source.
|
|
18
|
+
*
|
|
19
|
+
* Three answers are legitimate, per route rather than per project: render at
|
|
20
|
+
* runtime (the default, and what this template does), read a frozen export
|
|
21
|
+
* whose digest is declared here, or generate the bytes as a release step. This
|
|
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 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.
|
|
26
|
+
*/
|
|
27
|
+
export function assertDeclaredBuildInputs(
|
|
28
|
+
declaration: ProjectDeclaration = appDeclaration,
|
|
29
|
+
from: string = root,
|
|
30
|
+
): void {
|
|
31
|
+
for (const input of declaration.build?.inputs ?? []) {
|
|
32
|
+
const path = resolve(from, input.path);
|
|
33
|
+
let bytes: Buffer;
|
|
34
|
+
try {
|
|
35
|
+
bytes = readFileSync(path);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Declared build input "${input.name}" is missing: ${input.path}. A build input is a frozen export inside the source — restore it, or stop declaring it and read the data at runtime.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
42
|
+
if (actual !== input.digest) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Declared build input "${input.name}" (${input.path}) no longer matches its digest.\n declared: ${input.digest}\n actual: ${actual}\nTwo builds of one source would produce different bytes. Re-freeze the export and update the digest in project.json, or drop the input and render the data at runtime.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (import.meta.main) {
|
|
51
|
+
assertDeclaredBuildInputs();
|
|
52
|
+
const declared = appDeclaration.build?.inputs ?? [];
|
|
53
|
+
console.log(
|
|
54
|
+
declared.length === 0
|
|
55
|
+
? 'This build reads no declared data.'
|
|
56
|
+
: `${declared.length} declared build input(s) match their digests.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,12 +7,13 @@ const rootFiles = ['playwright.config.ts', 'ecosystem.config.cjs', 'ecosystem.de
|
|
|
7
7
|
const processEnvMarker = ['process', 'env'].join('.');
|
|
8
8
|
const replacedThemePackage = ['next', 'themes'].join('-');
|
|
9
9
|
const generatedDirectories = new Set(['.git', '.next', 'dist', 'node_modules']);
|
|
10
|
+
// The supervision files used to read the environment directly to build an argv
|
|
11
|
+
// for the web role. They no longer do — a role reads its own bindings — so they
|
|
12
|
+
// are no longer environment boundaries, and this list is smaller by two.
|
|
10
13
|
const processEnvBoundaries = new Set([
|
|
11
14
|
'packages/frontend/src/env.ts',
|
|
12
15
|
'packages/config/src/server.ts',
|
|
13
16
|
'scripts/tooling-env.ts',
|
|
14
|
-
'ecosystem.config.cjs',
|
|
15
|
-
'ecosystem.dev.config.cjs',
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
function lineAt(source: string, offset: number): number {
|
|
@@ -52,6 +53,21 @@ function inspect(path: string, source: string): string[] {
|
|
|
52
53
|
failures.push(
|
|
53
54
|
`${path}: server-only dependency ${dependency} crossed the browser boundary`,
|
|
54
55
|
);
|
|
56
|
+
// Two separate reasons meet on this line, and the second one is the
|
|
57
|
+
// quiet one. Shipping a database client to the browser is the obvious
|
|
58
|
+
// failure; the other is that a route reaching a data source makes the
|
|
59
|
+
// BUILD depend on data — bytes that are neither code nor a binding, so
|
|
60
|
+
// the artifact stops being a function of the source and starts being a
|
|
61
|
+
// function of whichever machine had the database. Three answers are
|
|
62
|
+
// legitimate, chosen per route: render at runtime (what this template
|
|
63
|
+
// does), declare a frozen export as `build.inputs` in `project.json`
|
|
64
|
+
// and let `scripts/build-inputs.ts` pin its digest, or generate the
|
|
65
|
+
// bytes as a release step.
|
|
66
|
+
if (path.startsWith('packages/frontend/src/app/')) {
|
|
67
|
+
failures.push(
|
|
68
|
+
`${path}: a route reading data makes the build depend on it — render at runtime, declare the export in build.inputs, or generate it as a release step`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
73
|
}
|