@xemahq/repo-build-tooling 0.18.0 → 0.19.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 CHANGED
@@ -33,6 +33,7 @@ pnpm exec xema-scrub-swagger-paths dist
33
33
  | `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
34
  | `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>... --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; 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
35
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
36
+ | `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents (plain scripts without turbo); CI is the proof |
36
37
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
37
38
 
38
39
  `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.18.0",
3
+ "version": "0.19.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-prepush-sanity": "src/prepush-sanity.mjs",
28
29
  "xema-promote-image": "src/promote-image.mjs",
29
30
  "xema-readiness": "src/readiness.mjs",
30
31
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
@@ -36,6 +37,7 @@
36
37
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
37
38
  "./composed-image": "./src/composed-image.mjs",
38
39
  "./package.json": "./package.json",
40
+ "./prepush-sanity": "./src/prepush-sanity.mjs",
39
41
  "./promote-image": "./src/promote-image.mjs",
40
42
  "./readiness": "./src/readiness.mjs",
41
43
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
@@ -45,9 +47,9 @@
45
47
  "@xemahq/release-policy": "^14.0.0"
46
48
  },
47
49
  "scripts": {
48
- "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-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.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-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-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"
49
51
  },
50
52
  "xemaRelease": {
51
- "sourceDigest": "sha256:aba8ae8794d81525b38c0dfcfefae6abb58b1075eebdc18d261e661c0e07b2fd"
53
+ "sourceDigest": "sha256:83e791d902527383230de73310281ab3f1b6c8a79a640e686ffba5bd4b102def"
52
54
  }
