@xemahq/repo-build-tooling 0.23.0 → 0.25.0
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/README.md +1 -1
- package/package.json +6 -4
- package/src/composed-image-fixture.test.mjs +7 -3
- package/src/composed-image-in-image-closure.test.mjs +2 -2
- package/src/composed-image-package-files.mjs +33 -0
- package/src/composed-image-pnpm-context.test.mjs +3 -3
- package/src/composed-image-prebuilt-dockerfile.mjs +10 -12
- package/src/composed-image-prebuilt-dockerfile.test.mjs +37 -3
- package/src/composed-image.mjs +4 -3
- package/src/install-git-hooks.mjs +323 -0
- package/src/install-git-hooks.test.mjs +85 -0
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
37
37
|
| `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents, and `test` and `openapi:current` (where defined) only on the packages the push changed; a fleet-wide change runs only `lint` and `typecheck` on the full graph; root `lint` only without turbo; a park-only push (`refs/xema-session/*`) skips |
|
|
38
38
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
39
39
|
|
|
40
|
-
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--cache-id`, `--build-source
|
|
40
|
+
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--cache-id`, `--build-source` and `--base <ref@sha256:…>`, the digest-pinned image the stages start FROM; a reference without a digest is refused; `--copy` and `--stage-dir` optional). The assemble stage copies per package only `package.json` and its plain `files` entries (what `pnpm pack` ships), so a README or test edit changes no image; a package without a `files` array or with a glob entry is refused; `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. `pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)` runs on the CI runner after `pnpm install --frozen-lockfile` and builds the offline `xema-pnpm` build context (`staged.json`, `corepack/pnpm.tgz`, and, with `--image-dir` only, a warmed `cache/` of registry metadata; `--subject` writes no `cache/` and `staged.json` says `cache: false`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` on stdout (everything else on stderr) and refuses a non-empty `<dir>`. `dockerfile --prebuilt` renders an OFFLINE image: every install RUN is `--network=none` and reads the runner's pnpm store and the `pnpm-context` output as the named build contexts `xema-pnpm-store` and `xema-pnpm`; without them the build fails with a message (no network fallback). `pnpm rebuild` runs only for `onlyBuiltDependencies` in the members' prod closure (less prisma, @prisma/client, @prisma/engines, @nestjs/core, esbuild, @swc/core) and the render needs `pnpm install` first; a node-gyp module with no optional prebuilt packages is refused. `pnpm-context --image-dir` reads the deps stage's COPY directories from the committed Dockerfile. Run `xema-composed-image` with no arguments for the full usage.
|
|
41
41
|
|
|
42
42
|
Every step refuses rather than skips. The package has no dependencies.
|
|
43
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
26
26
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
27
27
|
"xema-composed-image": "src/composed-image.mjs",
|
|
28
|
+
"xema-install-git-hooks": "src/install-git-hooks.mjs",
|
|
28
29
|
"xema-prepush-sanity": "src/prepush-sanity.mjs",
|
|
29
30
|
"xema-promote-image": "src/promote-image.mjs",
|
|
30
31
|
"xema-readiness": "src/readiness.mjs",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
38
39
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
39
40
|
"./composed-image": "./src/composed-image.mjs",
|
|
41
|
+
"./install-git-hooks": "./src/install-git-hooks.mjs",
|
|
40
42
|
"./package.json": "./package.json",
|
|
41
43
|
"./prepush-sanity": "./src/prepush-sanity.mjs",
|
|
42
44
|
"./promote-image": "./src/promote-image.mjs",
|
|
@@ -46,12 +48,12 @@
|
|
|
46
48
|
"./service-image": "./src/service-image.mjs"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
|
-
"@xemahq/release-policy": "^
|
|
51
|
+
"@xemahq/release-policy": "^16.0.0"
|
|
50
52
|
},
|
|
51
53
|
"scripts": {
|
|
52
|
-
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs"
|
|
54
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs && node --test src/install-git-hooks.test.mjs"
|
|
53
55
|
},
|
|
54
56
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
57
|
+
"sourceDigest": "sha256:60086692452e7d72167195fe99ab5634a798fd8b2461a6e0b09bb27b894b7aa4"
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -9,6 +9,8 @@ export function write(root, rel, content) {
|
|
|
9
9
|
fs.writeFileSync(path.join(root, rel), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export const BASE = 'ghcr.io/example/base/node-runtime@sha256:' + 'a'.repeat(64);
|
|
13
|
+
|
|
12
14
|
export const MEMBERS = Object.freeze({ 'alpha/alpha-api': 'biomes/alpha/api/alpha-api', 'alpha/alpha-db-api': 'biomes/alpha/api/alpha-db-api' });
|
|
13
15
|
export const CLOSURE = Object.freeze(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
|
|
14
16
|
|
|
@@ -26,10 +28,12 @@ export function workspace({ built = true } = {}) {
|
|
|
26
28
|
write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: Object.values(MEMBERS) });
|
|
27
29
|
write(root, 'pnpm-workspace.yaml', "packages:\n - 'biomes/*/api/*'\n\nonlyBuiltDependencies:\n - 'esbuild'\n # a note\n - prisma\n\n - \"@prisma/engines\" # trailing\nother: 1\n");
|
|
28
30
|
write(root, '.npmrc', 'registry=https://registry.npmjs.org/\n');
|
|
29
|
-
write(root, 'biomes/alpha/api/alpha-api/package.json', { name: '@t/alpha-api' });
|
|
30
|
-
write(root, 'biomes/alpha/api/alpha-db-api/package.json', { name: '@t/alpha-db-api' });
|
|
31
|
+
write(root, 'biomes/alpha/api/alpha-api/package.json', { name: '@t/alpha-api', files: ['dist'] });
|
|
32
|
+
write(root, 'biomes/alpha/api/alpha-db-api/package.json', { name: '@t/alpha-db-api', files: ['dist', 'prisma'] });
|
|
31
33
|
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\n');
|
|
32
|
-
write(root, '
|
|
34
|
+
write(root, 'biomes/alpha/api/alpha-api/README.md', '# readme\n');
|
|
35
|
+
write(root, 'biomes/alpha/api/alpha-api/src/main.test.ts', '// test\n');
|
|
36
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist', 'assets/data.json'] });
|
|
33
37
|
if (built) {
|
|
34
38
|
write(root, 'biomes/alpha/api/alpha-api/dist/main.js', '// main\n');
|
|
35
39
|
write(root, 'biomes/alpha/api/alpha-db-api/dist/main.js', '// main\n');
|
|
@@ -9,7 +9,7 @@ import test, { after } from 'node:test';
|
|
|
9
9
|
|
|
10
10
|
import { renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
11
11
|
import { copyTool } from './composed-image-stage.mjs';
|
|
12
|
-
import { fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
12
|
+
import { BASE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
13
13
|
|
|
14
14
|
const temps = [];
|
|
15
15
|
after(() => {
|
|
@@ -28,7 +28,7 @@ function renderedEntries() {
|
|
|
28
28
|
root,
|
|
29
29
|
copy: ['tooling/dev'],
|
|
30
30
|
cacheId: 'alpha-composed-process',
|
|
31
|
-
buildSource: 'alpha',
|
|
31
|
+
buildSource: 'alpha', base: BASE,
|
|
32
32
|
usesToken: true,
|
|
33
33
|
exec: fakePnpm(root),
|
|
34
34
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What one workspace package contributes to the assemble stage's build context: its
|
|
2
|
+
// `package.json` plus the `files` entries `pnpm pack` ships. Nothing else of the directory
|
|
3
|
+
// (README, tests, configs) enters the context, so editing it changes no image.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, posix } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** Directories and files the prebuilt `.dockerignore` keeps out of the context (the staged outputs bring them back). */
|
|
8
|
+
export const IGNORED_DIRECTORIES = Object.freeze(['node_modules', 'dist', '.turbo', 'coverage']);
|
|
9
|
+
export const IGNORED_FILE_PATTERNS = Object.freeze(['*.tsbuildinfo', '*.log']);
|
|
10
|
+
|
|
11
|
+
const GLOB = /[*?[\]{}!]/;
|
|
12
|
+
const ignoredFile = (name) => name.endsWith('.tsbuildinfo') || name.endsWith('.log');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Repository-relative COPY sources for `directory`: `package.json` and each plain `files` entry. An entry the
|
|
16
|
+
* `.dockerignore` excludes (e.g. `dist`) is built output, staged separately, and is not a source here.
|
|
17
|
+
* A package with no `files` array, or a glob entry, is refused: `files` is what says what ships.
|
|
18
|
+
*/
|
|
19
|
+
export function packageSources({ root, directory }) {
|
|
20
|
+
const files = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8')).files;
|
|
21
|
+
if (!Array.isArray(files)) throw new Error(`${directory}/package.json has no "files" field — nothing says which of its files ship in the image`);
|
|
22
|
+
const globs = files.filter((entry) => typeof entry !== 'string' || GLOB.test(entry));
|
|
23
|
+
if (globs.length > 0) throw new Error(`${directory}/package.json "files" has glob or non-string entries (${globs.join(', ')}); list plain paths`);
|
|
24
|
+
const entries = files.map((entry) => posix.normalize(entry).replace(/\/$/, ''));
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry === '.' || entry.startsWith('/') || entry.split('/').includes('..')) throw new Error(`${directory}/package.json "files" entry "${entry}" is not inside the package`);
|
|
27
|
+
}
|
|
28
|
+
const shipped = entries.filter((entry) => {
|
|
29
|
+
const parts = entry.split('/');
|
|
30
|
+
return !parts.some((part) => IGNORED_DIRECTORIES.includes(part)) && !ignoredFile(parts.at(-1));
|
|
31
|
+
});
|
|
32
|
+
return [...new Set(['package.json', ...shipped])].map((entry) => posix.join(directory, entry));
|
|
33
|
+
}
|
|
@@ -6,14 +6,14 @@ import test from 'node:test';
|
|
|
6
6
|
|
|
7
7
|
import { buildPnpmContext, resolveCacheDir } from './composed-image-pnpm-context.mjs';
|
|
8
8
|
import { lockfileOnlyCommand, renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
9
|
-
import { CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
9
|
+
import { BASE, CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
10
10
|
|
|
11
11
|
function setup({ store = true, cache = true } = {}) {
|
|
12
12
|
const root = workspace();
|
|
13
13
|
write(root, 'package.json', { name: 'r', packageManager: 'pnpm@10.33.0+sha512.abc' });
|
|
14
14
|
write(root, 'pnpm-lock.yaml', 'lockfileVersion: 9\n');
|
|
15
15
|
write(root, 'tooling/dev/x.txt', 'x');
|
|
16
|
-
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, copy: ['tooling/dev'], cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) }).dockerfile);
|
|
16
|
+
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, copy: ['tooling/dev'], cacheId: 'x', buildSource: 'repo', base: BASE, usesToken: false, exec: fakePnpm(root) }).dockerfile);
|
|
17
17
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
|
|
18
18
|
const storePath = path.join(home, '.pnpm-store', 'v10');
|
|
19
19
|
const cacheDir = path.join(home, 'cache');
|
|
@@ -89,7 +89,7 @@ test('the lockfile-only command is the one the generated Dockerfile runs (no dri
|
|
|
89
89
|
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--offline']);
|
|
90
90
|
assert.equal(lockfileOnlyCommand(['--filter', 'x'], { offline: false })[3], '--prefer-offline');
|
|
91
91
|
assert.ok(words.includes('--config.inject-workspace-packages=true') && words.includes('--config.dedupe-injected-deps=false'));
|
|
92
|
-
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) });
|
|
92
|
+
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', base: BASE, usesToken: false, exec: fakePnpm(root) });
|
|
93
93
|
assert.ok(text.includes('pnpm install --lockfile-only --offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
|
|
94
94
|
assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
|
|
95
95
|
});
|
|
@@ -9,7 +9,8 @@ import { dirname, join, posix } from 'node:path';
|
|
|
9
9
|
|
|
10
10
|
import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
|
|
11
11
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { npmrcUsesToken } from './composed-image-dockerfile.mjs';
|
|
13
|
+
import { IGNORED_DIRECTORIES, IGNORED_FILE_PATTERNS, packageSources } from './composed-image-package-files.mjs';
|
|
13
14
|
import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
|
|
14
15
|
import { needsPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
15
16
|
|
|
@@ -17,8 +18,10 @@ export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
|
17
18
|
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
18
19
|
const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
20
|
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
21
|
+
const DIGEST_REFERENCE = /^[a-z0-9][a-z0-9._/-]*(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$/;
|
|
20
22
|
|
|
21
|
-
function validate({ copy, cacheId, buildSource, stageDir }) {
|
|
23
|
+
function validate({ copy, cacheId, buildSource, stageDir, base }) {
|
|
24
|
+
if (!DIGEST_REFERENCE.test(base ?? '')) throw new Error(`--base must be an image reference pinned by digest (<ref>@sha256:<64 hex>), got "${base}"`);
|
|
22
25
|
for (const directory of [...(copy ?? []), stageDir]) {
|
|
23
26
|
if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
|
|
24
27
|
throw new Error(`"${directory}" is not a repository-relative directory`);
|
|
@@ -33,12 +36,7 @@ export function renderPrebuiltDockerignore({ stageDir = DEFAULT_STAGE_DIR } = {}
|
|
|
33
36
|
return `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` — DO NOT EDIT.
|
|
34
37
|
# Local build output stays out of the context; the staged outputs carry dist and node_modules/.prisma paths, so they are re-included LAST.
|
|
35
38
|
.git
|
|
36
|
-
|
|
37
|
-
**/dist
|
|
38
|
-
**/.turbo
|
|
39
|
-
**/coverage
|
|
40
|
-
**/*.tsbuildinfo
|
|
41
|
-
**/*.log
|
|
39
|
+
${[...IGNORED_DIRECTORIES.map((d) => `**/${d}`), ...IGNORED_FILE_PATTERNS.map((p) => `**/${p}`)].join('\n')}
|
|
42
40
|
!${stageDir}/**
|
|
43
41
|
`;
|
|
44
42
|
}
|
|
@@ -176,11 +174,12 @@ export function renderPrebuiltDockerfile({
|
|
|
176
174
|
buildSource,
|
|
177
175
|
usesToken,
|
|
178
176
|
stageDir = DEFAULT_STAGE_DIR,
|
|
177
|
+
base,
|
|
179
178
|
exec,
|
|
180
179
|
prodPackages = [],
|
|
181
180
|
}) {
|
|
182
181
|
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
183
|
-
validate({ copy, cacheId, buildSource, stageDir });
|
|
182
|
+
validate({ copy, cacheId, buildSource, stageDir, base });
|
|
184
183
|
const toolingVersion = toolVersion();
|
|
185
184
|
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
186
185
|
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
@@ -203,7 +202,7 @@ export function renderPrebuiltDockerfile({
|
|
|
203
202
|
: '';
|
|
204
203
|
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map(copyLine).join('\n');
|
|
205
204
|
const manifestSources = closure.map((directory) => `${directory}/package.json`);
|
|
206
|
-
const assembleSources = [...closure, ...manifests];
|
|
205
|
+
const assembleSources = [...closure.flatMap((directory) => packageSources({ root, directory })), ...manifests];
|
|
207
206
|
const imageCopy = posix.normalize(`${imageDir}/`);
|
|
208
207
|
const text = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
209
208
|
# Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
|
|
@@ -247,9 +246,8 @@ export function renderPrebuiltDockerfile({
|
|
|
247
246
|
FROM scratch AS xema-pnpm
|
|
248
247
|
FROM scratch AS xema-pnpm-store
|
|
249
248
|
|
|
250
|
-
FROM ${
|
|
249
|
+
FROM ${base} AS base
|
|
251
250
|
RUN corepack enable && \\
|
|
252
|
-
apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
|
|
253
251
|
pnpm config set store-dir /root/.pnpm-store
|
|
254
252
|
|
|
255
253
|
FROM base AS deps
|
|
@@ -5,7 +5,7 @@ import test from 'node:test';
|
|
|
5
5
|
|
|
6
6
|
import { depsCopyDirs, memberProdPackages, onlyBuiltDependencies, renderPrebuiltDockerfile, renderPrebuiltDockerignore, syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
7
7
|
import { parseArguments } from './composed-image.mjs';
|
|
8
|
-
import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
8
|
+
import { BASE, CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
9
9
|
|
|
10
10
|
/** An installed package directory for the prod-closure listing: `{ name, dir }`. */
|
|
11
11
|
function installed(root, name, { gyp = false, optional = {} } = {}) {
|
|
@@ -17,7 +17,7 @@ function installed(root, name, { gyp = false, optional = {} } = {}) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function options(root, extra = {}) {
|
|
20
|
-
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), prodPackages: [], ...extra };
|
|
20
|
+
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', base: BASE, usesToken: true, exec: fakePnpm(root), prodPackages: [], ...extra };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
|
|
@@ -39,7 +39,7 @@ test('one COPY --parents per kind: manifests in deps, package dirs (and biome ma
|
|
|
39
39
|
assert.equal(dockerfile.match(/^COPY --parents/gm).length, 2);
|
|
40
40
|
const [deps, assemble] = dockerfile.split('FROM deps AS assemble');
|
|
41
41
|
assert.match(deps, /COPY tooling\/dev\/ \.\/tooling\/dev\/\nCOPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/package\.json \\\n {4}packages\/sdks\/lib-a\/package\.json \\\n {4}\.\/\n/);
|
|
42
|
-
assert.match(assemble, /COPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api \\\n {4}biomes\/alpha\/api\/alpha-db-api \\\n {4}packages\/sdks\/lib-a \\\n {4}biomes\/alpha\/xema-biome\.json \\\n {4}\.\/\n/);
|
|
42
|
+
assert.match(assemble, /COPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/prisma \\\n {4}packages\/sdks\/lib-a\/package\.json \\\n {4}packages\/sdks\/lib-a\/assets\/data\.json \\\n {4}biomes\/alpha\/xema-biome\.json \\\n {4}\.\/\n/);
|
|
43
43
|
assert.match(assemble, /COPY infra\/composed-process\/ \.\/infra\/composed-process\/\nCOPY \.xema-stage\/out\/ \.\/\n/);
|
|
44
44
|
});
|
|
45
45
|
|
|
@@ -77,6 +77,8 @@ test('--write writes both files; --check refuses either stale, and a closure cha
|
|
|
77
77
|
const targets = syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
78
78
|
assert.deepEqual(targets, ['infra/composed-process/Dockerfile', 'infra/composed-process/Dockerfile.dockerignore']);
|
|
79
79
|
syncPrebuiltDockerfile({ mode: 'check', ...options(root) });
|
|
80
|
+
fs.mkdirSync(path.join(root, 'packages/sdks/lib-b'), { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-b/package.json'), JSON.stringify({ name: '@t/lib-b', files: ['dist'] }));
|
|
80
82
|
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { exec: fakePnpm(root, [...CLOSURE, 'packages/sdks/lib-b']) }) }), /Dockerfile is stale/);
|
|
81
83
|
fs.appendFileSync(path.join(root, 'infra/composed-process/Dockerfile.dockerignore'), 'x\n');
|
|
82
84
|
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile\.dockerignore is stale/);
|
|
@@ -199,3 +201,35 @@ test('the Prisma schema engine step renders in deps only when the prod closure c
|
|
|
199
201
|
const custom = renderPrebuiltDockerfile(options(root, { stageDir: 'build/stage', prodPackages: [installed(root, 'prisma')] })).dockerfile;
|
|
200
202
|
assert.match(custom, /--mount=type=bind,source=build\/stage\/prisma-engines,target=\/xema-prisma-engines/);
|
|
201
203
|
});
|
|
204
|
+
|
|
205
|
+
test('FROM is the digest-pinned base, and the base RUN installs no OS package', () => {
|
|
206
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
207
|
+
assert.match(dockerfile, new RegExp(`^FROM ${BASE} AS base\\nRUN corepack enable && \\\\\\n {4}pnpm config set store-dir /root/\\.pnpm-store\\n`, 'm'));
|
|
208
|
+
assert.doesNotMatch(dockerfile, /apt-get|node:22-bookworm/);
|
|
209
|
+
for (const base of [undefined, 'ghcr.io/example/base/node-runtime', 'ghcr.io/example/base/node-runtime:1', `x@sha256:${'a'.repeat(63)}`]) {
|
|
210
|
+
assert.throws(() => renderPrebuiltDockerfile(options(workspace(), { base })), /--base must be an image reference pinned by digest/);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('the assemble COPY lists per package only package.json and the files entries, never README or tests', () => {
|
|
215
|
+
const assemble = renderPrebuiltDockerfile(options(workspace())).dockerfile.split('FROM deps AS assemble')[1].split('COPY infra/')[0];
|
|
216
|
+
assert.doesNotMatch(assemble, /README|\.test\.|\/src\b/);
|
|
217
|
+
assert.doesNotMatch(assemble, /alpha-api \\\n/, 'a package directory is never copied whole');
|
|
218
|
+
assert.doesNotMatch(assemble, /\/dist\b/, 'built output arrives from the stage dir');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a package with no files field or a glob entry is refused', () => {
|
|
222
|
+
const root = workspace();
|
|
223
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a' }));
|
|
224
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root)), /packages\/sdks\/lib-a\/package\.json has no "files" field/);
|
|
225
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a', files: ['lib/**'] }));
|
|
226
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root)), /glob/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('--check fails when the rendered base or a files entry changed', () => {
|
|
230
|
+
const root = workspace();
|
|
231
|
+
syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
232
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { base: `other/base@sha256:${'b'.repeat(64)}` }) }), /Dockerfile is stale/);
|
|
233
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a', files: ['dist', 'assets/data.json', 'extra'] }));
|
|
234
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile is stale/);
|
|
235
|
+
});
|
package/src/composed-image.mjs
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
// dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>
|
|
73
73
|
// render `<image-dir>/Dockerfile` from the package's template
|
|
74
74
|
// (`composed-image-dockerfile.mjs`).
|
|
75
|
-
// dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
|
|
75
|
+
// dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> --base <ref@sha256:…> [--copy <dir>]… [--stage-dir <dir>]
|
|
76
76
|
// render the Dockerfile that ASSEMBLES the image from `stage` output, and its
|
|
77
77
|
// `Dockerfile.dockerignore` (`composed-image-prebuilt-dockerfile.mjs`).
|
|
78
78
|
//
|
|
@@ -328,11 +328,11 @@ const USAGE =
|
|
|
328
328
|
' load-image <image>\n' +
|
|
329
329
|
' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
|
|
330
330
|
' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
331
|
-
' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
|
|
331
|
+
' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> --base <ref@sha256:…> [--copy <dir>]… [--stage-dir <dir>]';
|
|
332
332
|
|
|
333
333
|
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also', 'subject']);
|
|
334
334
|
const BOOLEAN = new Set(['prebuilt', 'each']);
|
|
335
|
-
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
|
|
335
|
+
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source', 'base',
|
|
336
336
|
'image-name', 'service', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
337
337
|
]);
|
|
338
338
|
|
|
@@ -453,6 +453,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
453
453
|
cacheId: options['cache-id'],
|
|
454
454
|
buildSource: options['build-source'],
|
|
455
455
|
stageDir: options['stage-dir'],
|
|
456
|
+
base: options.base,
|
|
456
457
|
});
|
|
457
458
|
log(`composed prebuilt Dockerfile ${targets.join(', ')} ${options.mode === 'write' ? 'written' : 'current'}`);
|
|
458
459
|
return;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// `xema-install-git-hooks` — run from `prepare`: install the lefthook git hooks, but only where hooks can exist.
|
|
4
|
+
//
|
|
5
|
+
// `prepare` runs on EVERY `pnpm install`, and one of those installs happens
|
|
6
|
+
// inside the service Docker image build, which has neither a `.git` directory
|
|
7
|
+
// (the build context excludes it) nor a `git` binary. A bare
|
|
8
|
+
// `"prepare": "lefthook install"` therefore fails the image build outright:
|
|
9
|
+
//
|
|
10
|
+
// . prepare: Error: exec: "git": executable file not found in $PATH
|
|
11
|
+
// ERROR: failed to solve: process "... pnpm install --offline
|
|
12
|
+
// --filter ${SERVICE}... --frozen-lockfile" exit code: 1
|
|
13
|
+
//
|
|
14
|
+
// That is not a hook problem to work around — it is a category error. Git
|
|
15
|
+
// hooks are a property of a developer's WORKING TREE; an image build has no
|
|
16
|
+
// working tree to hook, so there is nothing to install and nothing has gone
|
|
17
|
+
// wrong. This script says exactly that, out loud, and exits 0.
|
|
18
|
+
//
|
|
19
|
+
// What it does NOT do is swallow a real failure. In an actual git work tree
|
|
20
|
+
// the install must succeed: a non-zero `lefthook install` propagates, because
|
|
21
|
+
// a developer whose pre-push hook silently failed to install is a developer
|
|
22
|
+
// who finds out from CI instead. The two outcomes are distinguished by a
|
|
23
|
+
// deterministic fact — "is there a git work tree here" — never by an `|| true`.
|
|
24
|
+
//
|
|
25
|
+
// ── WHY THIS SCRIPT RESOLVES THE BINARY ITSELF ────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// It used to `spawnSync('lefthook', …)`, i.e. rely on PATH / the caller's
|
|
28
|
+
// `.bin/`. Inside the AGGREGATOR checkout that is unreachable for a carved
|
|
29
|
+
// repository, and the failure was silent in both halves:
|
|
30
|
+
//
|
|
31
|
+
// 1. `lefthook install` bakes an ABSOLUTE path to the binary that ran it
|
|
32
|
+
// into the generated hook (measured: re-running `install` from a
|
|
33
|
+
// different binary rewrites that line). A carved repo's hook was baked by
|
|
34
|
+
// a `pnpm install` run INSIDE that repo — the escape hatch — so it points
|
|
35
|
+
// at `repos/<repo>/node_modules/.pnpm/lefthook-*/bin/lefthook`.
|
|
36
|
+
// 2. That store is exactly what `.claude/rules/package-store-ownership.md`
|
|
37
|
+
// forbids and `tooling/dev/repair-package-stores.mjs` deletes. Afterwards
|
|
38
|
+
// the baked path dangles, and every remaining fallback in lefthook's
|
|
39
|
+
// generated hook misses too: lefthook is not on PATH, and a carved repo
|
|
40
|
+
// root is not an aggregator workspace member, so it has no `node_modules`
|
|
41
|
+
// and no `.bin/` at all.
|
|
42
|
+
// 3. lefthook's generated hook ends its search with
|
|
43
|
+
// `echo "Can't find lefthook in PATH"` — and NO non-zero exit. So the
|
|
44
|
+
// hook printed a warning and returned 0. Measured 2026-08-17: every one
|
|
45
|
+
// of the ten carved repositories' `pre-push` hooks was in this state, so
|
|
46
|
+
// every local push in this checkout was unguarded while LOOKING guarded.
|
|
47
|
+
//
|
|
48
|
+
// The fix is the pattern `package-store-ownership.md` already documents for
|
|
49
|
+
// carved root scripts: resolve by PATH-INDEPENDENT node resolution instead of
|
|
50
|
+
// a binary name. `createRequire(...).resolve('lefthook/bin/index.js')` walks up
|
|
51
|
+
// from this file — `repos/<repo>/node_modules` (absent) → the aggregator's
|
|
52
|
+
// `node_modules` (present) — and in a standalone clone it finds the repo's own
|
|
53
|
+
// copy on the first hop. Same script, both topologies, no per-machine config.
|
|
54
|
+
//
|
|
55
|
+
// ── AND WHY IT HARDENS THE GENERATED HOOK ─────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// Resolving the binary fixes today's breakage. It does not fix the SHAPE of
|
|
58
|
+
// the defect: a baked absolute path can dangle again — another store repair,
|
|
59
|
+
// a `pnpm store prune`, a different machine, a moved checkout — and lefthook's
|
|
60
|
+
// template would once more warn-and-return-0. A gate whose only failure mode
|
|
61
|
+
// is a message nobody reads is the failure this repository names most often.
|
|
62
|
+
//
|
|
63
|
+
// So after installing, every generated hook's dead-end branch is rewritten to
|
|
64
|
+
// print the reason and `exit 1`. An unresolvable lefthook now REFUSES the
|
|
65
|
+
// push instead of waving it through; `git push --no-verify` remains the
|
|
66
|
+
// deliberate, visible bypass.
|
|
67
|
+
//
|
|
68
|
+
// The rewrite ASSERTS rather than assumes: the marker must be present exactly
|
|
69
|
+
// once in freshly generated content, and if lefthook's template ever changes
|
|
70
|
+
// so it is not, this fails loudly instead of leaving hooks quietly unhardened.
|
|
71
|
+
//
|
|
72
|
+
// ── AND WHY IT SWEEPS SUBMODULES ──────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// A carved repository's root is not an aggregator workspace member, so its own
|
|
75
|
+
// `prepare` NEVER runs from an aggregator install — which is why the fix above
|
|
76
|
+
// would otherwise be inert in the only checkout that has the bug. The
|
|
77
|
+
// aggregator install already owns every `node_modules` beneath `repos/`
|
|
78
|
+
// (`package-store-ownership.md`); it therefore owns the hooks baked against
|
|
79
|
+
// them. Each submodule that carries a lefthook config gets the same install +
|
|
80
|
+
// hardening, from the same resolved binary.
|
|
81
|
+
//
|
|
82
|
+
// In a standalone carved clone this loop is naturally inert — those
|
|
83
|
+
// repositories have no submodules — so one byte-identical script serves both.
|
|
84
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
import { spawnSync } from 'node:child_process';
|
|
87
|
+
import { createRequire } from 'node:module';
|
|
88
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
89
|
+
import { dirname, join, resolve } from 'node:path';
|
|
90
|
+
|
|
91
|
+
/** Printed with a stable prefix so the reason is greppable in build logs. */
|
|
92
|
+
const TAG = '[prepare:git-hooks]';
|
|
93
|
+
|
|
94
|
+
/** The directory `prepare` runs in: the repository (or aggregator) root. */
|
|
95
|
+
const ROOT = process.cwd();
|
|
96
|
+
|
|
97
|
+
function skip(reason) {
|
|
98
|
+
console.log(`${TAG} skipped — ${reason}. Nothing to install here.`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function die(message) {
|
|
103
|
+
console.error(`${TAG} ${message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 1. Is there anything to hook at all? ──────────────────────────────────
|
|
108
|
+
//
|
|
109
|
+
// `git rev-parse --git-dir` is the cheapest total answer to "is this a git
|
|
110
|
+
// work tree": it is non-zero outside one, and ENOENT when git is absent
|
|
111
|
+
// entirely. Both are the same verdict — no tree, no hooks.
|
|
112
|
+
// This is a dev tool, not service code, so it may read the process env.
|
|
113
|
+
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
|
|
114
|
+
skip("CI runner: git hooks belong to a developer's working tree");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const probe = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' });
|
|
118
|
+
|
|
119
|
+
if (probe.error?.code === 'ENOENT') {
|
|
120
|
+
skip('no `git` executable on PATH (image build / CI container)');
|
|
121
|
+
}
|
|
122
|
+
if (probe.error) {
|
|
123
|
+
skip(`could not probe for a git work tree (${probe.error.message})`);
|
|
124
|
+
}
|
|
125
|
+
if (probe.status !== 0) {
|
|
126
|
+
skip('not inside a git work tree (installed as a dependency or tarball)');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 2. One-time migration off the hand-rolled `.husky/` directory ─────────
|
|
130
|
+
//
|
|
131
|
+
// This repository used to point `core.hooksPath` at a tracked `.husky/`, wired
|
|
132
|
+
// up by every developer running `git config core.hooksPath .husky` BY HAND —
|
|
133
|
+
// which is why almost nobody was wired up. lefthook writes into `.git/hooks`,
|
|
134
|
+
// and a stale `core.hooksPath` silently wins over it, so the install would
|
|
135
|
+
// report success while no hook ever fired.
|
|
136
|
+
//
|
|
137
|
+
// Cleared ONLY when it points at a directory that no longer exists. A
|
|
138
|
+
// `core.hooksPath` someone set deliberately, to somewhere real, is left alone.
|
|
139
|
+
const hooksPath = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
});
|
|
142
|
+
const configured = (hooksPath.stdout ?? '').trim();
|
|
143
|
+
if (configured.length > 0 && !existsSync(configured)) {
|
|
144
|
+
console.log(
|
|
145
|
+
`${TAG} clearing core.hooksPath -> ${configured} (that directory is gone; ` +
|
|
146
|
+
'it would silently win over the hooks installed below)',
|
|
147
|
+
);
|
|
148
|
+
const unset = spawnSync('git', ['config', '--unset', 'core.hooksPath'], {
|
|
149
|
+
stdio: 'inherit',
|
|
150
|
+
});
|
|
151
|
+
if (unset.status !== 0) {
|
|
152
|
+
die(
|
|
153
|
+
'could not clear core.hooksPath. Run `git config --unset core.hooksPath` ' +
|
|
154
|
+
'yourself, or the hooks below will not fire.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── 3. Resolve the lefthook launcher, PATH-independently ──────────────────
|
|
160
|
+
//
|
|
161
|
+
// See the header. `lefthook`'s npm package exposes `bin/index.js`, a Node
|
|
162
|
+
// launcher that execs the platform binary; the binary reports its OWN absolute
|
|
163
|
+
// path to `lefthook install`, which is the path that gets baked into the hook.
|
|
164
|
+
// So resolving the launcher is enough to fix what gets baked.
|
|
165
|
+
//
|
|
166
|
+
// Resolved from the repository root, not from this file: when installed as a
|
|
167
|
+
// dependency this file lives in a package store that cannot see the repo's
|
|
168
|
+
// own `lefthook` devDependency.
|
|
169
|
+
const require = createRequire(join(ROOT, 'package.json'));
|
|
170
|
+
|
|
171
|
+
let launcher;
|
|
172
|
+
try {
|
|
173
|
+
launcher = require.resolve('lefthook/bin/index.js');
|
|
174
|
+
} catch {
|
|
175
|
+
// Reachable when the tree was installed with `--ignore-scripts` semantics or
|
|
176
|
+
// a filtered/production install that omits root devDependencies. Loud, but
|
|
177
|
+
// not fatal: the hooks are a dev affordance, and the install itself is fine.
|
|
178
|
+
skip('`lefthook` is not installed (root devDependencies were not installed)');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 4. Hardening the generated hook ───────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* lefthook's dead end. Present verbatim in every hook it generates (2.1.10),
|
|
185
|
+
* as the `else` arm of the binary search in `call_lefthook`. It prints and
|
|
186
|
+
* falls out of the function, so the hook exits 0 and the operation proceeds
|
|
187
|
+
* completely unverified.
|
|
188
|
+
*/
|
|
189
|
+
const DEAD_END = ' echo "Can\'t find lefthook in PATH"\n';
|
|
190
|
+
|
|
191
|
+
/** Proof our rewrite is already applied — makes the pass idempotent. */
|
|
192
|
+
const SENTINEL = '# xema:lefthook-unresolved-is-fatal';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The replacement. Single-quoted so nothing in it is expanded by `sh`, and
|
|
196
|
+
* written to stderr because that is where a refusal belongs. `exit 1` inside a
|
|
197
|
+
* shell function exits the whole hook, which is exactly the intent.
|
|
198
|
+
*/
|
|
199
|
+
const REFUSAL = [
|
|
200
|
+
` ${SENTINEL}`,
|
|
201
|
+
" echo 'lefthook is installed as a git hook here, but its binary cannot' >&2",
|
|
202
|
+
" echo 'be found — so THIS HOOK JUST VERIFIED NOTHING.' >&2",
|
|
203
|
+
" echo '' >&2",
|
|
204
|
+
" echo 'Refusing the operation rather than letting it through unguarded.' >&2",
|
|
205
|
+
" echo '' >&2",
|
|
206
|
+
" echo ' Fix: run `pnpm install` in the checkout that owns this tree' >&2",
|
|
207
|
+
" echo ' node_modules. That re-runs prepare, which re-bakes this' >&2",
|
|
208
|
+
" echo ' hook against a binary that exists.' >&2",
|
|
209
|
+
" echo ' Deliberate bypass: git push --no-verify' >&2",
|
|
210
|
+
' exit 1',
|
|
211
|
+
'',
|
|
212
|
+
].join('\n');
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Rewrite one generated hook so an unresolvable lefthook is fatal.
|
|
216
|
+
* @returns {'hardened'|'already'} never silently "nothing to do".
|
|
217
|
+
*/
|
|
218
|
+
function hardenHookFile(file) {
|
|
219
|
+
const source = readFileSync(file, 'utf8');
|
|
220
|
+
|
|
221
|
+
if (source.includes(SENTINEL)) return 'already';
|
|
222
|
+
|
|
223
|
+
const occurrences = source.split(DEAD_END).length - 1;
|
|
224
|
+
if (occurrences !== 1) {
|
|
225
|
+
die(
|
|
226
|
+
`cannot harden ${file}: expected lefthook's "can't find" branch exactly ` +
|
|
227
|
+
`once, found ${occurrences}. lefthook's hook template has changed — ` +
|
|
228
|
+
're-derive this rewrite rather than leaving hooks unhardened.',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeFileSync(file, source.replace(DEAD_END, REFUSAL), { mode: 0o755 });
|
|
233
|
+
return 'hardened';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── 5. Install + harden, here and in every hooked submodule ───────────────
|
|
237
|
+
|
|
238
|
+
/** Absolute path to a work tree's hooks directory, per git itself. */
|
|
239
|
+
function hooksDirOf(cwd) {
|
|
240
|
+
const out = spawnSync(
|
|
241
|
+
'git',
|
|
242
|
+
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
243
|
+
{ cwd, encoding: 'utf8' },
|
|
244
|
+
);
|
|
245
|
+
if (out.status !== 0) return null;
|
|
246
|
+
const dir = (out.stdout ?? '').trim();
|
|
247
|
+
return dir.length > 0 ? dir : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function installIn(cwd, label) {
|
|
251
|
+
const install = spawnSync(process.execPath, [launcher, 'install'], {
|
|
252
|
+
cwd,
|
|
253
|
+
stdio: 'inherit',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (install.error) {
|
|
257
|
+
die(`failed to run \`lefthook install\` in ${label}: ${install.error.message}`);
|
|
258
|
+
}
|
|
259
|
+
if (install.status !== 0) {
|
|
260
|
+
die(
|
|
261
|
+
`\`lefthook install\` exited ${install.status} in ${label}. Git hooks are ` +
|
|
262
|
+
'NOT installed; fix this rather than pushing without them.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const dir = hooksDirOf(cwd);
|
|
267
|
+
if (dir === null || !existsSync(dir)) {
|
|
268
|
+
die(`\`lefthook install\` reported success in ${label} but ${dir ?? 'its hooks directory'} does not exist.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Harden every hook lefthook generated — identified by the search function
|
|
272
|
+
// it writes, so a pre-existing hand-written hook is never touched.
|
|
273
|
+
let hardened = 0;
|
|
274
|
+
for (const name of readdirSync(dir)) {
|
|
275
|
+
if (name.endsWith('.sample')) continue;
|
|
276
|
+
const file = join(dir, name);
|
|
277
|
+
if (!statSync(file).isFile()) continue;
|
|
278
|
+
const body = readFileSync(file, 'utf8');
|
|
279
|
+
if (!body.includes('call_lefthook')) continue;
|
|
280
|
+
if (hardenHookFile(file) === 'hardened') hardened += 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (hardened > 0) {
|
|
284
|
+
console.log(`${TAG} ${label}: hardened ${hardened} hook(s) — an unresolvable lefthook now exits 1.`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
installIn(ROOT, '.');
|
|
289
|
+
|
|
290
|
+
// Submodules that carry their own lefthook config. Read from `.gitmodules`
|
|
291
|
+
// rather than `git submodule foreach`, which would spawn a shell per entry
|
|
292
|
+
// across a 28-submodule tree for a question two greps answer.
|
|
293
|
+
const CONFIGS = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml'];
|
|
294
|
+
|
|
295
|
+
const gitmodules = resolve(ROOT, '.gitmodules');
|
|
296
|
+
if (existsSync(gitmodules)) {
|
|
297
|
+
const listed = spawnSync(
|
|
298
|
+
'git',
|
|
299
|
+
['config', '--file', gitmodules, '--get-regexp', String.raw`^submodule\..*\.path$`],
|
|
300
|
+
{ encoding: 'utf8' },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Exit 1 means "no matching keys", which is a legitimate empty answer.
|
|
304
|
+
if (listed.status !== 0 && listed.status !== 1) {
|
|
305
|
+
die(`could not read submodule paths from ${gitmodules} (exit ${listed.status}).`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const paths = (listed.stdout ?? '')
|
|
309
|
+
.split('\n')
|
|
310
|
+
.map((line) => line.split(' ').slice(1).join(' ').trim())
|
|
311
|
+
.filter((p) => p.length > 0);
|
|
312
|
+
|
|
313
|
+
for (const rel of paths) {
|
|
314
|
+
const dir = resolve(dirname(gitmodules), rel);
|
|
315
|
+
if (!CONFIGS.some((name) => existsSync(join(dir, name)))) continue;
|
|
316
|
+
// An uninitialised submodule is an empty directory, not a work tree.
|
|
317
|
+
if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd: dir, stdio: 'ignore' }).status !== 0) {
|
|
318
|
+
console.log(`${TAG} ${rel}: not initialised — skipping (run \`git submodule update --init\`).`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
installIn(dir, rel);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { after, describe, it } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'install-git-hooks.mjs');
|
|
10
|
+
const made = [];
|
|
11
|
+
|
|
12
|
+
after(() => {
|
|
13
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function tempDir() {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), 'xema-hooks-'));
|
|
18
|
+
made.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(cwd, env = {}) {
|
|
23
|
+
const base = { ...process.env, CI: '', GITHUB_ACTIONS: '' };
|
|
24
|
+
return spawnSync(process.execPath, [SCRIPT], { cwd, env: { ...base, ...env }, encoding: 'utf8' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A git work tree whose `lefthook` is a stub that writes a lefthook-shaped hook. */
|
|
28
|
+
function repoWithFakeLefthook() {
|
|
29
|
+
const dir = tempDir();
|
|
30
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
31
|
+
writeFileSync(join(dir, 'package.json'), '{}');
|
|
32
|
+
const bin = join(dir, 'node_modules', 'lefthook', 'bin');
|
|
33
|
+
mkdirSync(bin, { recursive: true });
|
|
34
|
+
writeFileSync(join(dir, 'node_modules', 'lefthook', 'package.json'), '{"name":"lefthook"}');
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(bin, 'index.js'),
|
|
37
|
+
[
|
|
38
|
+
"const fs = require('node:fs');",
|
|
39
|
+
"fs.mkdirSync('.git/hooks', { recursive: true });",
|
|
40
|
+
"fs.writeFileSync('.git/hooks/pre-push', 'call_lefthook() {\\n if x; then\\n :\\n else\\n echo \"Can\\'t find lefthook in PATH\"\\n fi\\n}\\n');",
|
|
41
|
+
].join('\n'),
|
|
42
|
+
);
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('xema-install-git-hooks', () => {
|
|
47
|
+
for (const env of [{ CI: 'true' }, { GITHUB_ACTIONS: 'true' }]) {
|
|
48
|
+
it(`is a no-op on CI (${Object.keys(env)[0]})`, () => {
|
|
49
|
+
const dir = repoWithFakeLefthook();
|
|
50
|
+
const out = run(dir, env);
|
|
51
|
+
assert.equal(out.status, 0);
|
|
52
|
+
assert.match(out.stdout, /skipped — CI runner/);
|
|
53
|
+
assert.equal(existsSync(join(dir, '.git', 'hooks', 'pre-push')), false);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('is a no-op outside a git work tree', () => {
|
|
58
|
+
const dir = tempDir();
|
|
59
|
+
const out = run(dir, { GIT_CEILING_DIRECTORIES: dirname(dir) });
|
|
60
|
+
assert.equal(out.status, 0);
|
|
61
|
+
assert.match(out.stdout, /not inside a git work tree/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('skips when lefthook is not installed', () => {
|
|
65
|
+
const dir = tempDir();
|
|
66
|
+
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
67
|
+
writeFileSync(join(dir, 'package.json'), '{}');
|
|
68
|
+
const out = run(dir);
|
|
69
|
+
assert.equal(out.status, 0);
|
|
70
|
+
assert.match(out.stdout, /`lefthook` is not installed/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('installs and hardens the hook outside CI, idempotently', () => {
|
|
74
|
+
const dir = repoWithFakeLefthook();
|
|
75
|
+
const first = run(dir);
|
|
76
|
+
assert.equal(first.status, 0, first.stderr);
|
|
77
|
+
assert.match(first.stdout, /hardened 1 hook/);
|
|
78
|
+
const hook = readFileSync(join(dir, '.git', 'hooks', 'pre-push'), 'utf8');
|
|
79
|
+
assert.match(hook, /xema:lefthook-unresolved-is-fatal/);
|
|
80
|
+
assert.match(hook, /exit 1/);
|
|
81
|
+
// Re-running regenerates then re-hardens; the stub rewrites the hook each time.
|
|
82
|
+
const second = run(dir);
|
|
83
|
+
assert.equal(second.status, 0, second.stderr);
|
|
84
|
+
});
|
|
85
|
+
});
|