@xemahq/repo-build-tooling 0.21.3 → 0.23.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 +2 -11
- package/package.json +7 -7
- package/src/composed-image-prisma-engine.mjs +14 -6
- package/src/prepush-sanity.mjs +141 -18
- package/src/prepush-sanity.test.mjs +68 -1
- package/src/service-image-in-image.mjs +194 -0
- package/src/service-image-platform.mjs +128 -0
- package/src/service-image-stage.mjs +253 -0
- package/src/service-image.mjs +60 -0
- package/src/service-image.test.mjs +404 -0
- package/src/buildx-builder-host.mjs +0 -61
- package/src/buildx-builder-limits.mjs +0 -40
- package/src/buildx-builder.mjs +0 -175
- package/src/buildx-builder.test.mjs +0 -232
package/README.md
CHANGED
|
@@ -30,26 +30,17 @@ 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`, `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
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. |
|
|
34
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 |
|
|
35
|
-
| `xema-buildx-builder ensure` | Ensures the runner's persistent, capped buildkitd container (`xema-buildkit-signed-<runner>`, state volume `-state`) is up and is the current buildx builder. Env: `RUNNER_NAME`, `BUILDKIT_IMAGE`, `BUILDKIT_LIMITS` (JSON `cpus`, `memory`, `maxParallelism`, `gcMaxUsedSpace`, all required and positive), optional `BUILDKIT_READY_DEADLINE_SECONDS` (positive integer, default 180). The slot is `RUNNER_NAME` with a trailing `-<hostname>` stripped only when it equals this container's own hostname, so a recreated runner container reuses its builder and cache. Pinned to the runner's cgroup cpuset; caps are clamped so every `xema-buildkit-signed-*` builder on the host fits (memory at most half of `docker info` MemTotal divided by N, CPUs at most NCPU divided by N). On each run it removes this verb's labelled builders, and their `-state` volumes, whose recorded runner container is no longer running; unlabelled containers are never touched. Recreated once when the image, effective caps, config or runner container change. Prints `buildx-boot path= secs= builder=`. |
|
|
36
36
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
37
|
-
| `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents (root `lint` only without turbo;
|
|
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
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`; `--copy` and `--stage-dir` optional); `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
|
|
|
44
|
-
## Security model of `xema-buildx-builder`
|
|
45
|
-
|
|
46
|
-
The persistent builder and its cache mounts are for signed develop and main builds only.
|
|
47
|
-
|
|
48
|
-
- The verb refuses any `GITHUB_EVENT_NAME` other than `push` and `workflow_dispatch`, and any `GITHUB_REF` other than `refs/heads/main` and `refs/heads/develop`, before any docker call. A pull request never touches the persistent builder or its cache.
|
|
49
|
-
- Callers also gate the step on the opt-in org variable: the develop-images lane runs the verb only `if: vars.XEMA_BUILDKIT_MODE == 'persistent'` and uses the stock `setup-buildx-action` otherwise.
|
|
50
|
-
- The builder, its container and its state volume are named `xema-buildkit-signed-<runner>`; the `xema-buildkit-signed-*` prefix is reserved for signed lanes, so no other lane shares those cache mounts.
|
|
51
|
-
- Owner decision D97 (2026-10-14): pull request jobs run on the same runners. The in-process guards above are the accepted mitigation, because only we push to our repositories.
|
|
52
|
-
|
|
53
44
|
## Resolution
|
|
54
45
|
|
|
55
46
|
Inside the `xema-monorepo` aggregator, `@xemahq/repo-build-tooling` resolves to this directory through a workspace override. In an independent repository's CI it resolves from npm by the declared range, so a change here reaches a consumer only after it is published and the consumer's range admits it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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)",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
24
|
"xema-arm64-supplement-needed": "src/arm64-supplement-needed.mjs",
|
|
25
|
-
"xema-buildx-builder": "src/buildx-builder.mjs",
|
|
26
25
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
27
26
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
28
27
|
"xema-composed-image": "src/composed-image.mjs",
|
|
@@ -30,11 +29,11 @@
|
|
|
30
29
|
"xema-promote-image": "src/promote-image.mjs",
|
|
31
30
|
"xema-readiness": "src/readiness.mjs",
|
|
32
31
|
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
33
|
-
"xema-service-bootstrap": "src/service-bootstrap.mjs"
|
|
32
|
+
"xema-service-bootstrap": "src/service-bootstrap.mjs",
|
|
33
|
+
"xema-service-image": "src/service-image.mjs"
|
|
34
34
|
},
|
|
35
35
|
"exports": {
|
|
36
36
|
"./arm64-supplement-needed": "./src/arm64-supplement-needed.mjs",
|
|
37
|
-
"./buildx-builder": "./src/buildx-builder.mjs",
|
|
38
37
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
39
38
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
40
39
|
"./composed-image": "./src/composed-image.mjs",
|
|
@@ -43,15 +42,16 @@
|
|
|
43
42
|
"./promote-image": "./src/promote-image.mjs",
|
|
44
43
|
"./readiness": "./src/readiness.mjs",
|
|
45
44
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
46
|
-
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
45
|
+
"./service-bootstrap": "./src/service-bootstrap.mjs",
|
|
46
|
+
"./service-image": "./src/service-image.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@xemahq/release-policy": "^14.0.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"test": "node
|
|
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"
|
|
53
53
|
},
|
|
54
54
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:1e968ed2990de85680e206c3dcb98c5467bb3c01166ace6b1e81be91e5344fb1"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -52,6 +52,19 @@ export function prismaEnginesDirectories(root) {
|
|
|
52
52
|
|
|
53
53
|
const runVersion = (file) => spawnSync(file, ['--version'], { encoding: 'utf8' });
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Run a schema engine and refuse it unless it reports `enginesVersion`: the check Prisma makes before it
|
|
57
|
+
* decides to download one. Returns the engine's version line.
|
|
58
|
+
*/
|
|
59
|
+
export function verifySchemaEngine(file, enginesVersion, version = runVersion) {
|
|
60
|
+
const result = version(file);
|
|
61
|
+
if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
|
|
62
|
+
const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
|
|
63
|
+
throw new Error(`${file} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
|
|
64
|
+
}
|
|
65
|
+
return String(result.stdout).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Install the staged schema engine into every @prisma/engines directory a prisma CLI resolves.
|
|
57
70
|
* `binaryTarget` and `version` are injectable for tests; by default they are Prisma's own
|
|
@@ -77,12 +90,7 @@ export async function installPrismaSchemaEngine({ root = process.cwd(), stagedDi
|
|
|
77
90
|
const destination = join(enginesDir, fileName);
|
|
78
91
|
copyFileSync(source, destination);
|
|
79
92
|
chmodSync(destination, 0o755);
|
|
80
|
-
|
|
81
|
-
if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
|
|
82
|
-
const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
|
|
83
|
-
throw new Error(`${destination} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
|
|
84
|
-
}
|
|
85
|
-
log(`prisma schema engine: ${destination} (${String(result.stdout).trim()})`);
|
|
93
|
+
log(`prisma schema engine: ${destination} (${verifySchemaEngine(destination, enginesVersion, version)})`);
|
|
86
94
|
installed.push(destination);
|
|
87
95
|
}
|
|
88
96
|
return installed;
|
package/src/prepush-sanity.mjs
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// 10-13/10-14). One verb for every repository's lefthook `pre-push`, so the
|
|
5
5
|
// logic is not copied per repo.
|
|
6
6
|
//
|
|
7
|
-
// With turbo (a `turbo.json` and a turbo dependency): turbo `lint
|
|
8
|
-
//
|
|
7
|
+
// With turbo (a `turbo.json` and a turbo dependency): turbo `lint`, `typecheck`,
|
|
8
|
+
// `test` (unit) and `openapi:current`, each where turbo.json defines it (D136: a cheap
|
|
9
|
+
// pre-push let a stale openapi spec through, and every red costs ~1h of CI). `lint` and
|
|
10
|
+
// `typecheck` run on the packages the pushed commits change plus their dependents; `test`
|
|
11
|
+
// and `openapi:current` only on the packages the push itself changes, and one line names the
|
|
12
|
+
// dependents whose tests are left to CI (an affected-set run took 36 min for a widely used package). A change to the lockfile,
|
|
9
13
|
// turbo.json, or anything in the root package.json other than its `scripts`
|
|
10
14
|
// table invalidates everything and runs the full graph. A repo whose turbo carries no `lint` task also runs its root `lint`
|
|
11
15
|
// script. Without turbo: the root `lint` script only (typecheck needs a build, which the hook never runs; CI has it).
|
|
@@ -13,9 +17,13 @@
|
|
|
13
17
|
// The pushed SHA is what is graded. In place when the tree is clean and the SHA
|
|
14
18
|
// is HEAD, or when no differing path lies in a package the run reads or at the
|
|
15
19
|
// repository root; otherwise from a `git archive` export. A delete-only push
|
|
16
|
-
// (or no ref on stdin) grades nothing.
|
|
20
|
+
// (or no ref on stdin) grades nothing. A push whose every remote ref is a
|
|
21
|
+
// session park (`refs/xema-session/*`) grades nothing either: a park is not a
|
|
22
|
+
// landing, and the work is graded when it lands on a branch.
|
|
17
23
|
//
|
|
18
|
-
//
|
|
24
|
+
// A fleet-wide change runs `lint` and `typecheck` on the full graph and says it
|
|
25
|
+
// leaves `test` and `openapi:current` to CI: never the full suite. Never builds
|
|
26
|
+
// for its own sake, never runs docker, image builds or `pnpm check`.
|
|
19
27
|
//
|
|
20
28
|
// Usage (lefthook `pre-push` with `use_stdin: true`):
|
|
21
29
|
// xema-prepush-sanity
|
|
@@ -60,11 +68,68 @@ export function stripJsonc(text) {
|
|
|
60
68
|
return out;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/** The tasks pre-push runs on the affected packages, in this order, where turbo.json defines them. */
|
|
72
|
+
export const SANITY_TASKS = ['lint', 'typecheck', 'test', 'openapi:current'];
|
|
73
|
+
/** The subset that also runs on the full graph and on dependents; the rest would be the full suite, which is CI's. */
|
|
74
|
+
export const FULL_GRAPH_TASKS = ['lint', 'typecheck'];
|
|
75
|
+
|
|
76
|
+
/** Package directories (a package.json below the root) among tracked file paths; node_modules never counts. */
|
|
77
|
+
export function packageDirs(files) {
|
|
78
|
+
return [...new Set(files.filter((f) => f.endsWith('/package.json') && !f.includes('node_modules/')).map((f) => f.slice(0, -'/package.json'.length)))];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Paths no task reads: root-level markdown and changesets. */
|
|
82
|
+
export function isInert(path) {
|
|
83
|
+
return /^[^/]*\.md$/.test(path) || path.startsWith('.changeset/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Changed paths that sit in no workspace package and that some task may read (tsconfig.base.json, biome.json, tooling/**). They invalidate every package. */
|
|
87
|
+
export function outsidePackages(changed, dirs) {
|
|
88
|
+
return changed.filter((p) => !isInert(p) && !dirs.some((d) => p.startsWith(`${d}/`)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A push with changes whose plan holds no task checked nothing, and must say so rather than pass; null when it is fine. */
|
|
92
|
+
export function emptyPlanFailure(changed, plannedTasks) {
|
|
93
|
+
if (changed.length === 0 || plannedTasks > 0) return null;
|
|
94
|
+
return `no task was planned for a push that changes ${changed.slice(0, 10).join(', ')}${changed.length > 10 ? ` (+${changed.length - 10} more)` : ''} — nothing was checked`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The turbo runs for a push. `range` '' is the full graph: lint and typecheck only. Otherwise lint and
|
|
99
|
+
* typecheck run on the affected set (changed packages plus dependents), while test and openapi:current
|
|
100
|
+
* run only on the packages the push itself changed: a widely used package has dozens of dependents and
|
|
101
|
+
* their tests cost ~36 min (authorization-nest: 78 packages, 195 tasks). CI runs those.
|
|
102
|
+
*/
|
|
103
|
+
export function planRuns(tasks, range) {
|
|
104
|
+
if (!range) {
|
|
105
|
+
const full = tasks.filter((t) => FULL_GRAPH_TASKS.includes(t));
|
|
106
|
+
return { runs: full.length > 0 ? [{ tasks: full, filter: null }] : [], leftToCi: tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t)) };
|
|
107
|
+
}
|
|
108
|
+
const affected = tasks.filter((t) => FULL_GRAPH_TASKS.includes(t));
|
|
109
|
+
const changedOnly = tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t));
|
|
110
|
+
const runs = [];
|
|
111
|
+
if (affected.length > 0) runs.push({ tasks: affected, filter: `...[${range}]` });
|
|
112
|
+
if (changedOnly.length > 0) runs.push({ tasks: changedOnly, filter: `[${range}]` });
|
|
113
|
+
return { runs, leftToCi: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Package names in `affected` that are not in `changed`, sorted; the root workspace `//` is never a package. */
|
|
117
|
+
export function dependentsOnly(affected, changed) {
|
|
118
|
+
const own = new Set(changed);
|
|
119
|
+
return [...new Set(affected)].filter((n) => n !== '//' && !own.has(n)).sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The one line naming the dependents whose test and openapi:current are left to CI. */
|
|
123
|
+
export function dependentsLine(dependents, tasks) {
|
|
124
|
+
if (dependents.length === 0 || tasks.length === 0) return '';
|
|
125
|
+
return `pre-push: ${tasks.join(' ')} ran only on the changed packages; ${dependents.length} dependent(s) are left to CI: ${dependents.join(', ')}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
63
128
|
/** The sanity tasks a turbo.json defines. An unparsable file throws: "no tasks" would report green. */
|
|
64
129
|
export function turboTasks(text) {
|
|
65
130
|
const cfg = JSON.parse(stripJsonc(text));
|
|
66
131
|
const defined = cfg.tasks ?? cfg.pipeline ?? {};
|
|
67
|
-
return
|
|
132
|
+
return SANITY_TASKS.filter((t) => t in defined);
|
|
68
133
|
}
|
|
69
134
|
|
|
70
135
|
/** The refs git writes on a pre-push hook's stdin, one sha graded once, deletes dropped. */
|
|
@@ -80,6 +145,18 @@ export function parseRefs(stdin) {
|
|
|
80
145
|
return refs;
|
|
81
146
|
}
|
|
82
147
|
|
|
148
|
+
/** Remote refs under this prefix are session parks, not landings. */
|
|
149
|
+
export const PARK_REF_PREFIX = 'refs/xema-session/';
|
|
150
|
+
|
|
151
|
+
/** True when stdin names at least one ref and every remote ref is a session park. A branch anywhere in the push grades the push. */
|
|
152
|
+
export function parkOnly(stdin) {
|
|
153
|
+
const remoteRefs = stdin
|
|
154
|
+
.split('\n')
|
|
155
|
+
.map((line) => line.trim().split(/\s+/)[2])
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
return remoteRefs.length > 0 && remoteRefs.every((ref) => ref.startsWith(PARK_REF_PREFIX));
|
|
158
|
+
}
|
|
159
|
+
|
|
83
160
|
/** True when two root package.json texts differ only in the top-level `scripts` key; a missing side or unparseable JSON is false. */
|
|
84
161
|
export function scriptsOnlyChange(baseText, tipText) {
|
|
85
162
|
try {
|
|
@@ -164,9 +241,19 @@ function rangeFor(ctx, base, sha) {
|
|
|
164
241
|
console.error('pre-push: lockfile, turbo.json or root package.json beyond its scripts changed — fleet-wide, running the FULL graph.');
|
|
165
242
|
return '';
|
|
166
243
|
}
|
|
244
|
+
const files = (rev) => (ctx.gitOut(['ls-tree', '-r', '--name-only', rev]) ?? '').split('\n').filter(Boolean);
|
|
245
|
+
const outside = outsidePackages(changed, packageDirs([...files(base), ...files(sha)]));
|
|
246
|
+
if (outside.length > 0) {
|
|
247
|
+
console.error(`pre-push: ${outside.slice(0, 10).join(', ')}${outside.length > 10 ? ` (+${outside.length - 10} more)` : ''} sit outside any workspace package — fleet-wide, running the FULL graph.`);
|
|
248
|
+
return '';
|
|
249
|
+
}
|
|
167
250
|
return `${base}...${sha}`;
|
|
168
251
|
}
|
|
169
252
|
|
|
253
|
+
function changedPaths(ctx, base, sha) {
|
|
254
|
+
return base ? (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean) : [];
|
|
255
|
+
}
|
|
256
|
+
|
|
170
257
|
function turboBin(root, env) {
|
|
171
258
|
const local = join(root, 'node_modules', '.bin', 'turbo');
|
|
172
259
|
if (existsSync(local)) return local;
|
|
@@ -191,8 +278,22 @@ function hasRootLint(dir) {
|
|
|
191
278
|
}
|
|
192
279
|
}
|
|
193
280
|
|
|
281
|
+
/** How many tasks turbo plans for `tasks` under `filter`. */
|
|
282
|
+
function dryTaskCount(ctx, bin, dir, tasks, filter) {
|
|
283
|
+
const dry = spawnSync(bin, ['run', ...tasks, `--filter=${filter}`, '--dry=json'], { cwd: dir, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
284
|
+
if (dry.status !== 0) throw new Error(`turbo --dry=json failed for --filter=${filter}`);
|
|
285
|
+
return (JSON.parse(dry.stdout).tasks ?? []).length;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Package names turbo would run `tasks` for under `filter`; an unreadable plan throws rather than hide a dependent. */
|
|
289
|
+
function dryPackages(ctx, bin, dir, tasks, filter) {
|
|
290
|
+
const dry = spawnSync(bin, ['run', ...tasks, `--filter=${filter}`, '--dry=json'], { cwd: dir, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
291
|
+
if (dry.status !== 0) throw new Error(`turbo --dry=json failed for --filter=${filter}`);
|
|
292
|
+
return JSON.parse(dry.stdout).packages ?? [];
|
|
293
|
+
}
|
|
294
|
+
|
|
194
295
|
/** Run the sanity tasks in `dir`; `range` '' is the full graph. Returns the verdict. */
|
|
195
|
-
function runHere(ctx, dir, range, bin) {
|
|
296
|
+
function runHere(ctx, dir, range, bin, changed) {
|
|
196
297
|
if (!bin) {
|
|
197
298
|
// Without turbo nothing orders the builds a workspace typecheck needs, and the hook never
|
|
198
299
|
// builds: typecheck there is CI's.
|
|
@@ -207,16 +308,29 @@ function runHere(ctx, dir, range, bin) {
|
|
|
207
308
|
return false;
|
|
208
309
|
}
|
|
209
310
|
if (tasks.length === 0) {
|
|
210
|
-
console.log(
|
|
311
|
+
console.log(`pre-push: turbo.json defines none of ${SANITY_TASKS.join(', ')} — FAILED`);
|
|
211
312
|
return false;
|
|
212
313
|
}
|
|
213
|
-
let ok;
|
|
214
314
|
if (range) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
315
|
+
const failure = emptyPlanFailure(changed, dryTaskCount(ctx, bin, dir, tasks, `...[${range}]`));
|
|
316
|
+
if (failure) {
|
|
317
|
+
console.log(`pre-push: ${failure} — FAILED`);
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const { runs, leftToCi } = planRuns(tasks, range);
|
|
322
|
+
if (leftToCi.length > 0) console.log(`pre-push: ${leftToCi.join(' ')} not run on the full graph — that is the full suite, CI runs it.`);
|
|
323
|
+
let ok = true;
|
|
324
|
+
for (const run of runs) {
|
|
325
|
+
const where = run.filter ? (run.filter.startsWith('...') ? `affected by ${range}, plus dependents` : `only the packages ${range} changes`) : 'full graph';
|
|
326
|
+
console.log(`pre-push: turbo run ${run.tasks.join(' ')} (${where})`);
|
|
327
|
+
const filterArgs = run.filter ? [`--filter=${run.filter}`] : [];
|
|
328
|
+
ok = ctx.inherit(bin, ['run', ...run.tasks, ...filterArgs, '--continue=dependencies-successful'], dir) && ok;
|
|
329
|
+
}
|
|
330
|
+
if (range) {
|
|
331
|
+
const changedOnly = tasks.filter((t) => !FULL_GRAPH_TASKS.includes(t));
|
|
332
|
+
const line = dependentsLine(dependentsOnly(dryPackages(ctx, bin, dir, tasks, `...[${range}]`), dryPackages(ctx, bin, dir, tasks, `[${range}]`)), changedOnly);
|
|
333
|
+
if (line) console.log(line);
|
|
220
334
|
}
|
|
221
335
|
// Lint that turbo does not carry is the root `lint` script CI runs.
|
|
222
336
|
if (!tasks.includes('lint') && hasRootLint(dir)) {
|
|
@@ -258,7 +372,7 @@ function exportTo(ctx, rev, dir) {
|
|
|
258
372
|
return spawnSync('sh', ['-c', 'git archive "$1" | tar -x -C "$2"', 'sh', rev, dir], { cwd: ctx.root, env: ctx.env }).status === 0;
|
|
259
373
|
}
|
|
260
374
|
|
|
261
|
-
function gradeFromExport(ctx, sha, base, range, bin) {
|
|
375
|
+
function gradeFromExport(ctx, sha, base, range, bin, changed) {
|
|
262
376
|
const scratch = mkdtempSync(join(tmpdir(), 'xema-prepush-'));
|
|
263
377
|
try {
|
|
264
378
|
const sh = (args) => ctx.git(['-c', 'user.email=gate@local', '-c', 'user.name=gate', ...args], scratch).status === 0;
|
|
@@ -275,7 +389,7 @@ function gradeFromExport(ctx, sha, base, range, bin) {
|
|
|
275
389
|
console.log('pre-push: install of the export failed');
|
|
276
390
|
return false;
|
|
277
391
|
}
|
|
278
|
-
return runHere(ctx, scratch, exportRange, bin);
|
|
392
|
+
return runHere(ctx, scratch, exportRange, bin, changed);
|
|
279
393
|
} finally {
|
|
280
394
|
rmSync(scratch, { recursive: true, force: true });
|
|
281
395
|
}
|
|
@@ -285,12 +399,17 @@ function gradeSha(ctx, ref, bin) {
|
|
|
285
399
|
const { sha } = ref;
|
|
286
400
|
const base = pickBase(ctx, ref);
|
|
287
401
|
const range = rangeFor(ctx, base, sha);
|
|
402
|
+
const changed = changedPaths(ctx, base, sha);
|
|
403
|
+
if (range && changed.length > 0 && changed.every(isInert)) {
|
|
404
|
+
console.log(`pre-push: only paths no task reads changed (${changed.slice(0, 10).join(', ')}) — nothing to grade.`);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
288
407
|
const head = (ctx.gitOut(['rev-parse', 'HEAD']) ?? '').trim();
|
|
289
408
|
const short = (rev) => (ctx.gitOut(['rev-parse', '--short', rev]) ?? rev).trim();
|
|
290
409
|
const clean = (ctx.gitOut(['status', '--porcelain']) ?? '') === '';
|
|
291
410
|
if (sha === head && clean) {
|
|
292
411
|
console.log(`pre-push: grading HEAD ${short(head)} in place (clean tree).`);
|
|
293
|
-
return runHere(ctx, ctx.root, range, bin);
|
|
412
|
+
return runHere(ctx, ctx.root, range, bin, changed);
|
|
294
413
|
}
|
|
295
414
|
if (bin) {
|
|
296
415
|
let tasks = [];
|
|
@@ -301,7 +420,7 @@ function gradeSha(ctx, ref, bin) {
|
|
|
301
420
|
}
|
|
302
421
|
if (tasks.length > 0 && inPlaceSafe(ctx, sha, range, tasks, bin)) {
|
|
303
422
|
console.log(`pre-push: grading ${short(sha)} in place — no differing path is in a package this run reads.`);
|
|
304
|
-
return runHere(ctx, ctx.root, range, bin);
|
|
423
|
+
return runHere(ctx, ctx.root, range, bin, changed);
|
|
305
424
|
}
|
|
306
425
|
}
|
|
307
426
|
console.log(
|
|
@@ -309,7 +428,7 @@ function gradeSha(ctx, ref, bin) {
|
|
|
309
428
|
? `pre-push: grading ${short(sha)} from an export — the tree is dirty, so it is not what is being pushed.`
|
|
310
429
|
: `pre-push: grading ${short(sha)} from an export — the commit being PUSHED, which is not HEAD ${short(head)}.`,
|
|
311
430
|
);
|
|
312
|
-
return gradeFromExport(ctx, sha, base, bin ? range : '', bin);
|
|
431
|
+
return gradeFromExport(ctx, sha, base, bin ? range : '', bin, changed);
|
|
313
432
|
}
|
|
314
433
|
|
|
315
434
|
export function main(stdin) {
|
|
@@ -318,6 +437,10 @@ export function main(stdin) {
|
|
|
318
437
|
console.error('pre-push: not inside a git repository — FAILED');
|
|
319
438
|
return 1;
|
|
320
439
|
}
|
|
440
|
+
if (parkOnly(stdin)) {
|
|
441
|
+
console.log(`pre-push: skipped — every ref is a session park under ${PARK_REF_PREFIX}; parks are not landings, the gate runs when the work lands.`);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
321
444
|
const ctx = createContext(top.stdout.trim());
|
|
322
445
|
const refs = parseRefs(stdin);
|
|
323
446
|
if (refs.length === 0) {
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { test } from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { blockingPath, isFleetWide, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
8
|
+
import { blockingPath, dependentsLine, emptyPlanFailure, isInert, outsidePackages, packageDirs, dependentsOnly, isFleetWide, planRuns, parkOnly, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
9
9
|
|
|
10
10
|
const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
|
|
11
11
|
const SHA_A = 'a'.repeat(40);
|
|
@@ -18,6 +18,7 @@ test('stripJsonc drops comments outside strings only', () => {
|
|
|
18
18
|
|
|
19
19
|
test('turboTasks keeps lint and typecheck, and throws on unparsable turbo.json', () => {
|
|
20
20
|
assert.deepEqual(turboTasks('{"tasks":{"build":{},"typecheck":{},"lint":{}}}'), ['lint', 'typecheck']);
|
|
21
|
+
assert.deepEqual(turboTasks('{"tasks":{"openapi:current":{},"test":{},"build":{},"typecheck":{},"lint":{}}}'), ['lint', 'typecheck', 'test', 'openapi:current']);
|
|
21
22
|
assert.deepEqual(turboTasks('{"pipeline":{"typecheck":{}}}'), ['typecheck']);
|
|
22
23
|
assert.throws(() => turboTasks('{ nope'));
|
|
23
24
|
});
|
|
@@ -136,3 +137,69 @@ test('the turbo invocation receives TURBO_CACHE_DIR', () => {
|
|
|
136
137
|
rmSync(r.dir, { recursive: true, force: true });
|
|
137
138
|
}
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
test('parkOnly: only a push whose every remote ref is a session park', () => {
|
|
142
|
+
const park = `refs/heads/x ${SHA_A} refs/xema-session/lead/T1/x ${'0'.repeat(40)}`;
|
|
143
|
+
const dev = `refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`;
|
|
144
|
+
assert.equal(parkOnly(`${park}\n`), true);
|
|
145
|
+
assert.equal(parkOnly(`(delete) ${'0'.repeat(40)} refs/xema-session/lead/T1/x ${SHA_A}\n`), true);
|
|
146
|
+
assert.equal(parkOnly(`${park}\n${dev}\n`), false);
|
|
147
|
+
assert.equal(parkOnly(`${dev}\n`), false);
|
|
148
|
+
assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/main ${SHA_B}\n`), false);
|
|
149
|
+
assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/xema-session/x ${SHA_B}\n`), false);
|
|
150
|
+
assert.equal(parkOnly(''), false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a park-only push skips and says why; a park beside develop still grades', () => {
|
|
154
|
+
const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
|
|
155
|
+
try {
|
|
156
|
+
const parked = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\n`);
|
|
157
|
+
assert.equal(parked.status, 0, parked.stdout + parked.stderr);
|
|
158
|
+
assert.match(parked.stdout, /skipped — every ref is a session park/);
|
|
159
|
+
const mixed = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\nrefs/heads/x ${r.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
|
|
160
|
+
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
|
161
|
+
assert.match(mixed.stdout, /pre-push: FAILED/);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(r.dir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('planRuns: affected set for lint and typecheck, changed packages only for test and openapi:current', () => {
|
|
168
|
+
const tasks = ['lint', 'typecheck', 'test', 'openapi:current'];
|
|
169
|
+
assert.deepEqual(planRuns(tasks, 'A...B'), {
|
|
170
|
+
runs: [
|
|
171
|
+
{ tasks: ['lint', 'typecheck'], filter: '...[A...B]' },
|
|
172
|
+
{ tasks: ['test', 'openapi:current'], filter: '[A...B]' },
|
|
173
|
+
],
|
|
174
|
+
leftToCi: [],
|
|
175
|
+
});
|
|
176
|
+
assert.deepEqual(planRuns(['test'], 'A...B').runs, [{ tasks: ['test'], filter: '[A...B]' }]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('planRuns: a root-file change runs lint and typecheck on all and leaves the rest to CI', () => {
|
|
180
|
+
assert.deepEqual(planRuns(['lint', 'typecheck', 'test'], ''), { runs: [{ tasks: ['lint', 'typecheck'], filter: null }], leftToCi: ['test'] });
|
|
181
|
+
assert.deepEqual(planRuns(['test'], ''), { runs: [], leftToCi: ['test'] });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('dependentsOnly and dependentsLine name the dependents left to CI', () => {
|
|
185
|
+
const dependents = dependentsOnly(['//', 'b', 'a', 'c', 'a'], ['c']);
|
|
186
|
+
assert.deepEqual(dependents, ['a', 'b']);
|
|
187
|
+
assert.match(dependentsLine(dependents, ['test', 'openapi:current']), /test openapi:current ran only on the changed packages; 2 dependent\(s\) are left to CI: a, b/);
|
|
188
|
+
assert.equal(dependentsLine([], ['test']), '');
|
|
189
|
+
assert.equal(dependentsLine(['a'], []), '');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a path outside every workspace package is fleet-wide; package files and inert files are not', () => {
|
|
193
|
+
const dirs = packageDirs(['package.json', 'packages/a/package.json', 'packages/a/node_modules/x/package.json', 'apps/b/package.json']);
|
|
194
|
+
assert.deepEqual(dirs.sort(), ['apps/b', 'packages/a']);
|
|
195
|
+
assert.deepEqual(outsidePackages(['tsconfig.base.json', 'biome.json', 'tooling/ci/x.mjs'], dirs), ['tsconfig.base.json', 'biome.json', 'tooling/ci/x.mjs']);
|
|
196
|
+
assert.deepEqual(outsidePackages(['packages/a/src/x.ts', 'apps/b/package.json', 'README.md', '.changeset/x.md'], dirs), []);
|
|
197
|
+
assert.equal(isInert('README.md'), true);
|
|
198
|
+
assert.equal(isInert('tooling/README.md'), false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('zero planned tasks for a push with changes is a failure naming the paths, never green', () => {
|
|
202
|
+
assert.match(emptyPlanFailure(['tsconfig.base.json'], 0), /tsconfig\.base\.json.*nothing was checked/);
|
|
203
|
+
assert.equal(emptyPlanFailure(['packages/a/x.ts'], 3), null);
|
|
204
|
+
assert.equal(emptyPlanFailure([], 0), null);
|
|
205
|
+
});
|