53
55
  }
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════════════
3
+ // Pre-push: cheap sanity on what the push changes. CI is the proof (owner
4
+ // 10-13/10-14). One verb for every repository's lefthook `pre-push`, so the
5
+ // logic is not copied per repo.
6
+ //
7
+ // With turbo (a `turbo.json` and a turbo dependency): turbo `lint` and `typecheck` run only on the packages
8
+ // the pushed commits change plus their dependents. A change to the lockfile,
9
+ // root package.json or turbo.json invalidates everything and runs the full
10
+ // graph. A repo whose turbo carries no `lint` task also runs its root `lint`
11
+ // script. Without turbo: root `typecheck` and `lint` scripts.
12
+ //
13
+ // The pushed SHA is what is graded. In place when the tree is clean and the SHA
14
+ // is HEAD, or when no differing path lies in a package the run reads or at the
15
+ // repository root; otherwise from a `git archive` export. A delete-only push
16
+ // (or no ref on stdin) grades nothing.
17
+ //
18
+ // Never builds for its own sake, never runs tests, docker or `pnpm check`.
19
+ //
20
+ // Usage (lefthook `pre-push` with `use_stdin: true`):
21
+ // xema-prepush-sanity
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ import { spawnSync } from 'node:child_process';
24
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { dirname, join, delimiter } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const ZERO_SHA = /^0+$/;
30
+ const GIT_ENV = [
31
+ 'GIT_DIR',
32
+ 'GIT_WORK_TREE',
33
+ 'GIT_COMMON_DIR',
34
+ 'GIT_INDEX_FILE',
35
+ 'GIT_OBJECT_DIRECTORY',
36
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
37
+ 'GIT_PREFIX',
38
+ ];
39
+
40
+ /** turbo.json is JSONC: strip comments outside strings. */
41
+ export function stripJsonc(text) {
42
+ let out = '';
43
+ for (let i = 0; i < text.length; i++) {
44
+ const c = text[i];
45
+ if (c === '"') {
46
+ let j = i + 1;
47
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
48
+ out += text.slice(i, j + 1);
49
+ i = j;
50
+ } else if (c === '/' && text[i + 1] === '/') {
51
+ while (i < text.length && text[i] !== '\n') i++;
52
+ out += '\n';
53
+ } else if (c === '/' && text[i + 1] === '*') {
54
+ const end = text.indexOf('*/', i + 2);
55
+ i = end === -1 ? text.length : end + 1;
56
+ } else {
57
+ out += c;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** The sanity tasks a turbo.json defines. An unparsable file throws: "no tasks" would report green. */
64
+ export function turboTasks(text) {
65
+ const cfg = JSON.parse(stripJsonc(text));
66
+ const defined = cfg.tasks ?? cfg.pipeline ?? {};
67
+ return ['lint', 'typecheck'].filter((t) => t in defined);
68
+ }
69
+
70
+ /** The refs git writes on a pre-push hook's stdin, one sha graded once, deletes dropped. */
71
+ export function parseRefs(stdin) {
72
+ const seen = new Set();
73
+ const refs = [];
74
+ for (const line of stdin.split('\n')) {
75
+ const [, sha, remoteRef, remoteSha] = line.trim().split(/\s+/);
76
+ if (!sha || !/^[0-9a-f]+$/.test(sha) || ZERO_SHA.test(sha) || seen.has(sha)) continue;
77
+ seen.add(sha);
78
+ refs.push({ sha, remoteRef: remoteRef ?? '', remoteSha: remoteSha ?? '' });
79
+ }
80
+ return refs;
81
+ }
82
+
83
+ /** True when a changed path invalidates every package. */
84
+ export function isFleetWide(changedPaths) {
85
+ return changedPaths.some((p) => p === 'pnpm-lock.yaml' || p === 'package.json' || p === 'turbo.json');
86
+ }
87
+
88
+ /**
89
+ * First differing path that stops the working tree standing in for the pushed
90
+ * SHA, or null. `taskDirs` are the package directories the run reads;
91
+ * `isOwned(path)` says a package.json sits above the path. Root-level *.md files
92
+ * are the one root exception: no task reads them.
93
+ */
94
+ export function blockingPath(paths, taskDirs, isOwned) {
95
+ const dirs = taskDirs.filter((d) => d && d !== '.');
96
+ for (const p of paths) {
97
+ if (dirs.some((d) => p === d || p.startsWith(`${d}/`))) return { path: p, why: 'is in a package this run reads' };
98
+ if (!isOwned(p) && !/^[^/]*\.md$/.test(p)) return { path: p, why: 'is at the repository root' };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /** One turbo cache for the in-place run and every scratch export; an inherited value wins. */
104
+ export function turboCacheDir(inherited, root) {
105
+ return inherited || join(root, '.turbo', 'cache');
106
+ }
107
+
108
+ function cleanEnv(root) {
109
+ const env = { ...process.env };
110
+ for (const k of GIT_ENV) delete env[k];
111
+ env.TURBO_CACHE_DIR = turboCacheDir(env.TURBO_CACHE_DIR, root);
112
+ // Appended, never prepended: it only fills a gap (the aggregator owns node_modules).
113
+ const sup = spawnSync('git', ['rev-parse', '--show-superproject-working-tree'], { cwd: root, env, encoding: 'utf8' });
114
+ const superproject = sup.status === 0 ? sup.stdout.trim() : '';
115
+ if (superproject && existsSync(join(superproject, 'node_modules', '.bin'))) {
116
+ env.PATH = `${env.PATH ?? ''}${delimiter}${join(superproject, 'node_modules', '.bin')}`;
117
+ }
118
+ return env;
119
+ }
120
+
121
+ function createContext(root) {
122
+ const env = cleanEnv(root);
123
+ const git = (args, cwd = root) => spawnSync('git', args, { cwd, env, encoding: 'utf8', maxBuffer: 1 << 28 });
124
+ const gitOut = (args, cwd = root) => {
125
+ const r = git(args, cwd);
126
+ return r.status === 0 ? r.stdout : null;
127
+ };
128
+ const inherit = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, env, stdio: ['ignore', 'inherit', 'inherit'] }).status === 0;
129
+ return { root, env, git, gitOut, inherit };
130
+ }
131
+
132
+ function pickBase(ctx, { remoteSha, remoteRef }) {
133
+ if (remoteSha && /^[0-9a-f]+$/.test(remoteSha) && !ZERO_SHA.test(remoteSha) && ctx.git(['cat-file', '-e', `${remoteSha}^{commit}`]).status === 0) {
134
+ return remoteSha;
135
+ }
136
+ const target = remoteRef.replace(/^refs\/heads\//, '');
137
+ for (const cand of [`origin/${target}`, 'origin/develop']) {
138
+ const out = ctx.gitOut(['rev-parse', '--verify', '-q', `${cand}^{commit}`]);
139
+ if (out) return out.trim();
140
+ }
141
+ return '';
142
+ }
143
+
144
+ /** `A...B` naming the change, or '' for the full graph. */
145
+ function rangeFor(ctx, base, sha) {
146
+ if (!base) {
147
+ console.error('pre-push: no base to diff against — running the FULL graph.');
148
+ return '';
149
+ }
150
+ const changed = (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean);
151
+ if (isFleetWide(changed)) {
152
+ console.error('pre-push: lockfile, root package.json or turbo.json changed — fleet-wide, running the FULL graph.');
153
+ return '';
154
+ }
155
+ return `${base}...${sha}`;
156
+ }
157
+
158
+ function turboBin(root, env) {
159
+ const local = join(root, 'node_modules', '.bin', 'turbo');
160
+ if (existsSync(local)) return local;
161
+ const r = spawnSync('sh', ['-c', 'command -v turbo'], { env, encoding: 'utf8' });
162
+ return r.status === 0 ? r.stdout.trim() : '';
163
+ }
164
+
165
+ function declaresTurbo(dir) {
166
+ try {
167
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
168
+ return Boolean(pkg.dependencies?.turbo ?? pkg.devDependencies?.turbo);
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ function hasRootLint(dir) {
175
+ try {
176
+ return Boolean(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).scripts?.lint);
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /** Run the sanity tasks in `dir`; `range` '' is the full graph. Returns the verdict. */
183
+ function runHere(ctx, dir, range, bin) {
184
+ if (!bin) {
185
+ console.log('pre-push: pnpm run typecheck && pnpm run lint (this repository declares no turbo)');
186
+ const typecheck = ctx.inherit('pnpm', ['run', 'typecheck'], dir);
187
+ const lint = ctx.inherit('pnpm', ['run', 'lint'], dir);
188
+ return typecheck && lint;
189
+ }
190
+ let tasks;
191
+ try {
192
+ tasks = turboTasks(readFileSync(join(dir, 'turbo.json'), 'utf8'));
193
+ } catch (error) {
194
+ console.log(`pre-push: could not read the tasks from turbo.json (${error.message}) — FAILED`);
195
+ return false;
196
+ }
197
+ if (tasks.length === 0) {
198
+ console.log('pre-push: turbo.json defines none of lint, typecheck — FAILED');
199
+ return false;
200
+ }
201
+ let ok;
202
+ if (range) {
203
+ console.log(`pre-push: turbo run ${tasks.join(' ')} (affected by ${range}, plus dependents)`);
204
+ ok = ctx.inherit(bin, ['run', ...tasks, `--filter=...[${range}]`, '--continue=dependencies-successful'], dir);
205
+ } else {
206
+ console.log(`pre-push: turbo run ${tasks.join(' ')} (full graph)`);
207
+ ok = ctx.inherit(bin, ['run', ...tasks, '--continue=dependencies-successful'], dir);
208
+ }
209
+ // Lint that turbo does not carry is the root `lint` script CI runs.
210
+ if (!tasks.includes('lint') && hasRootLint(dir)) {
211
+ console.log('pre-push: pnpm run lint');
212
+ ok = ctx.inherit('pnpm', ['run', 'lint'], dir) && ok;
213
+ }
214
+ return ok;
215
+ }
216
+
217
+ function inPlaceSafe(ctx, sha, range, tasks, bin) {
218
+ if (!range || !bin) return false;
219
+ const dry = spawnSync(bin, ['run', ...tasks, `--filter=...[${range}]`, '--dry=json'], { cwd: ctx.root, env: ctx.env, encoding: 'utf8', maxBuffer: 1 << 28 });
220
+ if (dry.status !== 0) return false;
221
+ let taskDirs;
222
+ try {
223
+ taskDirs = (JSON.parse(dry.stdout).tasks ?? []).map((t) => t.directory);
224
+ } catch {
225
+ return false;
226
+ }
227
+ const paths = [
228
+ ctx.gitOut(['diff', '--name-only', '-z', 'HEAD']) ?? '',
229
+ ctx.gitOut(['ls-files', '-z', '--others', '--exclude-standard']) ?? '',
230
+ ctx.gitOut(['diff', '--name-only', '-z', 'HEAD', sha]) ?? '',
231
+ ].flatMap((s) => s.split('\0').filter(Boolean));
232
+ const isOwned = (p) => {
233
+ let dir = dirname(p);
234
+ while (dir !== '.' && dir !== '/') {
235
+ if (existsSync(join(ctx.root, dir, 'package.json'))) return true;
236
+ dir = dirname(dir);
237
+ }
238
+ return false;
239
+ };
240
+ const blocked = blockingPath(paths, taskDirs, isOwned);
241
+ if (blocked) console.error(`pre-push: ${blocked.path} ${blocked.why}`);
242
+ return blocked === null;
243
+ }
244
+
245
+ function exportTo(ctx, rev, dir) {
246
+ return spawnSync('sh', ['-c', 'git archive "$1" | tar -x -C "$2"', 'sh', rev, dir], { cwd: ctx.root, env: ctx.env }).status === 0;
247
+ }
248
+
249
+ function gradeFromExport(ctx, sha, base, range, bin) {
250
+ const scratch = mkdtempSync(join(tmpdir(), 'xema-prepush-'));
251
+ try {
252
+ const sh = (args) => ctx.git(['-c', 'user.email=gate@local', '-c', 'user.name=gate', ...args], scratch).status === 0;
253
+ if (!sh(['init', '-q', '.'])) return false;
254
+ let exportRange = '';
255
+ if (range) {
256
+ // Two commits (base, then the graded tree) so turbo can diff them as it would a checkout.
257
+ if (!exportTo(ctx, base, scratch) || !sh(['add', '-A']) || !sh(['commit', '-qm', 'base'])) return false;
258
+ for (const name of readdirSync(scratch)) if (name !== '.git') rmSync(join(scratch, name), { recursive: true, force: true });
259
+ exportRange = 'HEAD~1...HEAD';
260
+ }
261
+ if (!exportTo(ctx, sha, scratch) || !sh(['add', '-A']) || !sh(['commit', '-qm', 'graded', '--allow-empty'])) return false;
262
+ if (!ctx.inherit('pnpm', ['install', '--frozen-lockfile', '--prefer-offline'], scratch)) {
263
+ console.log('pre-push: install of the export failed');
264
+ return false;
265
+ }
266
+ return runHere(ctx, scratch, exportRange, bin);
267
+ } finally {
268
+ rmSync(scratch, { recursive: true, force: true });
269
+ }
270
+ }
271
+
272
+ function gradeSha(ctx, ref, bin) {
273
+ const { sha } = ref;
274
+ const base = pickBase(ctx, ref);
275
+ const range = rangeFor(ctx, base, sha);
276
+ const head = (ctx.gitOut(['rev-parse', 'HEAD']) ?? '').trim();
277
+ const short = (rev) => (ctx.gitOut(['rev-parse', '--short', rev]) ?? rev).trim();
278
+ const clean = (ctx.gitOut(['status', '--porcelain']) ?? '') === '';
279
+ if (sha === head && clean) {
280
+ console.log(`pre-push: grading HEAD ${short(head)} in place (clean tree).`);
281
+ return runHere(ctx, ctx.root, range, bin);
282
+ }
283
+ if (bin) {
284
+ let tasks = [];
285
+ try {
286
+ tasks = turboTasks(readFileSync(join(ctx.root, 'turbo.json'), 'utf8'));
287
+ } catch {
288
+ // runHere reports an unreadable turbo.json as the failure it is.
289
+ }
290
+ if (tasks.length > 0 && inPlaceSafe(ctx, sha, range, tasks, bin)) {
291
+ console.log(`pre-push: grading ${short(sha)} in place — no differing path is in a package this run reads.`);
292
+ return runHere(ctx, ctx.root, range, bin);
293
+ }
294
+ }
295
+ console.log(
296
+ sha === head
297
+ ? `pre-push: grading ${short(sha)} from an export — the tree is dirty, so it is not what is being pushed.`
298
+ : `pre-push: grading ${short(sha)} from an export — the commit being PUSHED, which is not HEAD ${short(head)}.`,
299
+ );
300
+ return gradeFromExport(ctx, sha, base, bin ? range : '', bin);
301
+ }
302
+
303
+ export function main(stdin) {
304
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', env: Object.fromEntries(Object.entries(process.env).filter(([k]) => !GIT_ENV.includes(k))) });
305
+ if (top.status !== 0) {
306
+ console.error('pre-push: not inside a git repository — FAILED');
307
+ return 1;
308
+ }
309
+ const ctx = createContext(top.stdout.trim());
310
+ const refs = parseRefs(stdin);
311
+ if (refs.length === 0) {
312
+ console.log('pre-push: no ref on stdin — this push updates nothing, nothing to grade.');
313
+ return 0;
314
+ }
315
+ // Turbo is used only where the repository declares it; a declared turbo that does not
316
+ // resolve is an uninstalled checkout, and fails rather than skipping the proof.
317
+ let bin = '';
318
+ if (existsSync(join(ctx.root, 'turbo.json')) && declaresTurbo(ctx.root)) {
319
+ bin = turboBin(ctx.root, ctx.env);
320
+ if (!bin) {
321
+ console.log('pre-push: no turbo binary resolves — run `pnpm install --frozen-lockfile` — FAILED');
322
+ console.log('::error::pre-push FAILED — turbo:unavailable');
323
+ return 1;
324
+ }
325
+ }
326
+ const failed = refs.filter((ref) => !gradeSha(ctx, ref, bin)).map((ref) => ctx.gitOut(['rev-parse', '--short', ref.sha])?.trim() ?? ref.sha);
327
+ if (failed.length === 0) {
328
+ console.log(`pre-push: GREEN — lint typecheck passed on ${refs.length} pushed commit(s).`);
329
+ return 0;
330
+ }
331
+ console.log(`::error::pre-push FAILED — ${failed.join(' ')}`);
332
+ console.log(`pre-push: FAILED — ${failed.join(' ')}`);
333
+ return 1;
334
+ }
335
+
336
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
337
+ process.exitCode = main(process.stdin.isTTY ? '' : readFileSync(0, 'utf8'));
338
+ }
@@ -0,0 +1,124 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { blockingPath, isFleetWide, parseRefs, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
9
+
10
+ const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
11
+ const SHA_A = 'a'.repeat(40);
12
+ const SHA_B = 'b'.repeat(40);
13
+
14
+ test('stripJsonc drops comments outside strings only', () => {
15
+ const text = '{ // c\n "a": "x//y", /* b */ "b": 1 }';
16
+ assert.deepEqual(JSON.parse(stripJsonc(text)), { a: 'x//y', b: 1 });
17
+ });
18
+
19
+ test('turboTasks keeps lint and typecheck, and throws on unparsable turbo.json', () => {
20
+ assert.deepEqual(turboTasks('{"tasks":{"build":{},"typecheck":{},"lint":{}}}'), ['lint', 'typecheck']);
21
+ assert.deepEqual(turboTasks('{"pipeline":{"typecheck":{}}}'), ['typecheck']);
22
+ assert.throws(() => turboTasks('{ nope'));
23
+ });
24
+
25
+ test('parseRefs dedupes shas and drops deletes and junk', () => {
26
+ const zero = '0'.repeat(40);
27
+ const stdin = [`refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`, `refs/heads/y ${SHA_A} refs/heads/z ${SHA_B}`, `(delete) ${zero} refs/heads/old ${SHA_B}`, ''].join('\n');
28
+ assert.deepEqual(parseRefs(stdin), [{ sha: SHA_A, remoteRef: 'refs/heads/develop', remoteSha: SHA_B }]);
29
+ assert.deepEqual(parseRefs(''), []);
30
+ });
31
+
32
+ test('isFleetWide flags only the lockfile, root package.json and turbo.json', () => {
33
+ assert.equal(isFleetWide(['pnpm-lock.yaml']), true);
34
+ assert.equal(isFleetWide(['package.json']), true);
35
+ assert.equal(isFleetWide(['packages/a/package.json', 'src/x.ts']), false);
36
+ });
37
+
38
+ test('blockingPath: package reads and repo-root files block, root markdown does not', () => {
39
+ const owned = (p) => p.startsWith('packages/');
40
+ assert.equal(blockingPath(['packages/a/src/x.ts'], ['packages/a'], owned)?.why, 'is in a package this run reads');
41
+ assert.equal(blockingPath(['packages/b/src/x.ts'], ['packages/a'], owned), null);
42
+ assert.equal(blockingPath(['tsconfig.base.json'], [], owned)?.why, 'is at the repository root');
43
+ assert.equal(blockingPath(['README.md'], [], owned), null);
44
+ });
45
+
46
+ function repo(scripts) {
47
+ const dir = mkdtempSync(join(tmpdir(), 'prepush-sanity-test-'));
48
+ const run = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
49
+ run('init', '-q', '-b', 'develop');
50
+ run('config', 'user.email', 't@t');
51
+ run('config', 'user.name', 't');
52
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't', private: true, scripts }));
53
+ run('add', '-A');
54
+ run('commit', '-qm', 'init');
55
+ const sha = run('rev-parse', 'HEAD').toString().trim();
56
+ return { dir, sha };
57
+ }
58
+
59
+ function hook(dir, stdin) {
60
+ return spawnSync('node', [VERB], { cwd: dir, input: stdin, encoding: 'utf8' });
61
+ }
62
+
63
+ test('no turbo: a failing typecheck refuses the push, a passing one is green', () => {
64
+ const bad = repo({ typecheck: 'exit 1', lint: 'exit 0' });
65
+ const good = repo({ typecheck: 'exit 0', lint: 'exit 0' });
66
+ try {
67
+ const refused = hook(bad.dir, `refs/heads/x ${bad.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
68
+ assert.equal(refused.status, 1, refused.stdout + refused.stderr);
69
+ assert.match(refused.stdout, /pre-push: FAILED/);
70
+ const green = hook(good.dir, `refs/heads/x ${good.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
71
+ assert.equal(green.status, 0, green.stdout + green.stderr);
72
+ assert.match(green.stdout, /GREEN/);
73
+ } finally {
74
+ rmSync(bad.dir, { recursive: true, force: true });
75
+ rmSync(good.dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('a delete-only push and a manual run grade nothing', () => {
80
+ const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
81
+ try {
82
+ const del = hook(r.dir, `(delete) ${'0'.repeat(40)} refs/heads/old ${r.sha}\n`);
83
+ assert.equal(del.status, 0, del.stdout + del.stderr);
84
+ assert.match(del.stdout, /nothing to grade/);
85
+ assert.equal(hook(r.dir, '').status, 0);
86
+ } finally {
87
+ rmSync(r.dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ test('turboCacheDir defaults to the repo root and keeps an inherited value', () => {
92
+ assert.equal(turboCacheDir('', '/r'), '/r/.turbo/cache');
93
+ assert.equal(turboCacheDir(undefined, '/r'), '/r/.turbo/cache');
94
+ assert.equal(turboCacheDir('/shared', '/r'), '/shared');
95
+ });
96
+
97
+ test('the turbo invocation receives TURBO_CACHE_DIR', () => {
98
+ const r = repo({});
99
+ try {
100
+ writeFileSync(join(r.dir, 'package.json'), JSON.stringify({ name: 't', private: true, devDependencies: { turbo: '^2' } }));
101
+ writeFileSync(join(r.dir, 'turbo.json'), JSON.stringify({ tasks: { typecheck: {} } }));
102
+ mkdirSync(join(r.dir, 'node_modules', '.bin'), { recursive: true });
103
+ const fake = join(r.dir, 'node_modules', '.bin', 'turbo');
104
+ const seen = join(r.dir, 'seen.txt');
105
+ writeFileSync(fake, `#!/bin/sh\nprintf '%s' "$TURBO_CACHE_DIR" > '${seen}'\n`);
106
+ chmodSync(fake, 0o755);
107
+ writeFileSync(join(r.dir, '.gitignore'), 'node_modules\nseen.txt\n');
108
+ execFileSync('git', ['add', '-A'], { cwd: r.dir });
109
+ execFileSync('git', ['commit', '-qm', 'turbo'], { cwd: r.dir });
110
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir }).toString().trim();
111
+ const stdin = `refs/heads/x ${sha} refs/heads/develop ${'0'.repeat(40)}\n`;
112
+ const env = { ...process.env };
113
+ delete env.TURBO_CACHE_DIR;
114
+ const run = (e) => spawnSync('node', [VERB], { cwd: r.dir, input: stdin, encoding: 'utf8', env: e });
115
+ const first = run(env);
116
+ assert.equal(first.status, 0, first.stdout + first.stderr);
117
+ assert.equal(readFileSync(seen, 'utf8'), join(realpathSync(r.dir), '.turbo', 'cache'));
118
+ const second = run({ ...env, TURBO_CACHE_DIR: '/inherited/cache' });
119
+ assert.equal(second.status, 0, second.stdout + second.stderr);
120
+ assert.equal(readFileSync(seen, 'utf8'), '/inherited/cache');
121
+ } finally {
122
+ rmSync(r.dir, { recursive: true, force: true });
123
+ }
124
+ });
@@ -75,10 +75,17 @@ function verifiedStatement({ subject, digest, type, predicateType, what, noun, p
75
75
  return { statement, payloadDigest };
76
76
  }
77
77
 
78
- /** The predicate types of every attestation on `subject`, listed without verifying any signature. A registry error is an error. */
78
+ // cosign v2 exits 1 with this exact stderr line (and empty stdout) for an image that carries no attestations.
79
+ const NO_ATTESTATIONS_LINE = 'Error: found no attestations';
80
+
81
+ /**
82
+ * The predicate types of every attestation on `subject`, listed without verifying any signature. cosign v2's
83
+ * "found no attestations" exit is an empty listing; any other failure is an error.
84
+ */
79
85
  function attestationTypes(subject, label, run) {
80
86
  const listed = run('cosign', ['download', 'attestation', subject]);
81
87
  if (listed.error) throw new Error(`${label}: could not run cosign to list the attestations of ${subject}: ${listed.error.message}`);
88
+ if (listed.status === 1 && String(listed.stderr ?? '').split('\n').some((line) => line.trim() === NO_ATTESTATIONS_LINE)) return [];
82
89
  if (listed.status !== 0) throw new Error(`${label}: listing the attestations of ${subject} failed: ${describeFailure(listed)}`);
83
90
  return listed.stdout.split('\n').map((text) => text.trim()).filter(Boolean).map((line) => {
84
91
  try {
@@ -99,7 +106,11 @@ export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, rel
99
106
  const subject = `${releaseRef}@${digest}`;
100
107
  const label = 'reuse';
101
108
  // (a) presence, without identity verification: only the predicate types are read
102
- if (!attestationTypes(subject, label, run).includes(IMAGE_PROMOTION_PREDICATE_TYPE)) return undefined;
109
+ const types = attestationTypes(subject, label, run);
110
+ if (types.length === 0) {
111
+ throw new Error(`${label}: release image ${subject} carries no attestations; a lane-built release image always has some; needs a human`);
112
+ }
113
+ if (!types.includes(IMAGE_PROMOTION_PREDICATE_TYPE)) return undefined;
103
114
  // (b) a promotion attestation is present: any verification failure is an error
104
115
  const found = verifiedStatement({
105
116
  subject, digest, type: IMAGE_PROMOTION_PREDICATE_TYPE, predicateType: IMAGE_PROMOTION_PREDICATE_TYPE,
@@ -60,7 +60,7 @@ if (tool === 'cosign') {
60
60
  if (args[0] === 'download' && args[1] === 'attestation') {
61
61
  if (state.downloadErrors[subject]) fail(state.downloadErrors[subject]);
62
62
  const listed = state.attestations[subject];
63
- if (!listed) fail('Error: no attestations found for ' + subject + '\\n');
63
+ if (!listed || listed.statements.length === 0) fail('Error: found no attestations\\nerror during command execution: found no attestations\\n');
64
64
  for (const statement of listed.statements) {
65
65
  process.stdout.write(JSON.stringify({ payloadType: 'application/vnd.in-toto+json', payload: Buffer.from(statement).toString('base64'), signatures: [] }) + '\\n');
66
66
  }
@@ -246,6 +246,17 @@ test('a registry error listing the attestations is an error, not a miss', () =>
246
246
  );
247
247
  });
248
248
 
249
+ test('reuse: a release digest with no attestations at all (cosign v2) is an error, not a miss', () => {
250
+ refused({ tags: { [`${RELEASE}:${TAG}`]: OTHER } }, /reuse: release image .* carries no attestations; .* needs a human/);
251
+ });
252
+
253
+ test('reuse: exit 1 with any other stderr is an error', () => {
254
+ refused(
255
+ { tags: { [`${RELEASE}:${TAG}`]: OTHER }, downloadErrors: { [`${RELEASE}@${OTHER}`]: 'Error: GET https://ghcr.io/v2/x/manifests/y: UNAUTHORIZED\n' } },
256
+ /reuse: listing the attestations of .* failed: .*UNAUTHORIZED/,
257
+ );
258
+ });
259
+
249
260
  test('a promotion attestation signed by the wrong identity is refused, not a miss', () => {
250
261
  refused(promotedRelease({ identity: DEVELOP_IDENTITY }), /reuse: .* has no image-promotion\/v1 attestation signed by/);
251
262
  });
@@ -322,6 +333,13 @@ test('a registry error listing the dev attestations is an error, not a miss', ()
322
333
  );
323
334
  });
324
335
 
336
+ test('copy: exit 1 with a non-"found no attestations" stderr is an error, not a miss', () => {
337
+ refused(
338
+ provenDevelop({ downloadErrors: { [`${DEV}@${DIGEST}`]: 'Error: GET https://ghcr.io/v2/x/manifests/y: UNAUTHORIZED\n' } }),
339
+ /copy: listing the attestations of .* failed: .*UNAUTHORIZED/,
340
+ );
341
+ });
342
+
325
343
  test('leg (a): provenance signed by a unit-branch workflow is refused before any copy', () => {
326
344
  const r = refused(
327
345
  provenDevelop({ attestations: { [`${DEV}@${DIGEST}`]: { identity: DEVELOP_IDENTITY.replace('refs/heads/develop', 'refs/heads/unit/x'), statements: [provenance()] } } }),