@xemahq/repo-build-tooling 0.17.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
@@ -30,9 +30,10 @@ 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-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 tag is signed by a main workflow, or is the proven develop digest), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it). 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 a promotion it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign`, `git` and full history. It does not sign |
34
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
+ | `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.17.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:150d82fb857d535b44f1e29686108056532bc9a7827b51260c88bf3270a00eeb"
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
+ });
@@ -3,9 +3,9 @@
3
3
  // `xema-promote-image` proved before the copy (promote-image-provenance.mjs).
4
4
 
5
5
  import { writeFileSync } from 'node:fs';
6
- import { SLSA_PROVENANCE_V1 } from './promote-image-provenance.mjs';
6
+ import { IMAGE_PROMOTION_PREDICATE_TYPE, SLSA_PROVENANCE_V1 } from './promote-image-provenance.mjs';
7
7
 
8
- export const IMAGE_PROMOTION_PREDICATE_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
8
+ export { IMAGE_PROMOTION_PREDICATE_TYPE };
9
9
 
10
10
  export function promotionPredicate({ releaseRef, tag, digest, source, revision, commit, provenanceDigest, signerPattern }) {
11
11
  return {
@@ -16,7 +16,12 @@ export function promotionPredicate({ releaseRef, tag, digest, source, revision,
16
16
  };
17
17
  }
18
18
 
19
+ /** The predicate of a REUSE: this run's release and commit, with `source` and `developProvenance` carried from main's earlier promotion. */
20
+ export function reusedPromotionPredicate({ releaseRef, tag, digest, commit, source, developProvenance }) {
21
+ return { release: { repository: releaseRef, tag, digest }, source, promotedCommit: commit, developProvenance };
22
+ }
23
+
19
24
  /** Writes the predicate to a path that must not exist yet, so a stale file is never attested. */
20
- export function writePromotionPredicate(path, facts) {
21
- writeFileSync(path, `${JSON.stringify(promotionPredicate(facts), null, 2)}\n`, { flag: 'wx' });
25
+ export function writePromotionPredicate(path, predicate) {
26
+ writeFileSync(path, `${JSON.stringify(predicate, null, 2)}\n`, { flag: 'wx' });
22
27
  }
@@ -1,6 +1,6 @@
1
1
  // The trust half of `xema-promote-image`: what must be PROVEN about a develop
2
- // digest before its bytes may be published under a release tag, and how a
3
- // release digest is recognised as already released by main.
2
+ // digest before its bytes may be published under a release tag, and how a release digest main
3
+ // already promoted is reused.
4
4
  //
5
5
  // A develop digest is promotable only on a CONJUNCTION, each leg proven from the
6
6
  // develop lane's SIGNED provenance and never from a tag:
@@ -20,7 +20,7 @@ export const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
20
20
  export const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1';
21
21
  const COSIGN_SLSA_V1_TYPE = 'slsaprovenance1';
22
22
  const COMMIT = /^[0-9a-f]{40}$/;
23
- const UNSIGNED = /no matching signatures|no signatures found/i;
23
+ export const IMAGE_PROMOTION_PREDICATE_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
24
24
 
25
25
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
26
 
@@ -30,22 +30,7 @@ export function workflowIdentityPattern(repository, files, branch) {
30
30
  return `^${escapeRegExp(`${GITHUB_SERVER}/${repository}/.github/workflows/`)}(?:${alternatives})${escapeRegExp(`@refs/heads/${branch}`)}$`;
31
31
  }
32
32
 
33
- /** True when `subject` carries a signature from one of main's release workflows; false when it carries none. */
34
- export function releaseSignedByMain({ subject, repository, releaseWorkflows }, run = runTool) {
35
- const result = run('cosign', [
36
- 'verify',
37
- '--certificate-identity-regexp', workflowIdentityPattern(repository, releaseWorkflows, 'main'),
38
- '--certificate-oidc-issuer', GITHUB_OIDC_ISSUER,
39
- subject,
40
- ]);
41
- if (result.error) throw new Error(`could not run cosign to verify ${subject}: ${result.error.message}`);
42
- if (result.status === 0) return true;
43
- const detail = describeFailure(result);
44
- if (UNSIGNED.test(detail)) return false;
45
- throw new Error(`verifying main's signature on ${subject} failed (not a "no matching signatures"): ${detail}`);
46
- }
47
-
48
- function readStatements(stdout, subject) {
33
+ function readStatements(stdout, subject, label) {
49
34
  const byDigest = new Map();
50
35
  for (const line of stdout.split('\n').map((text) => text.trim()).filter(Boolean)) {
51
36
  let payload;
@@ -54,41 +39,127 @@ function readStatements(stdout, subject) {
54
39
  payload = Buffer.from(JSON.parse(line).payload, 'base64');
55
40
  statement = JSON.parse(payload.toString('utf8'));
56
41
  } catch (error) {
57
- throw new Error(`leg (a) signer: cosign returned an unreadable attestation for ${subject}`, { cause: error });
42
+ throw new Error(`${label}: cosign returned an unreadable attestation for ${subject}`, { cause: error });
58
43
  }
59
44
  byDigest.set(`sha256:${createHash('sha256').update(payload).digest('hex')}`, statement);
60
45
  }
61
46
  return byDigest;
62
47
  }
63
48
 
64
- /** Proves legs (a), (b) and (c) for `<devRef>@<digest>`; returns `{ revision, provenanceDigest, signerPattern }`. */
65
- export function verifyDevelopProvenance(
66
- { devRef, digest, repository, developWorkflow, mainRef, commit, tag, tagCommand },
67
- run = runTool,
68
- ) {
69
- const subject = `${devRef}@${digest}`;
70
- const signerPattern = workflowIdentityPattern(repository, [developWorkflow], 'develop');
71
-
72
- // (a) signer
49
+ /**
50
+ * Runs `cosign verify-attestation` of `type` on `subject` under `pattern` and requires exactly one distinct verified
51
+ * statement whose subject is the digest. Returns `{ statement, payloadDigest }`.
52
+ */
53
+ function verifiedStatement({ subject, digest, type, predicateType, what, noun, pattern, label }, run) {
73
54
  const verified = run('cosign', [
74
55
  'verify-attestation',
75
- '--type', COSIGN_SLSA_V1_TYPE,
76
- '--certificate-identity-regexp', signerPattern,
56
+ '--type', type,
57
+ '--certificate-identity-regexp', pattern,
77
58
  '--certificate-oidc-issuer', GITHUB_OIDC_ISSUER,
78
59
  subject,
79
60
  ]);
80
- if (verified.error || verified.status !== 0) {
81
- throw new Error(`leg (a) signer: ${subject} has no SLSA v1 provenance signed by ${signerPattern}: ${describeFailure(verified)}`);
61
+ if (verified.error) throw new Error(`${label}: could not run cosign to verify ${subject}: ${verified.error.message}`);
62
+ if (verified.status !== 0) {
63
+ const detail = describeFailure(verified);
64
+ throw new Error(`${label}: ${subject} has no ${what} signed by ${pattern}: ${detail}`);
82
65
  }
83
- const statements = readStatements(verified.stdout, subject);
66
+ const statements = readStatements(verified.stdout, subject, label);
84
67
  if (statements.size !== 1) {
85
- throw new Error(`leg (a) signer: ${subject} carries ${statements.size} distinct verified provenance statements; exactly one is promotable`);
68
+ throw new Error(`${label}: ${subject} carries ${statements.size} distinct verified ${noun} statements; exactly one is promotable`);
86
69
  }
87
- const [[provenanceDigest, statement]] = statements;
70
+ const [[payloadDigest, statement]] = statements;
88
71
  const hex = digest.slice('sha256:'.length);
89
- if (statement.predicateType !== SLSA_PROVENANCE_V1 || !statement.subject?.some((entry) => entry?.digest?.sha256 === hex)) {
90
- throw new Error(`leg (a) signer: the verified statement is not SLSA v1 provenance about ${subject}`);
72
+ if (statement.predicateType !== predicateType || !statement.subject?.some((entry) => entry?.digest?.sha256 === hex)) {
73
+ throw new Error(`${label}: the verified statement is not ${what} about ${subject}`);
74
+ }
75
+ return { statement, payloadDigest };
76
+ }
77
+
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
+ */
85
+ function attestationTypes(subject, label, run) {
86
+ const listed = run('cosign', ['download', 'attestation', subject]);
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 [];
89
+ if (listed.status !== 0) throw new Error(`${label}: listing the attestations of ${subject} failed: ${describeFailure(listed)}`);
90
+ return listed.stdout.split('\n').map((text) => text.trim()).filter(Boolean).map((line) => {
91
+ try {
92
+ return JSON.parse(Buffer.from(JSON.parse(line).payload, 'base64').toString('utf8')).predicateType;
93
+ } catch (error) {
94
+ throw new Error(`${label}: cosign listed an unreadable attestation for ${subject}`, { cause: error });
95
+ }
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Reuse of a release digest main already promoted. Verifies its image-promotion/v1 attestation under one of main's
101
+ * release workflows (anchored identity, GitHub OIDC issuer) and returns `{ source, developProvenance }` copied from it.
102
+ * Returns `undefined` when the digest carries no promotion attestation (a legacy image); a registry error, a signature or identity
103
+ * failure, or a source outside `<registry>/<owner>/dev/`, is an error.
104
+ */
105
+ export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows }, run = runTool) {
106
+ const subject = `${releaseRef}@${digest}`;
107
+ const label = 'reuse';
108
+ // (a) presence, without identity verification: only the predicate types are read
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;
114
+ // (b) a promotion attestation is present: any verification failure is an error
115
+ const found = verifiedStatement({
116
+ subject, digest, type: IMAGE_PROMOTION_PREDICATE_TYPE, predicateType: IMAGE_PROMOTION_PREDICATE_TYPE,
117
+ what: 'image-promotion/v1 attestation', noun: 'promotion', pattern: workflowIdentityPattern(repository, releaseWorkflows, 'main'),
118
+ label,
119
+ }, run);
120
+ const { source, developProvenance, release } = found.statement.predicate ?? {};
121
+ for (const [field, expected, stated] of [['digest', digest, release?.digest], ['repository', releaseRef, release?.repository], ['tag', tag, release?.tag]]) {
122
+ if (stated !== expected) throw new Error(`${label}: the promotion attestation of ${subject} states release ${field} ${stated}, not ${expected}`);
123
+ }
124
+ const owner = repository.split('/')[0].toLowerCase();
125
+ const devSource = new RegExp(`^ghcr\\.io/${escapeRegExp(owner)}/dev/[a-z0-9][a-z0-9._/-]*@${escapeRegExp(digest)}$`);
126
+ if (typeof source?.reference !== 'string' || !devSource.test(source.reference)) {
127
+ throw new Error(`${label}: the promotion attestation of ${subject} names source '${source?.reference}', not a ghcr.io/${owner}/dev/<name>@${digest} reference`);
91
128
  }
129
+ if (typeof source.revision !== 'string' || !COMMIT.test(source.revision)) {
130
+ throw new Error(`${label}: the promotion attestation of ${subject} names no 40-hex source revision`);
131
+ }
132
+ const { predicateType, payloadDigest, signerIdentityPattern } = developProvenance ?? {};
133
+ if (![predicateType, payloadDigest, signerIdentityPattern].every((value) => typeof value === 'string' && value !== '')) {
134
+ throw new Error(`${label}: the promotion attestation of ${subject} carries no developProvenance`);
135
+ }
136
+ return {
137
+ source: { reference: source.reference, revision: source.revision },
138
+ developProvenance: { predicateType, payloadDigest, signerIdentityPattern },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Whether `<devRef>@<digest>` carries any SLSA v1 provenance attestation, listed without verifying a signature. A digest
144
+ * with none (an image reused on develop from before provenance) is not promotable but not an error; a registry error is.
145
+ */
146
+ export function hasDevelopProvenance({ devRef, digest }, run = runTool) {
147
+ return attestationTypes(`${devRef}@${digest}`, 'copy', run).includes(SLSA_PROVENANCE_V1);
148
+ }
149
+
150
+ /** Proves legs (a), (b) and (c) for `<devRef>@<digest>`; returns `{ revision, provenanceDigest, signerPattern }`. */
151
+ export function verifyDevelopProvenance(
152
+ { devRef, digest, repository, developWorkflow, mainRef, commit, tag, tagCommand },
153
+ run = runTool,
154
+ ) {
155
+ const subject = `${devRef}@${digest}`;
156
+ const signerPattern = workflowIdentityPattern(repository, [developWorkflow], 'develop');
157
+
158
+ // (a) signer
159
+ const { statement, payloadDigest: provenanceDigest } = verifiedStatement(
160
+ { subject, digest, type: COSIGN_SLSA_V1_TYPE, predicateType: SLSA_PROVENANCE_V1, what: 'SLSA v1 provenance', noun: 'provenance', pattern: signerPattern, label: 'leg (a) signer' },
161
+ run,
162
+ );
92
163
  const revision = statement.predicate?.runDetails?.metadata?.buildkit_metadata?.vcs?.revision;
93
164
  if (typeof revision !== 'string' || !COMMIT.test(revision)) {
94
165
  throw new Error(`leg (a) signer: the provenance of ${subject} names no 40-hex vcs.revision`);
@@ -3,20 +3,24 @@
3
3
  // DEVELOP repository already holds for one content-addressed tag, instead of
4
4
  // rebuilding them, and only when the develop bytes are PROVEN promotable.
5
5
  //
6
- // 1. `<release-ref>:<tag>` exists:
7
- // - signed by one of main's `--release-workflow`s -> reused (evidence=release-signature)
8
- // - else it must be the develop digest, proven below -> reused (evidence=develop-provenance)
6
+ // 1. `<release-ref>:<tag>` exists: its image-promotion/v1 attestation must verify under one of main's
7
+ // `--release-workflow` identities (exactly one statement about the digest), and its source must be a
8
+ // `ghcr.io/<owner>/dev/<name>` reference -> reused (evidence=prior-promotion)
9
+ // - no image-promotion/v1 attestation (a legacy image) -> miss (reason=reuse-no-promotion): the caller builds
10
+ // - a signature / identity failure, or a source outside dev/ -> error, never a miss
9
11
  // 2. else `<dev-ref>:<tag>` exists: prove it (legs a, b, c in
10
12
  // promote-image-provenance.mjs), copy it by digest (`imagetools create`
11
13
  // from `<dev-ref>@<digest>`, so an index stays whole), re-probe, and refuse
12
14
  // unless the release digest equals the source -> copied (evidence=develop-provenance)
15
+ // - no SLSA provenance on the dev digest at all (reused on develop from before the swap) -> miss (reason=copy-no-develop-provenance)
16
+ // - provenance present but any leg fails -> error, never a miss
13
17
  // 3. else -> miss: the caller builds.
14
18
  //
15
- // With evidence=develop-provenance it writes the promotion predicate
16
- // (`https://xema.dev/attestations/image-promotion/v1`) to
17
- // `--promotion-predicate-out`, for the caller to `cosign attest` under main's own
18
- // identity. It signs nothing itself; the develop identity is evidence for the
19
- // promotion, never a signature the release side trusts.
19
+ // On every non-miss result it writes the promotion predicate
20
+ // (`https://xema.dev/attestations/image-promotion/v1`) to `--promotion-predicate-out`, for the caller to
21
+ // `cosign attest` under main's own identity. A copy states the facts it just proved; a reuse states this run's
22
+ // release and commit and carries `source` and `developProvenance` from main's earlier promotion. It signs
23
+ // nothing itself; the develop identity is evidence for the promotion, never a signature the release side trusts.
20
24
  //
21
25
  // `--tag` is the caller's content key, and everything after `--` is the command
22
26
  // that computes it (e.g. `node tooling/release/compute-image-tag.mjs --service
@@ -30,22 +34,45 @@
30
34
  // Outputs: `result`, `digest`, `source`, `revision`, `evidence`,
31
35
  // `provenance_digest`, `promotion_predicate` and `promotion_predicate_type` appended to `$GITHUB_OUTPUT`
32
36
  // (required; empty values where a fact does not apply), and one stdout line
33
- // `promote-image result=<r> digest=<d> src=<repository>@<d> revision=<sha> evidence=<e>`.
37
+ // `promote-image result=<r> digest=<d> src=<repository>@<d> revision=<sha> evidence=<e>`, with ` reason=<why>` appended
38
+ // to a miss that has a reason.
34
39
 
35
40
  import { appendFileSync, existsSync } from 'node:fs';
36
41
  import process from 'node:process';
37
42
  import { describeErrorChain } from '@xemahq/release-policy/registry';
38
43
  import { invokedDirectly } from './readiness.mjs';
39
- import { IMAGE_PROMOTION_PREDICATE_TYPE, writePromotionPredicate } from './promote-image-predicate.mjs';
40
- import { releaseSignedByMain, verifyDevelopProvenance } from './promote-image-provenance.mjs';
44
+ import { IMAGE_PROMOTION_PREDICATE_TYPE, promotionPredicate, reusedPromotionPredicate, writePromotionPredicate } from './promote-image-predicate.mjs';
45
+ import { hasDevelopProvenance, reuseEarlierPromotion, verifyDevelopProvenance } from './promote-image-provenance.mjs';
41
46
  import { copyByDigest, probeDigest, runTool } from './promote-image-registry.mjs';
42
47
 
43
48
  export const PromoteResult = Object.freeze({ REUSED: 'reused', COPIED: 'copied', MISS: 'miss' });
44
49
  export const PromoteEvidence = Object.freeze({
45
- RELEASE_SIGNATURE: 'release-signature',
50
+ PRIOR_PROMOTION: 'prior-promotion',
46
51
  DEVELOP_PROVENANCE: 'develop-provenance',
47
52
  NONE: 'none',
48
53
  });
54
+ export const PromoteMissReason = Object.freeze({ NO_PROMOTION: 'reuse-no-promotion', NO_DEVELOP_PROVENANCE: 'copy-no-develop-provenance' });
55
+
56
+ // The one cosign major shared by the develop attest step, this verb and xema-deploy's verify (xema-deploy pins
57
+ // v2.x in scripts/install-cosign.sh and refuses anything else). v2 `attest` stores `.att` tags; v3 stores
58
+ // bundles/referrers, so a mixed pair makes `cosign download attestation` see nothing and every promotion
59
+ // would silently become a build.
60
+ export const COSIGN_MAJOR = 2;
61
+
62
+ /** Runs `cosign version` once and refuses unless its GitVersion is major `COSIGN_MAJOR`. */
63
+ export function assertCosignMajor(run = runTool) {
64
+ const result = run('cosign', ['version']);
65
+ if (result.error) throw new Error(`could not run cosign to read its version: ${result.error.message}`);
66
+ if (result.status !== 0) throw new Error(`cosign version failed: ${(result.stderr || result.stdout || `exit ${result.status}`).trim()}`);
67
+ const match = /^GitVersion:\s*v?(\d+)\.\d+\.\d+/m.exec(result.stdout ?? '');
68
+ if (match === null) throw new Error('cosign version printed no parsable GitVersion; refusing to promote with an unknown cosign');
69
+ if (Number(match[1]) !== COSIGN_MAJOR) {
70
+ throw new Error(
71
+ `cosign major ${match[1]} found, but this verb needs cosign v${COSIGN_MAJOR}.x: v2 and v3 store attestations differently, ` +
72
+ 'so a mismatch with the develop attest step would make every promotion silently a build. Refusing.',
73
+ );
74
+ }
75
+ }
49
76
 
50
77
  const USAGE =
51
78
  'usage: xema-promote-image --service <name> --dev-ref <registry/repository> --release-ref <registry/repository> ' +
@@ -115,29 +142,32 @@ export function parsePromoteArguments(argv) {
115
142
 
116
143
  const MISS = Object.freeze({ result: PromoteResult.MISS, digest: '', source: '', evidence: PromoteEvidence.NONE });
117
144
 
118
- /** Runs the steps. Returns `{ result, digest, source, evidence, proof? }`; `proof` is set for evidence=develop-provenance. */
145
+ /**
146
+ * Runs the steps. Returns `{ result, digest, source, evidence, predicate?, revision?, provenanceDigest?, reason? }`;
147
+ * `predicate` is set on every non-miss result, `reason` on a miss that has one.
148
+ */
119
149
  export function promoteImage(options, run = runTool) {
120
- const { service, devRef, releaseRef, tag, repository, releaseWorkflows } = options;
150
+ const { service, devRef, releaseRef, tag, commit, repository, releaseWorkflows } = options;
151
+ assertCosignMajor(run);
121
152
  const releaseTagged = `${releaseRef}:${tag}`;
122
153
  const released = probeDigest(releaseTagged, run);
123
154
  if (released !== undefined) {
124
- const subject = `${releaseRef}@${released}`;
125
- if (releaseSignedByMain({ subject, repository, releaseWorkflows }, run)) {
126
- return { result: PromoteResult.REUSED, digest: released, source: subject, evidence: PromoteEvidence.RELEASE_SIGNATURE };
127
- }
128
- const developed = probeDigest(`${devRef}:${tag}`, run);
129
- if (developed !== released) {
130
- throw new Error(
131
- `${service}: ${releaseTagged} (${released}) carries no signature from main and is not the develop digest ` +
132
- `(${developed ?? 'no develop tag'}), so nothing proves it promotable. Refusing.`,
133
- );
134
- }
135
- const proof = verifyDevelopProvenance({ ...options, digest: released }, run);
136
- return { result: PromoteResult.REUSED, digest: released, source: `${devRef}@${released}`, evidence: PromoteEvidence.DEVELOP_PROVENANCE, proof };
155
+ const earlier = reuseEarlierPromotion({ releaseRef, tag, digest: released, repository, releaseWorkflows }, run);
156
+ if (earlier === undefined) return { ...MISS, reason: PromoteMissReason.NO_PROMOTION };
157
+ return {
158
+ result: PromoteResult.REUSED,
159
+ digest: released,
160
+ source: earlier.source.reference,
161
+ evidence: PromoteEvidence.PRIOR_PROMOTION,
162
+ revision: earlier.source.revision,
163
+ provenanceDigest: earlier.developProvenance.payloadDigest,
164
+ predicate: reusedPromotionPredicate({ releaseRef, tag, digest: released, commit, ...earlier }),
165
+ };
137
166
  }
138
167
  const developed = probeDigest(`${devRef}:${tag}`, run);
139
168
  if (developed === undefined) return MISS;
140
169
 
170
+ if (!hasDevelopProvenance({ devRef, digest: developed }, run)) return { ...MISS, reason: PromoteMissReason.NO_DEVELOP_PROVENANCE };
141
171
  const proof = verifyDevelopProvenance({ ...options, digest: developed }, run);
142
172
  const source = `${devRef}@${developed}`;
143
173
  copyByDigest(source, releaseTagged, run);
@@ -148,7 +178,15 @@ export function promoteImage(options, run = runTool) {
148
178
  'the copy did not preserve the digest, so the release tag does not name the develop bytes. Refusing.',
149
179
  );
150
180
  }
151
- return { result: PromoteResult.COPIED, digest: developed, source, evidence: PromoteEvidence.DEVELOP_PROVENANCE, proof };
181
+ return {
182
+ result: PromoteResult.COPIED,
183
+ digest: developed,
184
+ source,
185
+ evidence: PromoteEvidence.DEVELOP_PROVENANCE,
186
+ revision: proof.revision,
187
+ provenanceDigest: proof.provenanceDigest,
188
+ predicate: promotionPredicate({ releaseRef, tag, digest: developed, source, commit, ...proof }),
189
+ };
152
190
  }
153
191
 
154
192
  export function main(argv, { githubOutput, run = runTool, write = (text) => process.stdout.write(text) } = {}) {
@@ -157,29 +195,20 @@ export function main(argv, { githubOutput, run = runTool, write = (text) => proc
157
195
  if (existsSync(options.predicateOut)) {
158
196
  throw new Error(`--promotion-predicate-out ${options.predicateOut} already exists; refusing to leave a stale predicate to attest`);
159
197
  }
160
- const { result, digest, source, evidence, proof } = promoteImage(options, run);
161
- if (proof !== undefined) {
162
- writePromotionPredicate(options.predicateOut, {
163
- releaseRef: options.releaseRef,
164
- tag: options.tag,
165
- digest,
166
- source,
167
- commit: options.commit,
168
- ...proof,
169
- });
170
- }
198
+ const { result, digest, source, evidence, predicate, revision, provenanceDigest, reason } = promoteImage(options, run);
199
+ if (predicate !== undefined) writePromotionPredicate(options.predicateOut, predicate);
171
200
  const outputs = {
172
201
  result,
173
202
  digest,
174
203
  source,
175
- revision: proof?.revision ?? '',
204
+ revision: revision ?? '',
176
205
  evidence,
177
- provenance_digest: proof?.provenanceDigest ?? '',
178
- promotion_predicate: proof === undefined ? '' : options.predicateOut,
179
- promotion_predicate_type: proof === undefined ? '' : IMAGE_PROMOTION_PREDICATE_TYPE,
206
+ provenance_digest: provenanceDigest ?? '',
207
+ promotion_predicate: predicate === undefined ? '' : options.predicateOut,
208
+ promotion_predicate_type: predicate === undefined ? '' : IMAGE_PROMOTION_PREDICATE_TYPE,
180
209
  };
181
210
  appendFileSync(githubOutput, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''));
182
- write(`promote-image result=${result} digest=${digest} src=${source} revision=${outputs.revision} evidence=${evidence}\n`);
211
+ write(`promote-image result=${result} digest=${digest} src=${source} revision=${outputs.revision} evidence=${evidence}${reason === undefined ? '' : ` reason=${reason}`}\n`);
183
212
  }
184
213
 
185
214
  if (invokedDirectly(process.argv[1], import.meta.url)) {
@@ -23,7 +23,8 @@ const PROMOTION_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
23
23
  // answering from one JSON state file:
24
24
  // tags / errors / copyDigest `docker buildx imagetools inspect|create`
25
25
  // signatures[subject] the identity `cosign verify` finds
26
- // attestations[subject] { identity, statements } `cosign verify-attestation` finds
26
+ // downloadErrors[subject] what `cosign download attestation` fails with
27
+ // attestations[subject] { identity, statements, type? } (type defaults to slsaprovenance1) `cosign verify-attestation` finds
27
28
  // ancestors revisions `git merge-base --is-ancestor` accepts
28
29
  // hashes[commit] what the tag command prints for `--commit <commit>`
29
30
  // Every call is recorded in `calls` as [tool, ...args].
@@ -50,8 +51,21 @@ if (tool === 'docker') {
50
51
  if (args[2] === 'create') { state.tags[args[4]] = state.copyDigest ?? args[5].split('@')[1]; save(); process.exit(0); }
51
52
  fail('unexpected docker ' + args.join(' '), 2);
52
53
  }
54
+ if (tool === 'cosign' && args[0] === 'version') {
55
+ process.stdout.write(state.cosignVersion ?? 'GitVersion: v2.6.3\\nGitCommit: abc\\n');
56
+ process.exit(0);
57
+ }
53
58
  if (tool === 'cosign') {
54
59
  const subject = args[args.length - 1];
60
+ if (args[0] === 'download' && args[1] === 'attestation') {
61
+ if (state.downloadErrors[subject]) fail(state.downloadErrors[subject]);
62
+ const listed = state.attestations[subject];
63
+ if (!listed || listed.statements.length === 0) fail('Error: found no attestations\\nerror during command execution: found no attestations\\n');
64
+ for (const statement of listed.statements) {
65
+ process.stdout.write(JSON.stringify({ payloadType: 'application/vnd.in-toto+json', payload: Buffer.from(statement).toString('base64'), signatures: [] }) + '\\n');
66
+ }
67
+ process.exit(0);
68
+ }
55
69
  const pattern = new RegExp(flag('--certificate-identity-regexp'));
56
70
  if (flag('--certificate-oidc-issuer') !== 'https://token.actions.githubusercontent.com') fail('wrong issuer', 2);
57
71
  if (args[0] === 'verify') {
@@ -60,7 +74,10 @@ if (tool === 'cosign') {
60
74
  }
61
75
  if (args[0] === 'verify-attestation') {
62
76
  const found = state.attestations[subject];
63
- if (!found || !pattern.test(found.identity) || flag('--type') !== 'slsaprovenance1') fail('Error: no matching attestations: none of the expected identities matched\\n');
77
+ if (!found || !pattern.test(found.identity)) fail('Error: no matching attestations: none of the expected identities matched\\n');
78
+ const wanted = flag('--type');
79
+ const have = found.type ?? 'slsaprovenance1';
80
+ if (wanted !== have) fail('Error: none of the attestations matched the predicate type: ' + wanted + ', found: ' + have + '\\n');
64
81
  for (const statement of found.statements) {
65
82
  process.stdout.write(JSON.stringify({ payloadType: 'application/vnd.in-toto+json', payload: Buffer.from(statement).toString('base64'), signatures: [] }) + '\\n');
66
83
  }
@@ -108,7 +125,7 @@ function scenario(state) {
108
125
  fs.mkdirSync(binDir);
109
126
  for (const tool of ['docker', 'cosign', 'git', 'tag-command']) fs.writeFileSync(path.join(binDir, tool), SHIM, { mode: 0o755 });
110
127
  const stateFile = path.join(dir, 'state.json');
111
- fs.writeFileSync(stateFile, JSON.stringify({ tags: {}, errors: {}, signatures: {}, attestations: {}, ancestors: [], hashes: {}, calls: [], ...state }));
128
+ fs.writeFileSync(stateFile, JSON.stringify({ tags: {}, errors: {}, downloadErrors: {}, signatures: {}, attestations: {}, ancestors: [], hashes: {}, calls: [], ...state }));
112
129
  const output = path.join(dir, 'github-output');
113
130
  fs.writeFileSync(output, '');
114
131
  const predicate = path.join(dir, 'promotion.json');
@@ -155,24 +172,111 @@ function refused(state, pattern) {
155
172
  }
156
173
  }
157
174
 
158
- test('a release tag signed by a main workflow is reused, without develop proof or a predicate', () => {
175
+ const sha = (text) => `sha256:${createHash('sha256').update(text).digest('hex')}`;
176
+ const EARLIER_PROVENANCE = {
177
+ predicateType: 'https://slsa.dev/provenance/v1',
178
+ payloadDigest: sha('earlier-provenance'),
179
+ signerIdentityPattern: '^https://github\\.com/acme/svc/\\.github/workflows/(?:develop-images\\.yaml)@refs/heads/develop$',
180
+ };
181
+ const EARLIER_COMMIT = '5'.repeat(40);
182
+
183
+ // A release digest main already promoted: its image-promotion/v1 attestation, signed by a main workflow.
184
+ function promotedRelease({ source = `${DEV}@${OTHER}`, identity = MAIN_IDENTITY, type = PROMOTION_TYPE, extra = {} } = {}) {
185
+ const statement = JSON.stringify({
186
+ _type: 'https://in-toto.io/Statement/v1',
187
+ subject: [{ name: RELEASE, digest: { sha256: OTHER.slice('sha256:'.length) } }],
188
+ predicateType: PROMOTION_TYPE,
189
+ predicate: {
190
+ release: { repository: RELEASE, tag: TAG, digest: OTHER },
191
+ source: { reference: source, revision: REVISION },
192
+ promotedCommit: EARLIER_COMMIT,
193
+ developProvenance: EARLIER_PROVENANCE,
194
+ ...extra,
195
+ },
196
+ });
197
+ return {
198
+ tags: { [`${RELEASE}:${TAG}`]: OTHER },
199
+ attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: [statement] } },
200
+ };
201
+ }
202
+
203
+ test('a release digest main already promoted is reused: source and developProvenance carry over, release and commit are this run', () => {
204
+ const { run, predicatePath, cleanup } = scenario(promotedRelease());
205
+ try {
206
+ const r = run();
207
+ assert.equal(r.status, 0, r.stderr);
208
+ assert.equal(r.stdout, `promote-image result=reused digest=${OTHER} src=${DEV}@${OTHER} revision=${REVISION} evidence=prior-promotion\n`);
209
+ assert.deepEqual(r.outputs, {
210
+ result: 'reused', digest: OTHER, source: `${DEV}@${OTHER}`, revision: REVISION, evidence: 'prior-promotion',
211
+ provenance_digest: EARLIER_PROVENANCE.payloadDigest, promotion_predicate: predicatePath, promotion_predicate_type: PROMOTION_TYPE,
212
+ });
213
+ assert.deepEqual(r.predicate, {
214
+ release: { repository: RELEASE, tag: TAG, digest: OTHER },
215
+ source: { reference: `${DEV}@${OTHER}`, revision: REVISION },
216
+ promotedCommit: COMMIT,
217
+ developProvenance: EARLIER_PROVENANCE,
218
+ });
219
+ assert.deepEqual(creates(r.state), []);
220
+ assert.equal(r.state.calls.some((call) => call[0] === 'docker' && call[3] === `${DEV}:${TAG}`), false);
221
+ } finally {
222
+ cleanup();
223
+ }
224
+ });
225
+
226
+ test('a release digest with only SLSA provenance is a miss with a reason', () => {
159
227
  const { run, cleanup } = scenario({
160
228
  tags: { [`${RELEASE}:${TAG}`]: OTHER },
161
- signatures: { [`${RELEASE}@${OTHER}`]: MAIN_IDENTITY },
229
+ attestations: { [`${RELEASE}@${OTHER}`]: { identity: MAIN_IDENTITY, statements: [provenance(OTHER)] } },
162
230
  });
163
231
  try {
164
232
  const r = run();
165
233
  assert.equal(r.status, 0, r.stderr);
166
- assert.equal(r.stdout, `promote-image result=reused digest=${OTHER} src=${RELEASE}@${OTHER} revision= evidence=release-signature\n`);
167
- assert.equal(r.outputs.result, 'reused');
168
- assert.equal(r.outputs.promotion_predicate, '');
234
+ assert.equal(r.stdout, 'promote-image result=miss digest= src= revision= evidence=none reason=reuse-no-promotion\n');
235
+ assert.equal(r.outputs.result, 'miss');
169
236
  assert.equal(r.predicate, undefined);
170
- assert.deepEqual(creates(r.state), []);
171
237
  } finally {
172
238
  cleanup();
173
239
  }
174
240
  });
175
241
 
242
+ test('a registry error listing the attestations is an error, not a miss', () => {
243
+ refused(
244
+ { tags: { [`${RELEASE}:${TAG}`]: OTHER }, downloadErrors: { [`${RELEASE}@${OTHER}`]: 'Error: dial tcp: lookup ghcr.io: no such host\n' } },
245
+ /reuse: listing the attestations of .* failed: .*no such host/,
246
+ );
247
+ });
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
+
260
+ test('a promotion attestation signed by the wrong identity is refused, not a miss', () => {
261
+ refused(promotedRelease({ identity: DEVELOP_IDENTITY }), /reuse: .* has no image-promotion\/v1 attestation signed by/);
262
+ });
263
+
264
+ test('a promotion attestation whose source is outside dev/ is refused', () => {
265
+ refused(promotedRelease({ source: `ghcr.io/acme/svc@${OTHER}` }), /names source 'ghcr\.io\/acme\/svc@sha256:e+', not a ghcr\.io\/acme\/dev\//);
266
+ });
267
+
268
+ test('a promotion attestation stating another release repository is refused', () => {
269
+ refused(promotedRelease({ extra: { release: { repository: 'ghcr.io/acme/other', tag: TAG, digest: OTHER } } }), /states release repository ghcr\.io\/acme\/other, not /);
270
+ });
271
+
272
+ test('a promotion attestation stating another release tag is refused', () => {
273
+ refused(promotedRelease({ extra: { release: { repository: RELEASE, tag: 'a'.repeat(64), digest: OTHER } } }), /states release tag a+, not c+/);
274
+ });
275
+
276
+ test('a promotion attestation stating another release digest is refused', () => {
277
+ refused(promotedRelease({ extra: { release: { repository: RELEASE, tag: TAG, digest: DIGEST } } }), /states release digest sha256:d+, not /);
278
+ });
279
+
176
280
  test('a proven develop digest is copied by digest and the promotion predicate states the facts', () => {
177
281
  const { run, predicatePath, cleanup } = scenario(provenDevelop());
178
282
  try {
@@ -200,22 +304,40 @@ test('a proven develop digest is copied by digest and the promotion predicate st
200
304
  }
201
305
  });
202
306
 
203
- test('an unsigned release tag equal to the proven develop digest is reused with a promotion predicate', () => {
204
- const { run, cleanup } = scenario(provenDevelop({ tags: { [`${DEV}:${TAG}`]: DIGEST, [`${RELEASE}:${TAG}`]: DIGEST } }));
205
- try {
206
- const r = run();
207
- assert.equal(r.status, 0, r.stderr);
208
- assert.equal(r.outputs.result, 'reused');
209
- assert.equal(r.outputs.evidence, 'develop-provenance');
210
- assert.equal(r.predicate.source.revision, REVISION);
211
- assert.deepEqual(creates(r.state), []);
212
- } finally {
213
- cleanup();
214
- }
307
+ const SBOM = JSON.stringify({
308
+ _type: 'https://in-toto.io/Statement/v1',
309
+ subject: [{ name: DEV, digest: { sha256: DIGEST.slice('sha256:'.length) } }],
310
+ predicateType: 'https://spdx.dev/Document',
311
+ predicate: {},
215
312
  });
216
313
 
217
- test('an unsigned release tag that is not the develop digest is refused', () => {
218
- refused(provenDevelop({ tags: { [`${DEV}:${TAG}`]: DIGEST, [`${RELEASE}:${TAG}`]: OTHER } }), /carries no signature from main and is not the develop digest/);
314
+ for (const [name, statements] of [['no attestations', []], ['only an SBOM attestation', [SBOM]]]) {
315
+ test(`a dev digest with ${name} is a miss with a reason, not an error`, () => {
316
+ const { run, cleanup } = scenario(provenDevelop({ attestations: { [`${DEV}@${DIGEST}`]: { identity: DEVELOP_IDENTITY, statements } } }));
317
+ try {
318
+ const r = run();
319
+ assert.equal(r.status, 0, r.stderr);
320
+ assert.equal(r.stdout, 'promote-image result=miss digest= src= revision= evidence=none reason=copy-no-develop-provenance\n');
321
+ assert.equal(r.predicate, undefined);
322
+ assert.deepEqual(creates(r.state), []);
323
+ } finally {
324
+ cleanup();
325
+ }
326
+ });
327
+ }
328
+
329
+ test('a registry error listing the dev attestations is an error, not a miss', () => {
330
+ refused(
331
+ provenDevelop({ downloadErrors: { [`${DEV}@${DIGEST}`]: 'Error: dial tcp: lookup ghcr.io: no such host\n' } }),
332
+ /copy: listing the attestations of .* failed: .*no such host/,
333
+ );
334
+ });
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
+ );
219
341
  });
220
342
 
221
343
  test('leg (a): provenance signed by a unit-branch workflow is refused before any copy', () => {
@@ -266,7 +388,7 @@ test('a definite not-found in both repositories is a miss, not an error', () =>
266
388
  assert.equal(r.outputs.result, 'miss');
267
389
  assert.equal(r.outputs.digest, '');
268
390
  assert.equal(r.predicate, undefined);
269
- assert.equal(r.state.calls.length, 2);
391
+ assert.equal(r.state.calls.length, 3);
270
392
  } finally {
271
393
  cleanup();
272
394
  }
@@ -324,3 +446,26 @@ test('missing, malformed or unknown flags are refused before any external call',
324
446
  cleanup();
325
447
  }
326
448
  });
449
+
450
+ test('cosign of the pinned major passes and is asked once, before any other cosign call', () => {
451
+ const { run, cleanup } = scenario(provenDevelop());
452
+ try {
453
+ const r = run();
454
+ assert.equal(r.status, 0, r.stderr);
455
+ const cosign = r.state.calls.filter((call) => call[0] === 'cosign');
456
+ assert.deepEqual(cosign[0], ['cosign', 'version']);
457
+ assert.equal(cosign.filter((call) => call[1] === 'version').length, 1);
458
+ } finally {
459
+ cleanup();
460
+ }
461
+ });
462
+
463
+ test('a cosign of another major is refused before any registry call', () => {
464
+ const r = refused({ ...provenDevelop(), cosignVersion: 'GitVersion: v3.0.2\n' }, /cosign major 3 found, but this verb needs cosign v2\.x/);
465
+ assert.deepEqual(r.state.calls, [['cosign', 'version']]);
466
+ });
467
+
468
+ test('an unparsable cosign version is refused', () => {
469
+ const r = refused({ ...provenDevelop(), cosignVersion: 'GitVersion: devel\n' }, /no parsable GitVersion/);
470
+ assert.deepEqual(r.state.calls, [['cosign', 'version']]);
471
+ });