@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 +2 -1
- package/package.json +5 -3
- package/src/prepush-sanity.mjs +338 -0
- package/src/prepush-sanity.test.mjs +124 -0
- package/src/promote-image-predicate.mjs +9 -4
- package/src/promote-image-provenance.mjs +109 -38
- package/src/promote-image.mjs +73 -44
- package/src/promote-image.test.mjs +169 -24
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.
|
|
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:
|
|
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
|
|
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,
|
|
21
|
-
writeFileSync(path, `${JSON.stringify(
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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',
|
|
76
|
-
'--certificate-identity-regexp',
|
|
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
|
|
81
|
-
|
|
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(
|
|
68
|
+
throw new Error(`${label}: ${subject} carries ${statements.size} distinct verified ${noun} statements; exactly one is promotable`);
|
|
86
69
|
}
|
|
87
|
-
const [[
|
|
70
|
+
const [[payloadDigest, statement]] = statements;
|
|
88
71
|
const hex = digest.slice('sha256:'.length);
|
|
89
|
-
if (statement.predicateType !==
|
|
90
|
-
throw new Error(
|
|
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`);
|
package/src/promote-image.mjs
CHANGED
|
@@ -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
|
-
// -
|
|
8
|
-
//
|
|
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
|
-
//
|
|
16
|
-
// (`https://xema.dev/attestations/image-promotion/v1`) to
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 {
|
|
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,
|
|
161
|
-
if (
|
|
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:
|
|
204
|
+
revision: revision ?? '',
|
|
176
205
|
evidence,
|
|
177
|
-
provenance_digest:
|
|
178
|
-
promotion_predicate:
|
|
179
|
-
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
167
|
-
assert.equal(r.outputs.result, '
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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,
|
|
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
|
+
});
|