@xemahq/repo-build-tooling 0.27.1 → 0.27.3
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
CHANGED
|
@@ -30,7 +30,7 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
30
30
|
| `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
|
|
31
31
|
| `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
|
|
32
32
|
| `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `stage-tree`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
|
-
| `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The final stage only COPYs |
|
|
33
|
+
| `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The stage reaches the build as the named context `xema-service` (`--build-context xema-service=.xema-stage`); the authoritative name is `STAGED_SERVICE_CONTEXT` in `@xemahq/distribution-source-hash`, whose image hash frames such a service from its tracked tree, closure, turbo globalDependencies and tsconfig chain, so the stage must be built from no other repository input. The final stage only COPYs |
|
|
34
34
|
| `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
|
|
35
35
|
| `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
|
|
36
36
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.3",
|
|
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)",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"@xemahq/release-policy": "^16.0.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
|
-
"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-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.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/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
|
|
50
|
+
"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-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.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/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs && node --test src/staged-image-proof.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"xemaRelease": {
|
|
53
|
-
"sourceDigest": "sha256:
|
|
53
|
+
"sourceDigest": "sha256:d6ac6c711e4edb7ef2d2fa6531089a4f443381fd1e622ee2a060eb36a229ee6c"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// for the members' closures only and checked to pin nothing new, then production dependencies are
|
|
5
5
|
// installed from the runner's pnpm store alone (`--offline`, `--ignore-scripts`), selected for the
|
|
6
6
|
// TARGET platform (`--os/--cpu/--libc`) so no foreign-platform binary is staged.
|
|
7
|
+
//
|
|
8
|
+
// The re-resolve runs in a scratch copy of the tree's manifests, never in the tree: pnpm 10.33 `--lockfile-only`
|
|
9
|
+
// with a filter that leaves workspace projects out still links a full `node_modules` (dev dependencies included),
|
|
10
|
+
// and the `--prod` install after it does not prune it, so dev-only packages would ship and be scanned.
|
|
7
11
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
9
13
|
import { tmpdir } from 'node:os';
|
|
10
14
|
import { join } from 'node:path';
|
|
11
15
|
|
|
@@ -31,21 +35,41 @@ export const productionInstallCommand = (filters, arch) => [
|
|
|
31
35
|
'--os=linux', `--cpu=${nodeArch(arch)}`, `--libc=${libcOf(arch)}`,
|
|
32
36
|
];
|
|
33
37
|
|
|
38
|
+
/** The root files a resolve reads, beside every workspace `package.json` and patch file. */
|
|
39
|
+
const RESOLVE_ROOT_FILES = Object.freeze(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs']);
|
|
40
|
+
|
|
41
|
+
/** Copy what `pnpm install --lockfile-only` reads from `treeRoot` into `resolveRoot`: no sources, no `node_modules`. */
|
|
42
|
+
export function copyResolveInputs(treeRoot, resolveRoot, directory = '') {
|
|
43
|
+
for (const entry of readdirSync(join(treeRoot, directory), { withFileTypes: true })) {
|
|
44
|
+
const path = join(directory, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (entry.name !== 'node_modules' && entry.name !== '.git') copyResolveInputs(treeRoot, resolveRoot, path);
|
|
47
|
+
} else if (entry.isFile() && (entry.name === 'package.json' || entry.name.endsWith('.patch') || (directory === '' && RESOLVE_ROOT_FILES.includes(entry.name)))) {
|
|
48
|
+
mkdirSync(join(resolveRoot, directory), { recursive: true });
|
|
49
|
+
copyFileSync(join(treeRoot, path), join(resolveRoot, path));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'inherit' });
|
|
35
55
|
|
|
36
56
|
/**
|
|
37
57
|
* Install the members' production closure into `treeRoot` (which holds the root manifests, the lockfile and the
|
|
38
58
|
* closure's package files). `filters` are the members' literal `--filter <package>...` words. Throws when the
|
|
39
|
-
* re-resolve pinned a registry package the committed lockfile does not.
|
|
59
|
+
* re-resolve pinned a registry package the committed lockfile does not. Only the re-resolved `pnpm-lock.yaml` comes
|
|
60
|
+
* back from the scratch copy, so the tree's `node_modules` is the production install's alone.
|
|
40
61
|
*/
|
|
41
62
|
export function installClosure({ treeRoot, filters, arch, exec = defaultExec }) {
|
|
42
63
|
const scratch = mkdtempSync(join(tmpdir(), 'xema-composed-lock-'));
|
|
43
64
|
try {
|
|
44
65
|
const committed = join(scratch, 'pnpm-lock.committed.yaml');
|
|
45
66
|
copyFileSync(join(treeRoot, 'pnpm-lock.yaml'), committed);
|
|
67
|
+
const resolveRoot = join(scratch, 'resolve');
|
|
68
|
+
copyResolveInputs(treeRoot, resolveRoot);
|
|
46
69
|
const [resolveFile, ...resolveArgs] = lockfileOnlyCommand(filters);
|
|
47
|
-
exec(resolveFile, resolveArgs, { cwd:
|
|
48
|
-
assertPinsUnchanged({ root:
|
|
70
|
+
exec(resolveFile, resolveArgs, { cwd: resolveRoot });
|
|
71
|
+
assertPinsUnchanged({ root: resolveRoot, committedLockfile: committed });
|
|
72
|
+
copyFileSync(join(resolveRoot, 'pnpm-lock.yaml'), join(treeRoot, 'pnpm-lock.yaml'));
|
|
49
73
|
const [installFile, ...installArgs] = productionInstallCommand(filters, arch);
|
|
50
74
|
exec(installFile, installArgs, { cwd: treeRoot });
|
|
51
75
|
// pnpm 10.33 frozen offline install does not filter by libc; cpu/os do.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -15,11 +16,23 @@ function tree(committed) {
|
|
|
15
16
|
return treeRoot;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
test('the re-resolve then the production install
|
|
19
|
+
test('the re-resolve runs in a scratch copy of the manifests, then the production install runs in the tree, offline and frozen, for the target platform, with the literal filters', () => {
|
|
19
20
|
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
21
|
+
fs.writeFileSync(path.join(treeRoot, 'package.json'), '{"name":"root"}');
|
|
22
|
+
fs.mkdirSync(path.join(treeRoot, 'apps/a/dist'), { recursive: true });
|
|
23
|
+
fs.writeFileSync(path.join(treeRoot, 'apps/a/package.json'), '{"name":"@t/a"}');
|
|
24
|
+
fs.writeFileSync(path.join(treeRoot, 'apps/a/dist/main.js'), '');
|
|
20
25
|
const calls = [];
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const exec = (file, args, options) => {
|
|
27
|
+
const seen = fs.readdirSync(options.cwd, { recursive: true }).sort();
|
|
28
|
+
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(options.cwd, 'pnpm-lock.yaml'), `${lock('left-pad@1.3.0')}# re-resolved\n`);
|
|
29
|
+
calls.push({ line: [file, ...args], cwd: options.cwd, seen });
|
|
30
|
+
};
|
|
31
|
+
installClosure({ treeRoot, filters: FILTERS, arch: 'arm64', exec });
|
|
32
|
+
assert.notEqual(calls[0].cwd, treeRoot);
|
|
33
|
+
assert.deepEqual(calls[0].seen, ['apps', 'apps/a', 'apps/a/package.json', 'package.json', 'pnpm-lock.yaml']);
|
|
34
|
+
assert.equal(calls[1].cwd, treeRoot);
|
|
35
|
+
assert.match(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /# re-resolved/, 'the re-resolved lockfile came back into the tree');
|
|
23
36
|
assert.deepEqual(calls[0].line, lockfileOnlyCommand(FILTERS));
|
|
24
37
|
assert.deepEqual(calls[1].line, productionInstallCommand(FILTERS, 'arm64'));
|
|
25
38
|
const install = calls[1].line.join(' ');
|
|
@@ -32,10 +45,43 @@ test('the re-resolve then the production install run in the tree, offline and fr
|
|
|
32
45
|
test('a re-resolution that pins a registry package the committed lockfile does not stops before the install', () => {
|
|
33
46
|
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
34
47
|
const calls = [];
|
|
35
|
-
const exec = (file, args) => {
|
|
48
|
+
const exec = (file, args, options) => {
|
|
36
49
|
calls.push(args);
|
|
37
|
-
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(
|
|
50
|
+
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(options.cwd, 'pnpm-lock.yaml'), lock('left-pad@1.3.0', 'evil@9.9.9'));
|
|
38
51
|
};
|
|
39
52
|
assert.throws(() => installClosure({ treeRoot, filters: FILTERS, arch: 'amd64', exec }), /pinned registry package\(s\) the committed lockfile does not:\n {2}- evil@9\.9\.9/);
|
|
40
53
|
assert.equal(calls.length, 1, 'the production install never ran');
|
|
54
|
+
assert.doesNotMatch(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /evil/, 'the refused lockfile never reached the tree');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Real pnpm, no registry: `file:` packages only. pnpm 10.33 `--lockfile-only` with a filter that leaves a workspace
|
|
58
|
+
// project out links a full node_modules, dev dependencies included; the `--prod` install after it never prunes them.
|
|
59
|
+
test('after the install the tree holds the production closure only: no dev-only package, even when the filter leaves a project out', () => {
|
|
60
|
+
const treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-install-real-'));
|
|
61
|
+
const write = (file, json) => {
|
|
62
|
+
fs.mkdirSync(path.dirname(path.join(treeRoot, file)), { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(treeRoot, file), JSON.stringify(json));
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
write('package.json', { name: 'root', private: true });
|
|
67
|
+
fs.writeFileSync(path.join(treeRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
|
68
|
+
write('vendor/prod-only/package.json', { name: 'prod-only', version: '1.0.0' });
|
|
69
|
+
write('vendor/dev-only/package.json', { name: 'dev-only', version: '1.0.0' });
|
|
70
|
+
const deps = { dependencies: { 'prod-only': 'file:../../vendor/prod-only' }, devDependencies: { 'dev-only': 'file:../../vendor/dev-only' } };
|
|
71
|
+
write('apps/lib/package.json', { name: '@t/lib', version: '1.0.0', ...deps });
|
|
72
|
+
write('apps/a/package.json', { name: '@t/a', version: '1.0.0', ...deps, dependencies: { ...deps.dependencies, '@t/lib': 'workspace:*' } });
|
|
73
|
+
write('apps/other/package.json', { name: '@t/other', version: '1.0.0', devDependencies: deps.devDependencies });
|
|
74
|
+
execFileSync('pnpm', ['install', '--lockfile-only', '--offline'], { cwd: treeRoot, stdio: 'ignore' });
|
|
75
|
+
|
|
76
|
+
// CI=true as on the runner, where pnpm keeps a modules directory it would otherwise refuse to reuse without a TTY.
|
|
77
|
+
const exec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'pipe', env: { ...process.env, CI: 'true' } });
|
|
78
|
+
installClosure({ treeRoot, filters: ['--filter', '@t/a...'], arch: 'arm64', exec });
|
|
79
|
+
|
|
80
|
+
const entries = fs.readdirSync(path.join(treeRoot, 'node_modules', '.pnpm'));
|
|
81
|
+
assert.ok(entries.some((entry) => entry.startsWith('prod-only@')), `the prod dependency is installed: ${entries}`);
|
|
82
|
+
assert.ok(!entries.some((entry) => entry.startsWith('dev-only@')), `no dev-only package is installed: ${entries}`);
|
|
83
|
+
assert.match(fs.readFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), 'utf8'), /injectWorkspacePackages: true/, 'the tree holds the re-resolved lockfile');
|
|
84
|
+
} finally {
|
|
85
|
+
fs.rmSync(treeRoot, { recursive: true, force: true });
|
|
86
|
+
}
|
|
41
87
|
});
|
|
@@ -24,12 +24,23 @@ export const CONTEXT_DIRECTORY = 'context';
|
|
|
24
24
|
* context, which puts it in the content tag.
|
|
25
25
|
*/
|
|
26
26
|
export const ROOT_BUILD_INPUT = /^(pnpm-lock\.yaml|package\.json|pnpm-workspace\.yaml|\.npmrc|\.pnpmfile\.cjs|turbo\.json|tsconfig[^/]*\.json)$/;
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The repository-root files that are build inputs (`ROOT_BUILD_INPUT`), sorted. A root build input
|
|
29
|
+
* that is not a regular file (a symlink, a directory) is refused, naming it: the content tag frames
|
|
30
|
+
* a symlink's link text, not the file the build reads, so skipping or following it would let the
|
|
31
|
+
* staged bytes move under one tag.
|
|
32
|
+
*/
|
|
33
|
+
export const rootBuildInputNames = (root) => {
|
|
34
|
+
const entries = readdirSync(root, { withFileTypes: true }).filter((entry) => ROOT_BUILD_INPUT.test(entry.name));
|
|
35
|
+
const irregular = entries.filter((entry) => !entry.isFile()).map((entry) => entry.name);
|
|
36
|
+
if (irregular.length > 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`root build input(s) ${irregular.sort().map((name) => `"${name}"`).join(', ')} in "${root}" are not regular files ` +
|
|
39
|
+
`(a symlink or a directory); the content tag cannot bind them, so commit each as a regular file`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return entries.map((entry) => entry.name).sort();
|
|
43
|
+
};
|
|
33
44
|
export const TOOL_NAME = '@xemahq/repo-build-tooling';
|
|
34
45
|
const TOOL_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
46
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { after, test } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { rootBuildInputNames } from './staged-image-proof.mjs';
|
|
8
|
+
|
|
9
|
+
const roots = [];
|
|
10
|
+
after(() => {
|
|
11
|
+
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
const freshRoot = () => {
|
|
14
|
+
const root = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'xema-staged-image-proof-')));
|
|
15
|
+
roots.push(root);
|
|
16
|
+
return root;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test('rootBuildInputNames lists the regular root build inputs, sorted, and nothing else', () => {
|
|
20
|
+
const root = freshRoot();
|
|
21
|
+
for (const name of ['turbo.json', 'package.json', 'tsconfig.base.json', 'README.md', '.node-version']) {
|
|
22
|
+
writeFileSync(join(root, name), '{}\n');
|
|
23
|
+
}
|
|
24
|
+
mkdirSync(join(root, 'services'));
|
|
25
|
+
assert.deepEqual(rootBuildInputNames(root), ['package.json', 'tsconfig.base.json', 'turbo.json']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a symlinked root build input refuses, naming it', () => {
|
|
29
|
+
const root = freshRoot();
|
|
30
|
+
writeFileSync(join(root, 'package.json'), '{}\n');
|
|
31
|
+
mkdirSync(join(root, 'tooling'));
|
|
32
|
+
writeFileSync(join(root, 'tooling', 'turbo.json'), '{}\n');
|
|
33
|
+
symlinkSync('tooling/turbo.json', join(root, 'turbo.json'));
|
|
34
|
+
assert.throws(() => rootBuildInputNames(root), /root build input\(s\) "turbo\.json" .* are not regular files/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a directory named like a root build input refuses', () => {
|
|
38
|
+
const root = freshRoot();
|
|
39
|
+
mkdirSync(join(root, 'tsconfig.json'));
|
|
40
|
+
assert.throws(() => rootBuildInputNames(root), /"tsconfig\.json" .* are not regular files/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a symlink that is not a root build input is not this check\'s concern', () => {
|
|
44
|
+
const root = freshRoot();
|
|
45
|
+
writeFileSync(join(root, 'package.json'), '{}\n');
|
|
46
|
+
symlinkSync('package.json', join(root, 'README.md'));
|
|
47
|
+
assert.deepEqual(rootBuildInputNames(root), ['package.json']);
|
|
48
|
+
});
|