@xemahq/repo-build-tooling 0.21.1 → 0.21.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/buildx-builder.test.mjs +19 -9
- package/src/composed-image-in-image.mjs +11 -6
- package/src/composed-image-prebuilt-dockerfile.mjs +27 -1
- package/src/composed-image-prebuilt-dockerfile.test.mjs +24 -0
- package/src/composed-image-prisma-engine.mjs +89 -0
- package/src/composed-image-prisma-engine.test.mjs +73 -0
- package/src/prepush-sanity.mjs +19 -7
- package/src/prepush-sanity.test.mjs +16 -2
- package/src/promote-image-provenance.mjs +58 -2
- package/src/promote-image-registry.mjs +26 -0
- package/src/promote-image.mjs +38 -11
- package/src/promote-image.test.mjs +155 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
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
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 |
|
|
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>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
|
|
35
35
|
| `xema-buildx-builder ensure` | Ensures the runner's persistent, capped buildkitd container (`xema-buildkit-signed-<runner>`, state volume `-state`) is up and is the current buildx builder. Env: `RUNNER_NAME`, `BUILDKIT_IMAGE`, `BUILDKIT_LIMITS` (JSON `cpus`, `memory`, `maxParallelism`, `gcMaxUsedSpace`, all required and positive), optional `BUILDKIT_READY_DEADLINE_SECONDS` (positive integer, default 180). The slot is `RUNNER_NAME` with a trailing `-<hostname>` stripped only when it equals this container's own hostname, so a recreated runner container reuses its builder and cache. Pinned to the runner's cgroup cpuset; caps are clamped so every `xema-buildkit-signed-*` builder on the host fits (memory at most half of `docker info` MemTotal divided by N, CPUs at most NCPU divided by N). On each run it removes this verb's labelled builders, and their `-state` volumes, whose recorded runner container is no longer running; unlabelled containers are never touched. Recreated once when the image, effective caps, config or runner container change. Prints `buildx-boot path= secs= builder=`. |
|
|
36
36
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
37
37
|
| `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents (root `lint` only without turbo; typecheck is CI-only there); CI is the proof |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"@xemahq/release-policy": "^14.0.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"test": "node --test src/buildx-builder.test.mjs && 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"
|
|
52
|
+
"test": "node --test src/buildx-builder.test.mjs && node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs"
|
|
53
53
|
},
|
|
54
54
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:57594caa2c2d04aedf32ce374f8e0d5806022c23e5d14451c3263c925a641de4"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -6,8 +6,11 @@ import { join } from 'node:path';
|
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
|
+
import { runnerSlot } from './buildx-builder-host.mjs';
|
|
10
|
+
|
|
9
11
|
const BIN = fileURLToPath(new URL('./buildx-builder.mjs', import.meta.url));
|
|
10
12
|
const HOST = hostname();
|
|
13
|
+
const OTHER_HOST = `${HOST}-other`;
|
|
11
14
|
const OWN = 'xema-buildkit-signed-owl-runner-1';
|
|
12
15
|
const ownRow = (host = HOST) => `${OWN}|xema-buildx-builder|${host}`;
|
|
13
16
|
const LIMITS = '{"cpus":"4","memory":"8g","maxParallelism":4,"gcMaxUsedSpace":"5GB"}';
|
|
@@ -150,22 +153,29 @@ test('a recreated runner container (new hostname) recreates its builder once, ke
|
|
|
150
153
|
const first = s.run({});
|
|
151
154
|
const label = /--label xema\.buildkit\.cfg=([0-9a-f]{64})/.exec(first.calls)[1];
|
|
152
155
|
rmSync(join(s.dir, 'calls.log'));
|
|
153
|
-
const r = s.run({ FAKE_LIST: ownRow(
|
|
156
|
+
const r = s.run({ FAKE_LIST: ownRow(OTHER_HOST), FAKE_HOST: OTHER_HOST, FAKE_LABEL: label, FAKE_RUNNING: 'true' });
|
|
154
157
|
assert.match(r.stdout, /path=recreated/);
|
|
155
158
|
assert.doesNotMatch(r.calls, /volume rm/);
|
|
156
159
|
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
157
160
|
});
|
|
158
161
|
|
|
159
|
-
test('
|
|
162
|
+
test('runnerSlot strips a trailing -<hostname> only when it is exactly the injected hostname', () => {
|
|
163
|
+
assert.equal(runnerSlot('xema-fox1-2f7dad65784b', '2f7dad65784b'), 'xema-fox1');
|
|
164
|
+
assert.equal(runnerSlot('xema-fox1-2f7dad65784b', 'aaaaaaaaaaaa'), 'xema-fox1-2f7dad65784b');
|
|
165
|
+
assert.equal(runnerSlot('xema-fox1-2f7dad65784b', 'f7dad65784b'), 'xema-fox1-2f7dad65784b', 'a suffix of the id is not the id');
|
|
166
|
+
assert.equal(runnerSlot('xema-fox1-2f7dad65784b', 'ad65784b'), 'xema-fox1-2f7dad65784b');
|
|
167
|
+
assert.equal(runnerSlot('2f7dad65784b', '2f7dad65784b'), '2f7dad65784b', 'the whole name is kept: there is no slot left to strip to');
|
|
168
|
+
assert.equal(runnerSlot('Owl Runner/1', ''), 'owl-runner-1');
|
|
169
|
+
assert.throws(() => runnerSlot('', 'host'), /RUNNER_NAME/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the verb names the builder and its volume from the stripped slot', () => {
|
|
160
173
|
const s = sandbox();
|
|
161
174
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
assert.match(
|
|
165
|
-
|
|
166
|
-
assert.match(other.stdout, /builder=xema-buildkit-signed-xema-fox1-2f7dad65784b /);
|
|
167
|
-
const whole = s.run({ RUNNER_NAME: HOST });
|
|
168
|
-
assert.match(whole.stdout, new RegExp(`builder=xema-buildkit-signed-${HOST.toLowerCase().replace(/[^a-z0-9_.-]/g, '-')} `));
|
|
175
|
+
// HOST is only the value the process under test will read; the case is true whatever it is.
|
|
176
|
+
const r = s.run({ RUNNER_NAME: `xema-fox1-${HOST}` });
|
|
177
|
+
assert.match(r.stdout, /builder=xema-buildkit-signed-xema-fox1 /);
|
|
178
|
+
assert.match(r.calls, /-v xema-buildkit-signed-xema-fox1-state:/);
|
|
169
179
|
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
170
180
|
});
|
|
171
181
|
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// THE IN-IMAGE HALF OF THE COMPOSED-PROCESS BUILD.
|
|
4
4
|
//
|
|
5
5
|
// The prebuilt Dockerfile bind-mounts a copy of this package's `package.json` + `src/`
|
|
6
|
-
// (NO node_modules) and runs THIS file for `pins`, `index`, `load`, `sync-injected
|
|
7
|
-
// `prisma-check`. So this module and everything it imports
|
|
8
|
-
// relative modules; a bare package import would die with
|
|
9
|
-
// (`composed-image-in-image-closure.test.mjs` proves
|
|
10
|
-
// these functions and dispatches the same verbs
|
|
6
|
+
// (NO node_modules) and runs THIS file for `pins`, `index`, `load`, `sync-injected`,
|
|
7
|
+
// `prisma-check` and `prisma-schema-engine`. So this module and everything it imports
|
|
8
|
+
// load only `node:` builtins and relative modules; a bare package import would die with
|
|
9
|
+
// ERR_MODULE_NOT_FOUND in the image (`composed-image-in-image-closure.test.mjs` proves
|
|
10
|
+
// it). `composed-image.mjs` re-exports these functions and dispatches the same verbs
|
|
11
|
+
// outside Docker, except `prisma-schema-engine`, which writes into the installed tree and
|
|
12
|
+
// so runs only in the image.
|
|
11
13
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
14
|
|
|
13
15
|
import { spawnSync } from 'node:child_process';
|
|
@@ -20,6 +22,7 @@ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
|
20
22
|
import { readMembers } from './composed-image-members.mjs';
|
|
21
23
|
import { syncInjected } from './composed-image-sync-injected.mjs';
|
|
22
24
|
import { prismaCheck } from './composed-image-prisma-check.mjs';
|
|
25
|
+
import { installPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
23
26
|
|
|
24
27
|
export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
|
|
25
28
|
export const HOST_LINK = 'xema-composed-process';
|
|
@@ -182,7 +185,7 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
|
|
|
182
185
|
|
|
183
186
|
const USAGE =
|
|
184
187
|
'usage: composed-image-in-image <command> [--root <dir>] [--image-dir <dir>]\n' +
|
|
185
|
-
' pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each] | sync-injected | prisma-check';
|
|
188
|
+
' pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each] | sync-injected | prisma-check | prisma-schema-engine <staged dir>';
|
|
186
189
|
|
|
187
190
|
function parseInImageArguments(argv) {
|
|
188
191
|
const positional = [];
|
|
@@ -234,6 +237,8 @@ export function main(argv, { log = console.log } = {}) {
|
|
|
234
237
|
return;
|
|
235
238
|
case 'prisma-check':
|
|
236
239
|
return prismaCheck({ root, imageDir, log }).then(() => undefined);
|
|
240
|
+
case 'prisma-schema-engine':
|
|
241
|
+
return installPrismaSchemaEngine({ root, stagedDir: argument === undefined ? undefined : resolve(argument), log }).then(() => undefined);
|
|
237
242
|
default:
|
|
238
243
|
throw new Error(USAGE);
|
|
239
244
|
}
|
|
@@ -11,6 +11,7 @@ import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
|
|
|
11
11
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
12
12
|
import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
|
|
13
13
|
import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
|
|
14
|
+
import { needsPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
14
15
|
|
|
15
16
|
export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
16
17
|
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
@@ -139,6 +140,31 @@ export function memberProdPackages({ root = process.cwd(), imageDir = COMPOSED_I
|
|
|
139
140
|
return [...seen].map(([name, dir]) => ({ name, dir }));
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/** Where the deps stage bind-mounts `<stage dir>/prisma-engines` (`<enginesVersion>/schema-engine-<binaryTarget>`). */
|
|
144
|
+
const PRISMA_ENGINES_MOUNT = '/xema-prisma-engines';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The deps-stage step that puts the Prisma schema engine where Prisma looks, rendered only when the
|
|
148
|
+
* prod closure carries the prisma CLI (the one thing that runs the engine). It runs in DEPS so the
|
|
149
|
+
* engine lands in `node_modules/.pnpm/@prisma+engines@<v>/node_modules/@prisma/engines/`, which the
|
|
150
|
+
* final stage takes from deps (layer 1); assemble's copy excludes the .pnpm store.
|
|
151
|
+
*/
|
|
152
|
+
function prismaSchemaEngineStep({ toolMount, node, stageDir }) {
|
|
153
|
+
return `
|
|
154
|
+
# The Prisma SCHEMA ENGINE for this image's platform. \`prisma migrate deploy\` runs it (the
|
|
155
|
+
# Rust-free client does not replace it), and the prisma CLI downloads it into @prisma/engines
|
|
156
|
+
# at RUNTIME when it is missing, which the read-only, non-root container refuses. The install
|
|
157
|
+
# above is --ignore-scripts, so @prisma/engines' postinstall never fetched it. The runner
|
|
158
|
+
# staged it for every image platform at the lockfile's engines version (Prisma's downloader,
|
|
159
|
+
# sha256-checked) in \`${stageDir}/prisma-engines/\`; this copies the one for the platform
|
|
160
|
+
# Prisma computes HERE into each @prisma/engines a prisma CLI resolves, and fails the build
|
|
161
|
+
# unless it runs and reports that version. Here in deps, so the final stage carries it.
|
|
162
|
+
RUN --network=none ${toolMount} \\
|
|
163
|
+
--mount=type=bind,source=${stageDir}/prisma-engines,target=${PRISMA_ENGINES_MOUNT} \\
|
|
164
|
+
node ${node} prisma-schema-engine ${PRISMA_ENGINES_MOUNT}
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
142
168
|
const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
|
|
143
169
|
|
|
144
170
|
/** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
|
|
@@ -252,7 +278,7 @@ ${secret} ${toolMount} \\
|
|
|
252
278
|
node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
|
|
253
279
|
pnpm install --prod --offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile${rebuild.length === 0 ? '' : ` && \\
|
|
254
280
|
pnpm rebuild ${rebuild.join(' ')} ${filters}`}
|
|
255
|
-
|
|
281
|
+
${needsPrismaSchemaEngine(prodPackages) ? prismaSchemaEngineStep({ toolMount, node, stageDir }) : ''}
|
|
256
282
|
FROM deps AS assemble
|
|
257
283
|
COPY --parents \\
|
|
258
284
|
${parts(assembleSources)}
|
|
@@ -175,3 +175,27 @@ test('memberProdPackages reads the installed prod closure and refuses an empty o
|
|
|
175
175
|
assert.deepEqual(depsCopyDirs(dockerfile), ['tooling/dev/', 'tooling/ci/']);
|
|
176
176
|
assert.deepEqual(depsCopyDirs(renderPrebuiltDockerfile(options(root, { copy: [] })).dockerfile), []);
|
|
177
177
|
});
|
|
178
|
+
|
|
179
|
+
test('the Prisma schema engine step renders in deps only when the prod closure carries the prisma CLI; otherwise the bytes are unchanged', () => {
|
|
180
|
+
const root = workspace();
|
|
181
|
+
const without = renderPrebuiltDockerfile(options(root, { prodPackages: [installed(root, 'left-pad'), installed(root, '@prisma/client')] })).dockerfile;
|
|
182
|
+
assert.equal(without, renderPrebuiltDockerfile(options(root)).dockerfile);
|
|
183
|
+
assert.doesNotMatch(without, /prisma-schema-engine|prisma-engines/);
|
|
184
|
+
|
|
185
|
+
const withCli = renderPrebuiltDockerfile(options(root, { prodPackages: [installed(root, 'prisma'), installed(root, '@prisma/engines')] })).dockerfile;
|
|
186
|
+
const step =
|
|
187
|
+
'RUN --network=none --mount=type=bind,source=.xema-stage/tool,target=/opt/xema-composed-image \\\n' +
|
|
188
|
+
' --mount=type=bind,source=.xema-stage/prisma-engines,target=/xema-prisma-engines \\\n' +
|
|
189
|
+
' node /opt/xema-composed-image/src/composed-image-in-image.mjs prisma-schema-engine /xema-prisma-engines\n';
|
|
190
|
+
assert.equal(withCli.split(step).length, 2, 'exactly one engine step');
|
|
191
|
+
const [deps, rest] = withCli.split('FROM deps AS assemble');
|
|
192
|
+
assert.ok(deps.includes(step), 'the step is in deps, whose /monorepo the final stage copies whole (.pnpm included)');
|
|
193
|
+
assert.ok(deps.indexOf(step) > deps.indexOf('pnpm install --prod --offline --ignore-scripts'), 'after the install');
|
|
194
|
+
assert.doesNotMatch(rest, /prisma-schema-engine/);
|
|
195
|
+
// Only the commented step is added: removing it gives back the render without the CLI.
|
|
196
|
+
const added = /\n# The Prisma SCHEMA ENGINE[\s\S]*?prisma-schema-engine \/xema-prisma-engines\n/.exec(withCli)[0];
|
|
197
|
+
assert.equal(withCli.replace(added, ''), without);
|
|
198
|
+
|
|
199
|
+
const custom = renderPrebuiltDockerfile(options(root, { stageDir: 'build/stage', prodPackages: [installed(root, 'prisma')] })).dockerfile;
|
|
200
|
+
assert.match(custom, /--mount=type=bind,source=build\/stage\/prisma-engines,target=\/xema-prisma-engines/);
|
|
201
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `xema-composed-image prisma-schema-engine <staged dir>`, INSIDE the prebuilt image
|
|
2
|
+
// build, `--network=none`, on the TARGET arch, right after the deps stage's offline
|
|
3
|
+
// `pnpm install --prod --ignore-scripts`.
|
|
4
|
+
//
|
|
5
|
+
// `prisma migrate deploy` runs Prisma's NATIVE schema engine (the Rust-free client does
|
|
6
|
+
// not replace it). Before every migrate command the prisma CLI calls @prisma/engines'
|
|
7
|
+
// `ensureNeededBinariesExist`, which looks for `schema-engine-<binaryTarget>` in the
|
|
8
|
+
// @prisma/engines package directory and, when it is absent or reports another version,
|
|
9
|
+
// downloads it INTO that directory. A read-only, non-root container refuses the write
|
|
10
|
+
// ("Can't write to …/@prisma/engines"). The install runs `--ignore-scripts`, so the
|
|
11
|
+
// postinstall that would have fetched it never ran.
|
|
12
|
+
//
|
|
13
|
+
// The runner staged the engine for every image platform with Prisma's own downloader
|
|
14
|
+
// (which verifies each file against its published sha256), at the engines version the
|
|
15
|
+
// lockfile pins, as `<staged dir>/<enginesVersion>/schema-engine-<binaryTarget>`. This
|
|
16
|
+
// copies, for every prisma CLI the install carries, the engine for the platform Prisma
|
|
17
|
+
// computes HERE to the exact path Prisma looks at, then runs it and refuses the build
|
|
18
|
+
// unless it reports the pinned engines version: the same check Prisma makes before it
|
|
19
|
+
// decides to download. No environment variable points at it.
|
|
20
|
+
//
|
|
21
|
+
// Loads only `node:` builtins (see composed-image-in-image.mjs); Prisma's own modules are
|
|
22
|
+
// resolved from the installed tree.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { chmodSync, copyFileSync, existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** The prod-closure package whose presence makes the image need the schema engine: the CLI that runs it. */
|
|
29
|
+
export const PRISMA_CLI = 'prisma';
|
|
30
|
+
|
|
31
|
+
/** True when the members' prod closure (`[{ name }]`) carries the prisma CLI. */
|
|
32
|
+
export const needsPrismaSchemaEngine = (prodPackages) => prodPackages.some((p) => p.name === PRISMA_CLI);
|
|
33
|
+
|
|
34
|
+
/** Every distinct @prisma/engines directory (realpath) a prisma CLI under `<root>/node_modules/.pnpm` resolves. */
|
|
35
|
+
export function prismaEnginesDirectories(root) {
|
|
36
|
+
const store = join(root, 'node_modules', '.pnpm');
|
|
37
|
+
if (!existsSync(store)) throw new Error(`${store} does not exist: run the install first`);
|
|
38
|
+
const directories = new Set();
|
|
39
|
+
for (const entry of readdirSync(store).filter((name) => name.startsWith(`${PRISMA_CLI}@`)).sort()) {
|
|
40
|
+
const manifest = join(store, entry, 'node_modules', PRISMA_CLI, 'package.json');
|
|
41
|
+
if (!existsSync(manifest)) continue;
|
|
42
|
+
let engines;
|
|
43
|
+
try {
|
|
44
|
+
engines = createRequire(manifest).resolve('@prisma/engines/package.json');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`${dirname(manifest)} does not resolve @prisma/engines: ${error?.message ?? String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
directories.add(dirname(realpathSync(engines)));
|
|
49
|
+
}
|
|
50
|
+
return [...directories];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const runVersion = (file) => spawnSync(file, ['--version'], { encoding: 'utf8' });
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Install the staged schema engine into every @prisma/engines directory a prisma CLI resolves.
|
|
57
|
+
* `binaryTarget` and `version` are injectable for tests; by default they are Prisma's own
|
|
58
|
+
* (`@prisma/get-platform` and the engine's `--version`, resolved from each @prisma/engines).
|
|
59
|
+
*/
|
|
60
|
+
export async function installPrismaSchemaEngine({ root = process.cwd(), stagedDir, log = console.log, binaryTarget, version = runVersion } = {}) {
|
|
61
|
+
if (typeof stagedDir !== 'string' || stagedDir.length === 0) throw new Error('prisma-schema-engine needs the staged engines directory');
|
|
62
|
+
if (!existsSync(stagedDir)) throw new Error(`${stagedDir} does not exist: stage the Prisma schema engines into it before the image build`);
|
|
63
|
+
const directories = prismaEnginesDirectories(root);
|
|
64
|
+
if (directories.length === 0) {
|
|
65
|
+
throw new Error(`no ${PRISMA_CLI} CLI is installed under ${join(root, 'node_modules', '.pnpm')}, yet the Dockerfile was rendered for one: re-render it`);
|
|
66
|
+
}
|
|
67
|
+
const installed = [];
|
|
68
|
+
for (const enginesDir of directories) {
|
|
69
|
+
const fromEngines = createRequire(join(enginesDir, 'package.json'));
|
|
70
|
+
const { enginesVersion } = fromEngines('@prisma/engines-version');
|
|
71
|
+
const target = binaryTarget ?? (await fromEngines('@prisma/get-platform').getBinaryTargetForCurrentPlatform());
|
|
72
|
+
const fileName = `schema-engine-${target}`;
|
|
73
|
+
const source = join(stagedDir, enginesVersion, fileName);
|
|
74
|
+
if (!existsSync(source)) {
|
|
75
|
+
throw new Error(`no staged schema engine ${source} (engines ${enginesVersion}, platform ${target}): stage it for every image platform at the lockfile's engines version`);
|
|
76
|
+
}
|
|
77
|
+
const destination = join(enginesDir, fileName);
|
|
78
|
+
copyFileSync(source, destination);
|
|
79
|
+
chmodSync(destination, 0o755);
|
|
80
|
+
const result = version(destination);
|
|
81
|
+
if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
|
|
82
|
+
const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
|
|
83
|
+
throw new Error(`${destination} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
|
|
84
|
+
}
|
|
85
|
+
log(`prisma schema engine: ${destination} (${String(result.stdout).trim()})`);
|
|
86
|
+
installed.push(destination);
|
|
87
|
+
}
|
|
88
|
+
return installed;
|
|
89
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test, { after } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { installPrismaSchemaEngine, needsPrismaSchemaEngine, prismaEnginesDirectories } from './composed-image-prisma-engine.mjs';
|
|
8
|
+
|
|
9
|
+
const VERSION = '7.8.0-6.abc';
|
|
10
|
+
const temps = [];
|
|
11
|
+
after(() => {
|
|
12
|
+
for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function write(file, text) {
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
+
fs.writeFileSync(file, text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A pnpm-layout tree: prisma@7.8.0 -> @prisma/engines@7.8.0 -> @prisma/engines-version, and a staged engines dir. */
|
|
21
|
+
function tree({ stagedTargets = ['debian-openssl-3.0.x', 'linux-arm64-openssl-3.0.x'], cli = true } = {}) {
|
|
22
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-prisma-engine-'));
|
|
23
|
+
temps.push(root);
|
|
24
|
+
const store = path.join(root, 'node_modules', '.pnpm');
|
|
25
|
+
const engines = path.join(store, '@prisma+engines@7.8.0', 'node_modules', '@prisma', 'engines');
|
|
26
|
+
write(path.join(engines, 'package.json'), JSON.stringify({ name: '@prisma/engines', main: 'index.js' }));
|
|
27
|
+
write(path.join(engines, 'index.js'), 'module.exports = {};');
|
|
28
|
+
const version = path.join(store, '@prisma+engines@7.8.0', 'node_modules', '@prisma', 'engines-version');
|
|
29
|
+
write(path.join(version, 'package.json'), JSON.stringify({ name: '@prisma/engines-version', main: 'index.js' }));
|
|
30
|
+
write(path.join(version, 'index.js'), `module.exports = { enginesVersion: ${JSON.stringify(VERSION)} };`);
|
|
31
|
+
if (cli) {
|
|
32
|
+
const prisma = path.join(store, 'prisma@7.8.0_typescript@5.9.3', 'node_modules');
|
|
33
|
+
write(path.join(prisma, 'prisma', 'package.json'), JSON.stringify({ name: 'prisma' }));
|
|
34
|
+
fs.mkdirSync(path.join(prisma, '@prisma'), { recursive: true });
|
|
35
|
+
fs.symlinkSync(engines, path.join(prisma, '@prisma', 'engines'));
|
|
36
|
+
}
|
|
37
|
+
const staged = path.join(root, 'staged');
|
|
38
|
+
for (const target of stagedTargets) write(path.join(staged, VERSION, `schema-engine-${target}`), `engine ${target}`);
|
|
39
|
+
return { root, engines: fs.realpathSync(engines), staged };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const reports = (text) => () => ({ status: 0, stdout: `schema-engine-cli ${text}\n` });
|
|
43
|
+
|
|
44
|
+
test('needsPrismaSchemaEngine: only the prisma CLI in the prod closure asks for the engine', () => {
|
|
45
|
+
assert.equal(needsPrismaSchemaEngine([{ name: 'prisma' }, { name: 'x' }]), true);
|
|
46
|
+
assert.equal(needsPrismaSchemaEngine([{ name: '@prisma/client' }, { name: '@prisma/engines' }]), false);
|
|
47
|
+
assert.equal(needsPrismaSchemaEngine([]), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('the engine for the computed platform lands in the @prisma/engines the CLI resolves (realpath), executable, version-checked', async () => {
|
|
51
|
+
const { root, engines, staged } = tree();
|
|
52
|
+
assert.deepEqual(prismaEnginesDirectories(root), [engines]);
|
|
53
|
+
const lines = [];
|
|
54
|
+
const installed = await installPrismaSchemaEngine({ root, stagedDir: staged, binaryTarget: 'linux-arm64-openssl-3.0.x', version: reports(VERSION), log: (l) => lines.push(l) });
|
|
55
|
+
const target = path.join(engines, 'schema-engine-linux-arm64-openssl-3.0.x');
|
|
56
|
+
assert.deepEqual(installed, [target]);
|
|
57
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'engine linux-arm64-openssl-3.0.x');
|
|
58
|
+
assert.equal(fs.statSync(target).mode & 0o777, 0o755);
|
|
59
|
+
assert.equal(fs.existsSync(path.join(engines, 'schema-engine-debian-openssl-3.0.x')), false, 'only the computed platform');
|
|
60
|
+
assert.match(lines[0], new RegExp(`${VERSION}`));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the build fails loudly: no staged file for the platform, a wrong version, a failing engine, no CLI, no staged dir', async () => {
|
|
64
|
+
const { root, staged } = tree({ stagedTargets: ['debian-openssl-3.0.x'] });
|
|
65
|
+
const run = (extra) => installPrismaSchemaEngine({ root, stagedDir: staged, binaryTarget: 'debian-openssl-3.0.x', version: reports(VERSION), log: () => {}, ...extra });
|
|
66
|
+
await assert.rejects(run({ binaryTarget: 'linux-arm64-openssl-3.0.x' }), /no staged schema engine .*schema-engine-linux-arm64-openssl-3\.0\.x \(engines 7\.8\.0-6\.abc, platform linux-arm64/);
|
|
67
|
+
await assert.rejects(run({ version: reports('7.7.0-1.other') }), /--version did not report 7\.8\.0-6\.abc/);
|
|
68
|
+
await assert.rejects(run({ version: () => ({ status: null, error: new Error('Exec format error') }) }), /did not report .*Exec format error/);
|
|
69
|
+
await assert.rejects(run({ stagedDir: path.join(root, 'absent') }), /does not exist: stage the Prisma schema engines/);
|
|
70
|
+
await assert.rejects(run({ stagedDir: undefined }), /needs the staged engines directory/);
|
|
71
|
+
const bare = tree({ cli: false });
|
|
72
|
+
await assert.rejects(installPrismaSchemaEngine({ root: bare.root, stagedDir: bare.staged, binaryTarget: 'debian-openssl-3.0.x', log: () => {} }), /no prisma CLI is installed/);
|
|
73
|
+
});
|
package/src/prepush-sanity.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// With turbo (a `turbo.json` and a turbo dependency): turbo `lint` and `typecheck` run only on the packages
|
|
8
8
|
// the pushed commits change plus their dependents. A change to the lockfile,
|
|
9
|
-
//
|
|
10
|
-
// graph. A repo whose turbo carries no `lint` task also runs its root `lint`
|
|
9
|
+
// turbo.json, or anything in the root package.json other than its `scripts`
|
|
10
|
+
// table invalidates everything and runs the full graph. A repo whose turbo carries no `lint` task also runs its root `lint`
|
|
11
11
|
// script. Without turbo: the root `lint` script only (typecheck needs a build, which the hook never runs; CI has it).
|
|
12
12
|
//
|
|
13
13
|
// The pushed SHA is what is graded. In place when the tree is clean and the SHA
|
|
@@ -80,9 +80,20 @@ export function parseRefs(stdin) {
|
|
|
80
80
|
return refs;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/** True when a
|
|
84
|
-
export function
|
|
85
|
-
|
|
83
|
+
/** True when two root package.json texts differ only in the top-level `scripts` key; a missing side or unparseable JSON is false. */
|
|
84
|
+
export function scriptsOnlyChange(baseText, tipText) {
|
|
85
|
+
try {
|
|
86
|
+
const { scripts: _baseScripts, ...base } = JSON.parse(baseText);
|
|
87
|
+
const { scripts: _tipScripts, ...tip } = JSON.parse(tipText);
|
|
88
|
+
return JSON.stringify(base) === JSON.stringify(tip);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when a changed path invalidates every package. `rootScriptsOnly`: the root package.json change is the `scripts` table alone. */
|
|
95
|
+
export function isFleetWide(changedPaths, rootScriptsOnly = false) {
|
|
96
|
+
return changedPaths.some((p) => p === 'pnpm-lock.yaml' || p === 'turbo.json' || (p === 'package.json' && !rootScriptsOnly));
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
/**
|
|
@@ -148,8 +159,9 @@ function rangeFor(ctx, base, sha) {
|
|
|
148
159
|
return '';
|
|
149
160
|
}
|
|
150
161
|
const changed = (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean);
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
const rootScriptsOnly = changed.includes('package.json') && scriptsOnlyChange(ctx.gitOut(['show', `${base}:package.json`]) ?? '', ctx.gitOut(['show', `${sha}:package.json`]) ?? '');
|
|
163
|
+
if (isFleetWide(changed, rootScriptsOnly)) {
|
|
164
|
+
console.error('pre-push: lockfile, turbo.json or root package.json beyond its scripts changed — fleet-wide, running the FULL graph.');
|
|
153
165
|
return '';
|
|
154
166
|
}
|
|
155
167
|
return `${base}...${sha}`;
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { test } from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { blockingPath, isFleetWide, parseRefs, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
8
|
+
import { blockingPath, isFleetWide, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
9
9
|
|
|
10
10
|
const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
|
|
11
11
|
const SHA_A = 'a'.repeat(40);
|
|
@@ -29,12 +29,26 @@ test('parseRefs dedupes shas and drops deletes and junk', () => {
|
|
|
29
29
|
assert.deepEqual(parseRefs(''), []);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test('isFleetWide flags
|
|
32
|
+
test('isFleetWide flags the lockfile, turbo.json and a root package.json change beyond scripts', () => {
|
|
33
33
|
assert.equal(isFleetWide(['pnpm-lock.yaml']), true);
|
|
34
|
+
assert.equal(isFleetWide(['turbo.json']), true);
|
|
34
35
|
assert.equal(isFleetWide(['package.json']), true);
|
|
36
|
+
assert.equal(isFleetWide(['package.json'], true), false);
|
|
37
|
+
assert.equal(isFleetWide(['package.json', 'pnpm-lock.yaml'], true), true);
|
|
35
38
|
assert.equal(isFleetWide(['packages/a/package.json', 'src/x.ts']), false);
|
|
36
39
|
});
|
|
37
40
|
|
|
41
|
+
test('scriptsOnlyChange: only the scripts key may differ; anything else or unreadable is fleet-wide', () => {
|
|
42
|
+
const base = { name: 't', scripts: { a: '1' }, dependencies: { x: '1' }, packageManager: 'pnpm@10.0.0' };
|
|
43
|
+
const text = (o) => JSON.stringify(o);
|
|
44
|
+
assert.equal(scriptsOnlyChange(text(base), text({ ...base, scripts: { a: '2', b: '3' } })), true);
|
|
45
|
+
for (const change of [{ dependencies: { x: '2' } }, { packageManager: 'pnpm@10.1.0' }, { pnpm: { overrides: {} } }, { engines: { node: '22' } }]) {
|
|
46
|
+
assert.equal(scriptsOnlyChange(text(base), text({ ...base, ...change })), false, Object.keys(change)[0]);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(scriptsOnlyChange('', text(base)), false);
|
|
49
|
+
assert.equal(scriptsOnlyChange(text(base), '{ nope'), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
38
52
|
test('blockingPath: package reads and repo-root files block, root markdown does not', () => {
|
|
39
53
|
const owned = (p) => p.startsWith('packages/');
|
|
40
54
|
assert.equal(blockingPath(['packages/a/src/x.ts'], ['packages/a'], owned)?.why, 'is in a package this run reads');
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// nearest commit, is an error; the selected statements must all yield the same source and developProvenance.
|
|
23
23
|
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
|
-
import { describeFailure, runTool } from './promote-image-registry.mjs';
|
|
25
|
+
import { DIGEST_PATTERN, describeFailure, isImageIndex, readManifestByDigest, runTool } from './promote-image-registry.mjs';
|
|
26
26
|
|
|
27
27
|
export const GITHUB_SERVER = 'https://github.com';
|
|
28
28
|
export const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
@@ -145,7 +145,7 @@ function attestationTypes(subject, label, run) {
|
|
|
145
145
|
* Returns `undefined` when the digest carries no promotion attestation (a legacy image); a registry error, a signature or identity
|
|
146
146
|
* failure, or a source outside `<registry>/<owner>/dev/`, is an error.
|
|
147
147
|
*/
|
|
148
|
-
export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows, commit }, run = runTool) {
|
|
148
|
+
export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows, commit, exactCommit = false }, run = runTool) {
|
|
149
149
|
const subject = `${releaseRef}@${digest}`;
|
|
150
150
|
const label = 'reuse';
|
|
151
151
|
// (a) presence, without identity verification: only the predicate types are read
|
|
@@ -165,6 +165,8 @@ export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, rel
|
|
|
165
165
|
const nearest = nearestPromotedCommit(
|
|
166
166
|
new Set(about.map(promoted).filter((value) => typeof value === 'string' && COMMIT.test(value))), commit, subject, label, run,
|
|
167
167
|
);
|
|
168
|
+
// `exactCommit`: only a statement promoting this very commit counts; an ancestor's is `undefined` (the caller's miss)
|
|
169
|
+
if (exactCommit && nearest !== commit) return undefined;
|
|
168
170
|
const selected = about.filter((statement) => promoted(statement) === nearest);
|
|
169
171
|
const admitted = new Set(selected.map((statement) => JSON.stringify(admissible(statement, { subject, tag, digest, releaseRef, repository, label }))));
|
|
170
172
|
if (admitted.size !== 1) {
|
|
@@ -201,6 +203,60 @@ function admissible(statement, { subject, tag, digest, releaseRef, repository, l
|
|
|
201
203
|
};
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
// cosign v2's definite "this digest is not signed by that identity" answers; any other failure is an error.
|
|
207
|
+
const NO_MATCHING_SIGNATURE = /\bno (?:matching )?signatures\b/i;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether `subject` carries a signature by `pattern` (GitHub OIDC issuer). A definite "no matching signature" is
|
|
211
|
+
* `false`; a failure to run cosign or any other cosign failure is an error.
|
|
212
|
+
*/
|
|
213
|
+
function hasSignature(subject, pattern, label, run) {
|
|
214
|
+
const verified = run('cosign', ['verify', '--certificate-identity-regexp', pattern, '--certificate-oidc-issuer', GITHUB_OIDC_ISSUER, subject]);
|
|
215
|
+
if (verified.error) throw new Error(`${label}: could not run cosign to verify ${subject}: ${verified.error.message}`);
|
|
216
|
+
if (verified.status === 0) return true;
|
|
217
|
+
const detail = describeFailure(verified);
|
|
218
|
+
if (verified.status === 1 && NO_MATCHING_SIGNATURE.test(detail)) return false;
|
|
219
|
+
throw new Error(`${label}: verifying the signature of ${subject} failed: ${detail}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Reuse of a release tag that release-arm64 already moved from main's amd64 digest A to a multi-arch index D. Every
|
|
224
|
+
* step fails closed: `undefined` (the caller's existing miss) unless ALL of
|
|
225
|
+
* (1) `releaseDigest` (what `<releaseRef>:<tag>` resolves to), read by digest, is an image index D;
|
|
226
|
+
* (2) D is signed by `indexWorkflow` of `repository` on main;
|
|
227
|
+
* (3) D has exactly one linux/amd64 child A (two are refused as ambiguous);
|
|
228
|
+
* (4) A carries a verified image-promotion/v1 statement under main's `releaseWorkflows` whose selected (nearest)
|
|
229
|
+
* promotedCommit IS `commit` (a rerun; an ancestor's is the miss), bound to this repository, tag and digest A
|
|
230
|
+
* (`reuseEarlierPromotion`, whose refusals stay errors).
|
|
231
|
+
* Returns `{ digest: A, index: D, source, developProvenance }`.
|
|
232
|
+
*/
|
|
233
|
+
export function reuseThroughReleaseIndex({ releaseRef, tag, releaseDigest, repository, releaseWorkflows, indexWorkflow, commit }, run = runTool) {
|
|
234
|
+
const label = 'reuse via release index';
|
|
235
|
+
const index = releaseDigest;
|
|
236
|
+
// (1) an index, read by digest and hash-checked
|
|
237
|
+
const manifest = readManifestByDigest(releaseRef, index, run);
|
|
238
|
+
if (!isImageIndex(manifest)) return undefined;
|
|
239
|
+
// (2) signed by the index workflow on main
|
|
240
|
+
if (!hasSignature(`${releaseRef}@${index}`, workflowIdentityPattern(repository, [indexWorkflow], 'main'), label, run)) return undefined;
|
|
241
|
+
// (3) exactly one linux/amd64 child
|
|
242
|
+
const amd64 = (Array.isArray(manifest.manifests) ? manifest.manifests : [])
|
|
243
|
+
.filter((child) => child?.platform?.os === 'linux' && child.platform.architecture === 'amd64');
|
|
244
|
+
if (amd64.length === 0) return undefined;
|
|
245
|
+
if (amd64.length > 1) {
|
|
246
|
+
throw new Error(`${label}: ambiguous: index ${releaseRef}@${index} has ${amd64.length} linux/amd64 children (${amd64.map((child) => child.digest).join(', ')})`);
|
|
247
|
+
}
|
|
248
|
+
const digest = amd64[0].digest;
|
|
249
|
+
if (typeof digest !== 'string' || !DIGEST_PATTERN.test(digest) || digest === index) {
|
|
250
|
+
throw new Error(`${label}: index ${releaseRef}@${index} names no sha256 linux/amd64 child digest: ${digest}`);
|
|
251
|
+
}
|
|
252
|
+
// (4) the existing promotion verification of A, select-by-promotedCommit included
|
|
253
|
+
// ... restricted to a statement promoting this very `commit` (a rerun): a later commit with the same content must
|
|
254
|
+
// take the existing path and attest its own promotion, never reuse one that carries none
|
|
255
|
+
const earlier = reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows, commit, exactCommit: true }, run);
|
|
256
|
+
if (earlier === undefined) return undefined;
|
|
257
|
+
return { digest, index, ...earlier };
|
|
258
|
+
}
|
|
259
|
+
|
|
204
260
|
/**
|
|
205
261
|
* Whether `<devRef>@<digest>` carries any SLSA v1 provenance attestation, listed without verifying a signature. A digest
|
|
206
262
|
* with none (an image reused on develop from before provenance) is not promotable but not an error; a registry error is.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// behind a rebuild.
|
|
10
10
|
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
|
|
13
14
|
export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
14
15
|
|
|
@@ -61,3 +62,28 @@ export function copyByDigest(source, target, run = runTool) {
|
|
|
61
62
|
throw new Error(`copying ${source} to ${target} failed: ${describeFailure(result)}`);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
|
|
66
|
+
const INDEX_MEDIA_TYPES = Object.freeze(['application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json']);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads the manifest `<repository>@<digest>` BY DIGEST (`imagetools inspect --raw`) and returns it parsed, refusing
|
|
70
|
+
* unless the bytes hash to `digest` (one trailing newline the printer may add is not part of the manifest).
|
|
71
|
+
*/
|
|
72
|
+
export function readManifestByDigest(repository, digest, run = runTool) {
|
|
73
|
+
const reference = `${repository}@${digest}`;
|
|
74
|
+
const result = run('docker', ['buildx', 'imagetools', 'inspect', reference, '--raw']);
|
|
75
|
+
if (result.error || result.status !== 0) throw new Error(`reading the manifest of ${reference} failed: ${describeFailure(result)}`);
|
|
76
|
+
const raw = String(result.stdout ?? '');
|
|
77
|
+
const hashes = (text) => `sha256:${createHash('sha256').update(text, 'utf8').digest('hex')}` === digest;
|
|
78
|
+
if (!hashes(raw) && !(raw.endsWith('\n') && hashes(raw.slice(0, -1)))) {
|
|
79
|
+
throw new Error(`the manifest read for ${reference} does not hash to ${digest}`);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(raw);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new Error(`the manifest of ${reference} is not JSON`, { cause: error });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether a parsed manifest is an image index (OCI index or Docker manifest list). */
|
|
89
|
+
export const isImageIndex = (manifest) => INDEX_MEDIA_TYPES.includes(manifest?.mediaType);
|
package/src/promote-image.mjs
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
// `--release-workflow` identities; the statement of the nearest promotedCommit that is an ancestor of
|
|
8
8
|
// (or equal to) `--commit` is the evidence (as xema-deploy's deploy verifier selects), and its source must be a
|
|
9
9
|
// `ghcr.io/<owner>/dev/<name>` reference -> reused (evidence=prior-promotion)
|
|
10
|
-
// - no image-promotion/v1 attestation (a legacy image) -> miss (reason=reuse-no-promotion): the caller builds
|
|
10
|
+
// - no image-promotion/v1 attestation (a legacy image) -> miss (reason=reuse-no-promotion): the caller builds,
|
|
11
|
+
// unless `--release-index-workflow` is given and the tag names release-arm64's index over main's amd64 digest A:
|
|
12
|
+
// the index is signed by that workflow on main, has exactly one linux/amd64 child A, and A passes this very
|
|
13
|
+
// reuse check with a statement promoting `--commit` itself (a rerun)
|
|
14
|
+
// -> reused (evidence=release-index, digest=A, index_digest=D,
|
|
15
|
+
// no predicate: the caller neither signs nor attests, tags :<sha>/main-<sha> to A and only the content tag (and
|
|
16
|
+
// latest) to D); any failed step keeps the miss
|
|
11
17
|
// - a signature / identity failure, or a source outside dev/ -> error, never a miss
|
|
12
18
|
// 2. else `<dev-ref>:<tag>` exists: prove it (legs a, b, c in
|
|
13
19
|
// promote-image-provenance.mjs), copy it by digest (`imagetools create`
|
|
@@ -17,7 +23,7 @@
|
|
|
17
23
|
// - provenance present but any leg fails -> error, never a miss
|
|
18
24
|
// 3. else -> miss: the caller builds.
|
|
19
25
|
//
|
|
20
|
-
// On every non-miss result it writes the promotion predicate
|
|
26
|
+
// On every non-miss result except `evidence=release-index` it writes the promotion predicate
|
|
21
27
|
// (`https://xema.dev/attestations/image-promotion/v1`) to `--promotion-predicate-out`, for the caller to
|
|
22
28
|
// `cosign attest` under main's own identity. A copy states the facts it just proved; a reuse states this run's
|
|
23
29
|
// release and commit and carries `source` and `developProvenance` from main's earlier promotion. It signs
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
// No retries. Every refusal is an error, never a miss.
|
|
34
40
|
//
|
|
35
41
|
// Outputs: `result`, `digest`, `source`, `revision`, `evidence`,
|
|
36
|
-
// `provenance_digest`, `promotion_predicate` and `
|
|
42
|
+
// `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` (and `index_digest` on evidence=release-index) appended to `$GITHUB_OUTPUT`
|
|
37
43
|
// (required; empty values where a fact does not apply), and one stdout line
|
|
38
44
|
// `promote-image result=<r> digest=<d> src=<repository>@<d> revision=<sha> evidence=<e>`, with ` reason=<why>` appended
|
|
39
45
|
// to a miss that has a reason.
|
|
@@ -43,13 +49,14 @@ import process from 'node:process';
|
|
|
43
49
|
import { describeErrorChain } from '@xemahq/release-policy/registry';
|
|
44
50
|
import { invokedDirectly } from './readiness.mjs';
|
|
45
51
|
import { IMAGE_PROMOTION_PREDICATE_TYPE, promotionPredicate, reusedPromotionPredicate, writePromotionPredicate } from './promote-image-predicate.mjs';
|
|
46
|
-
import { hasDevelopProvenance, reuseEarlierPromotion, verifyDevelopProvenance } from './promote-image-provenance.mjs';
|
|
52
|
+
import { hasDevelopProvenance, reuseEarlierPromotion, reuseThroughReleaseIndex, verifyDevelopProvenance } from './promote-image-provenance.mjs';
|
|
47
53
|
import { copyByDigest, probeDigest, runTool } from './promote-image-registry.mjs';
|
|
48
54
|
|
|
49
55
|
export const PromoteResult = Object.freeze({ REUSED: 'reused', COPIED: 'copied', MISS: 'miss' });
|
|
50
56
|
export const PromoteEvidence = Object.freeze({
|
|
51
57
|
PRIOR_PROMOTION: 'prior-promotion',
|
|
52
58
|
DEVELOP_PROVENANCE: 'develop-provenance',
|
|
59
|
+
RELEASE_INDEX: 'release-index',
|
|
53
60
|
NONE: 'none',
|
|
54
61
|
});
|
|
55
62
|
export const PromoteMissReason = Object.freeze({ NO_PROMOTION: 'reuse-no-promotion', NO_DEVELOP_PROVENANCE: 'copy-no-develop-provenance' });
|
|
@@ -78,11 +85,13 @@ export function assertCosignMajor(run = runTool) {
|
|
|
78
85
|
const USAGE =
|
|
79
86
|
'usage: xema-promote-image --service <name> --dev-ref <registry/repository> --release-ref <registry/repository> ' +
|
|
80
87
|
'--tag <content tag> --commit <40-hex> --main-ref <git ref> --signer-repository <owner/repo> ' +
|
|
81
|
-
'--develop-workflow <file.yaml> --release-workflow <file.yaml>... --
|
|
88
|
+
'--develop-workflow <file.yaml> --release-workflow <file.yaml>... [--release-index-workflow <file.yaml>] ' +
|
|
89
|
+
'--promotion-predicate-out <path> -- <tag command>...';
|
|
82
90
|
|
|
83
91
|
const SINGLE = Object.freeze(['service', 'dev-ref', 'release-ref', 'tag', 'commit', 'main-ref', 'signer-repository',
|
|
84
92
|
'develop-workflow', 'promotion-predicate-out']);
|
|
85
93
|
const REPEATABLE = Object.freeze(['release-workflow']);
|
|
94
|
+
const OPTIONAL = Object.freeze(['release-index-workflow']);
|
|
86
95
|
// A repository reference with no tag and no digest (those are passed separately).
|
|
87
96
|
const REPOSITORY_REF = /^[a-z0-9][a-z0-9.-]*(:[0-9]+)?(\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)+$/;
|
|
88
97
|
// The OCI distribution tag grammar.
|
|
@@ -104,7 +113,7 @@ export function parsePromoteArguments(argv) {
|
|
|
104
113
|
const arg = flags[i];
|
|
105
114
|
const name = arg.startsWith('--') ? arg.slice(2) : undefined;
|
|
106
115
|
const repeatable = REPEATABLE.includes(name);
|
|
107
|
-
if (name === undefined || (!SINGLE.includes(name) && !repeatable)) throw new Error(`unknown argument ${arg}\n${USAGE}`);
|
|
116
|
+
if (name === undefined || (!SINGLE.includes(name) && !OPTIONAL.includes(name) && !repeatable)) throw new Error(`unknown argument ${arg}\n${USAGE}`);
|
|
108
117
|
if (!repeatable && options[name] !== undefined) throw new Error(`${arg} given twice`);
|
|
109
118
|
const value = flags[i + 1];
|
|
110
119
|
if (value === undefined || value.startsWith('--') || value === '') throw new Error(`${arg} needs a value`);
|
|
@@ -123,7 +132,8 @@ export function parsePromoteArguments(argv) {
|
|
|
123
132
|
if (!COMMIT.test(options.commit)) refuse('commit', options.commit, 'a 40-hex commit');
|
|
124
133
|
if (options['main-ref'].startsWith('-')) refuse('main-ref', options['main-ref'], 'a git ref');
|
|
125
134
|
if (!GITHUB_REPOSITORY.test(options['signer-repository'])) refuse('signer-repository', options['signer-repository'], 'an owner/repo');
|
|
126
|
-
|
|
135
|
+
const indexWorkflow = options['release-index-workflow'];
|
|
136
|
+
for (const file of [options['develop-workflow'], ...options['release-workflow'], ...(indexWorkflow === undefined ? [] : [indexWorkflow])]) {
|
|
127
137
|
if (!WORKFLOW_FILE.test(file)) throw new Error(`workflow '${file}' is not a workflow file name under .github/workflows`);
|
|
128
138
|
}
|
|
129
139
|
return {
|
|
@@ -136,6 +146,7 @@ export function parsePromoteArguments(argv) {
|
|
|
136
146
|
repository: options['signer-repository'],
|
|
137
147
|
developWorkflow: options['develop-workflow'],
|
|
138
148
|
releaseWorkflows: options['release-workflow'],
|
|
149
|
+
indexWorkflow,
|
|
139
150
|
predicateOut: options['promotion-predicate-out'],
|
|
140
151
|
tagCommand,
|
|
141
152
|
};
|
|
@@ -145,16 +156,31 @@ const MISS = Object.freeze({ result: PromoteResult.MISS, digest: '', source: '',
|
|
|
145
156
|
|
|
146
157
|
/**
|
|
147
158
|
* Runs the steps. Returns `{ result, digest, source, evidence, predicate?, revision?, provenanceDigest?, reason? }`;
|
|
148
|
-
* `predicate` is set on every non-miss result
|
|
159
|
+
* `predicate` is set on every non-miss result except `evidence=release-index`, `reason` on a miss that has one.
|
|
149
160
|
*/
|
|
150
161
|
export function promoteImage(options, run = runTool) {
|
|
151
|
-
const { service, devRef, releaseRef, tag, commit, repository, releaseWorkflows } = options;
|
|
162
|
+
const { service, devRef, releaseRef, tag, commit, repository, releaseWorkflows, indexWorkflow } = options;
|
|
152
163
|
assertCosignMajor(run);
|
|
153
164
|
const releaseTagged = `${releaseRef}:${tag}`;
|
|
154
165
|
const released = probeDigest(releaseTagged, run);
|
|
155
166
|
if (released !== undefined) {
|
|
156
167
|
const earlier = reuseEarlierPromotion({ releaseRef, tag, digest: released, repository, releaseWorkflows, commit }, run);
|
|
157
|
-
if (earlier === undefined)
|
|
168
|
+
if (earlier === undefined) {
|
|
169
|
+
const viaIndex = indexWorkflow === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: reuseThroughReleaseIndex({ releaseRef, tag, releaseDigest: released, repository, releaseWorkflows, indexWorkflow, commit }, run);
|
|
172
|
+
if (viaIndex === undefined) return { ...MISS, reason: PromoteMissReason.NO_PROMOTION };
|
|
173
|
+
// release-arm64 already merged A into the index the tag names: nothing to sign or attest, no predicate.
|
|
174
|
+
return {
|
|
175
|
+
result: PromoteResult.REUSED,
|
|
176
|
+
digest: viaIndex.digest,
|
|
177
|
+
source: viaIndex.source.reference,
|
|
178
|
+
evidence: PromoteEvidence.RELEASE_INDEX,
|
|
179
|
+
revision: viaIndex.source.revision,
|
|
180
|
+
provenanceDigest: viaIndex.developProvenance.payloadDigest,
|
|
181
|
+
indexDigest: viaIndex.index,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
158
184
|
return {
|
|
159
185
|
result: PromoteResult.REUSED,
|
|
160
186
|
digest: released,
|
|
@@ -196,7 +222,7 @@ export function main(argv, { githubOutput, run = runTool, write = (text) => proc
|
|
|
196
222
|
if (existsSync(options.predicateOut)) {
|
|
197
223
|
throw new Error(`--promotion-predicate-out ${options.predicateOut} already exists; refusing to leave a stale predicate to attest`);
|
|
198
224
|
}
|
|
199
|
-
const { result, digest, source, evidence, predicate, revision, provenanceDigest, reason } = promoteImage(options, run);
|
|
225
|
+
const { result, digest, source, evidence, predicate, revision, provenanceDigest, reason, indexDigest } = promoteImage(options, run);
|
|
200
226
|
if (predicate !== undefined) writePromotionPredicate(options.predicateOut, predicate);
|
|
201
227
|
const outputs = {
|
|
202
228
|
result,
|
|
@@ -207,6 +233,7 @@ export function main(argv, { githubOutput, run = runTool, write = (text) => proc
|
|
|
207
233
|
provenance_digest: provenanceDigest ?? '',
|
|
208
234
|
promotion_predicate: predicate === undefined ? '' : options.predicateOut,
|
|
209
235
|
promotion_predicate_type: predicate === undefined ? '' : IMAGE_PROMOTION_PREDICATE_TYPE,
|
|
236
|
+
...(indexDigest === undefined ? {} : { index_digest: indexDigest }),
|
|
210
237
|
};
|
|
211
238
|
appendFileSync(githubOutput, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''));
|
|
212
239
|
write(`promote-image result=${result} digest=${digest} src=${source} revision=${outputs.revision} evidence=${evidence}${reason === undefined ? '' : ` reason=${reason}`}\n`);
|
|
@@ -22,6 +22,7 @@ const PROMOTION_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
|
|
|
22
22
|
// A fake `docker`, `cosign` and `git` on PATH, and a fake tag command, all
|
|
23
23
|
// answering from one JSON state file:
|
|
24
24
|
// tags / errors / copyDigest `docker buildx imagetools inspect|create`
|
|
25
|
+
// raw[repository@digest] the manifest bytes `docker buildx imagetools inspect --raw` prints
|
|
25
26
|
// signatures[subject] the identity `cosign verify` finds
|
|
26
27
|
// downloadErrors[subject] what `cosign download attestation` fails with
|
|
27
28
|
// attestations[subject] { identity, statements, type? } (type defaults to slsaprovenance1) `cosign verify-attestation` finds
|
|
@@ -45,6 +46,11 @@ if (tool === 'docker') {
|
|
|
45
46
|
if (args[2] === 'inspect') {
|
|
46
47
|
const ref = args[3];
|
|
47
48
|
if (state.errors[ref]) fail(state.errors[ref]);
|
|
49
|
+
if (args[4] === '--raw') {
|
|
50
|
+
if (state.raw?.[ref] === undefined) fail('ERROR: ' + ref + ': not found\\n');
|
|
51
|
+
process.stdout.write(state.raw[ref]);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
48
54
|
if (!state.tags[ref]) fail('ERROR: ' + ref + ': not found\\n');
|
|
49
55
|
process.stdout.write(JSON.stringify({ mediaType: 'application/vnd.oci.image.index.v1+json', digest: state.tags[ref] }));
|
|
50
56
|
process.exit(0);
|
|
@@ -203,10 +209,10 @@ function promotionStatement({ subjectDigest = OTHER, predicateType = PROMOTION_T
|
|
|
203
209
|
|
|
204
210
|
// A release digest main already promoted: its image-promotion/v1 attestation(s), signed by a main workflow.
|
|
205
211
|
// `lineage` defaults to EARLIER_COMMIT being an ancestor of COMMIT.
|
|
206
|
-
function promotedRelease({ source, identity = MAIN_IDENTITY, type = PROMOTION_TYPE, extra, statements, lineage = [[EARLIER_COMMIT, COMMIT]] } = {}) {
|
|
212
|
+
function promotedRelease({ source, identity = MAIN_IDENTITY, type = PROMOTION_TYPE, extra, statements, lineage = [[EARLIER_COMMIT, COMMIT]], promotedCommit } = {}) {
|
|
207
213
|
return {
|
|
208
214
|
tags: { [`${RELEASE}:${TAG}`]: OTHER },
|
|
209
|
-
attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: statements ?? [promotionStatement({ source, extra })] } },
|
|
215
|
+
attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: statements ?? [promotionStatement({ source, extra, promotedCommit })] } },
|
|
210
216
|
lineage,
|
|
211
217
|
};
|
|
212
218
|
}
|
|
@@ -610,3 +616,150 @@ test('an unparsable cosign version is refused', () => {
|
|
|
610
616
|
const r = refused({ ...provenDevelop(), cosignVersion: 'GitVersion: devel\n' }, /no parsable GitVersion/);
|
|
611
617
|
assert.deepEqual(r.state.calls, [['cosign', 'version']]);
|
|
612
618
|
});
|
|
619
|
+
|
|
620
|
+
// ---- reuse through release-arm64's index: `:<tag>` names index D over main's amd64 digest A (= OTHER) ----
|
|
621
|
+
const ARM64_IDENTITY = 'https://github.com/acme/svc/.github/workflows/release-arm64.yaml@refs/heads/main';
|
|
622
|
+
const ARM64_CHILD = `sha256:${'a'.repeat(64)}`;
|
|
623
|
+
const child = (digest, architecture) => ({ mediaType: 'application/vnd.oci.image.manifest.v1+json', digest, size: 1, platform: { os: 'linux', architecture } });
|
|
624
|
+
const ATTESTATION_CHILD = {
|
|
625
|
+
mediaType: 'application/vnd.oci.image.manifest.v1+json', digest: `sha256:${'b'.repeat(64)}`, size: 1,
|
|
626
|
+
platform: { os: 'unknown', architecture: 'unknown' }, annotations: { 'vnd.docker.reference.type': 'attestation-manifest' },
|
|
627
|
+
};
|
|
628
|
+
const indexBytes = (manifests, mediaType = 'application/vnd.oci.image.index.v1+json') => JSON.stringify({ schemaVersion: 2, mediaType, manifests });
|
|
629
|
+
|
|
630
|
+
// `children` / `mediaType` shape D; `signer` signs D; `amd64` is A's state (promoted by main by default).
|
|
631
|
+
function releasedIndex({ children = [child(OTHER, 'amd64'), child(ARM64_CHILD, 'arm64'), ATTESTATION_CHILD], mediaType, signer = ARM64_IDENTITY, amd64 = promotedRelease({ promotedCommit: COMMIT }), raw } = {}) {
|
|
632
|
+
const bytes = indexBytes(children, mediaType);
|
|
633
|
+
const index = sha(bytes);
|
|
634
|
+
return {
|
|
635
|
+
index,
|
|
636
|
+
state: {
|
|
637
|
+
...amd64,
|
|
638
|
+
tags: { [`${RELEASE}:${TAG}`]: index },
|
|
639
|
+
raw: { [`${RELEASE}@${index}`]: raw ?? `${bytes}\n` },
|
|
640
|
+
signatures: signer === null ? {} : { [`${RELEASE}@${index}`]: signer },
|
|
641
|
+
attestations: { ...amd64.attestations, [`${RELEASE}@${index}`]: { identity: ARM64_IDENTITY, statements: [provenance(index)] } },
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function runIndexed(state) {
|
|
647
|
+
const { run, flags, cleanup } = scenario(state);
|
|
648
|
+
const at = flags.indexOf('--promotion-predicate-out');
|
|
649
|
+
try {
|
|
650
|
+
// spread now: it reads the predicate file before cleanup removes it
|
|
651
|
+
return { ...run([...flags.slice(0, at), '--release-index-workflow', 'release-arm64.yaml', ...flags.slice(at)]) };
|
|
652
|
+
} finally {
|
|
653
|
+
cleanup();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const MISS_LINE = 'promote-image result=miss digest= src= revision= evidence=none reason=reuse-no-promotion\n';
|
|
658
|
+
const assertIndexMiss = (r) => {
|
|
659
|
+
assert.equal(r.status, 0, r.stderr);
|
|
660
|
+
assert.equal(r.stdout, MISS_LINE);
|
|
661
|
+
assert.equal(r.outputs.result, 'miss');
|
|
662
|
+
assert.equal(r.predicate, undefined);
|
|
663
|
+
assert.deepEqual(creates(r.state), []);
|
|
664
|
+
};
|
|
665
|
+
const assertIndexRefused = (r, pattern) => {
|
|
666
|
+
assert.equal(r.status, 1, r.stdout);
|
|
667
|
+
assert.match(r.stderr, pattern);
|
|
668
|
+
assert.deepEqual(r.outputs, {});
|
|
669
|
+
assert.equal(r.predicate, undefined);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
test('release index: the tag names release-arm64\'s index over main\'s promoted amd64 digest: reused at A, no predicate, nothing copied', () => {
|
|
673
|
+
const { index, state } = releasedIndex();
|
|
674
|
+
const r = runIndexed(state);
|
|
675
|
+
assert.equal(r.status, 0, r.stderr);
|
|
676
|
+
assert.equal(r.stdout, `promote-image result=reused digest=${OTHER} src=${DEV}@${OTHER} revision=${REVISION} evidence=release-index\n`);
|
|
677
|
+
assert.deepEqual(r.outputs, {
|
|
678
|
+
result: 'reused', digest: OTHER, source: `${DEV}@${OTHER}`, revision: REVISION, evidence: 'release-index',
|
|
679
|
+
provenance_digest: EARLIER_PROVENANCE.payloadDigest, promotion_predicate: '', promotion_predicate_type: '', index_digest: index,
|
|
680
|
+
});
|
|
681
|
+
assert.equal(r.predicate, undefined);
|
|
682
|
+
assert.deepEqual(creates(r.state), []);
|
|
683
|
+
assert.ok(r.state.calls.some((call) => call[0] === 'docker' && call[4] === `${RELEASE}@${index}` && call[5] === '--raw'));
|
|
684
|
+
assert.ok(r.state.calls.some((call) => call[0] === 'cosign' && call[1] === 'verify' && call.at(-1) === `${RELEASE}@${index}`));
|
|
685
|
+
assert.equal(r.state.calls.some((call) => call[0] === 'docker' && call[3] === `${DEV}:${TAG}`), false);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('release index: without --release-index-workflow the index stays the existing miss', () => {
|
|
689
|
+
const { run, cleanup } = scenario(releasedIndex().state);
|
|
690
|
+
try {
|
|
691
|
+
const r = run();
|
|
692
|
+
assertIndexMiss(r);
|
|
693
|
+
assert.equal(r.state.calls.some((call) => call[5] === '--raw'), false);
|
|
694
|
+
} finally {
|
|
695
|
+
cleanup();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('release index check 1: a tag naming a plain image without a promotion stays the existing miss', () => {
|
|
700
|
+
const bytes = JSON.stringify({ schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', layers: [] });
|
|
701
|
+
const plain = sha(bytes);
|
|
702
|
+
assertIndexMiss(runIndexed({
|
|
703
|
+
tags: { [`${RELEASE}:${TAG}`]: plain }, raw: { [`${RELEASE}@${plain}`]: bytes },
|
|
704
|
+
attestations: { [`${RELEASE}@${plain}`]: { identity: MAIN_IDENTITY, statements: [provenance(plain)] } },
|
|
705
|
+
}));
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('release index check 1: manifest bytes that do not hash to the tag digest are refused', () => {
|
|
709
|
+
const { state } = releasedIndex({ raw: indexBytes([child(DIGEST, 'amd64')]) });
|
|
710
|
+
assertIndexRefused(runIndexed(state), /does not hash to sha256:/);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test('release index: a tag naming a plain promoted image keeps the existing reuse unchanged (no index read)', () => {
|
|
714
|
+
const r = runIndexed(promotedRelease());
|
|
715
|
+
assert.equal(r.status, 0, r.stderr);
|
|
716
|
+
assert.equal(r.outputs.evidence, 'prior-promotion');
|
|
717
|
+
assert.equal(r.outputs.digest, OTHER);
|
|
718
|
+
assert.equal(r.predicate.promotedCommit, COMMIT);
|
|
719
|
+
assert.equal(r.state.calls.some((call) => call[5] === '--raw'), false);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('release index check 2: an index signed by another main workflow is the existing miss', () => {
|
|
723
|
+
assertIndexMiss(runIndexed(releasedIndex({ signer: MAIN_IDENTITY }).state));
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('release index check 2: an unsigned index is the existing miss', () => {
|
|
727
|
+
assertIndexMiss(runIndexed(releasedIndex({ signer: null }).state));
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test('release index check 2: an index signed by release-arm64 off main is the existing miss', () => {
|
|
731
|
+
assertIndexMiss(runIndexed(releasedIndex({ signer: ARM64_IDENTITY.replace('refs/heads/main', 'refs/heads/develop') }).state));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('release index check 3: an index with no linux/amd64 child is the existing miss', () => {
|
|
735
|
+
assertIndexMiss(runIndexed(releasedIndex({ children: [child(ARM64_CHILD, 'arm64'), ATTESTATION_CHILD] }).state));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('release index check 3: an index with two linux/amd64 children is refused as ambiguous', () => {
|
|
739
|
+
const { state } = releasedIndex({ children: [child(OTHER, 'amd64'), child(DIGEST, 'amd64'), child(ARM64_CHILD, 'arm64')] });
|
|
740
|
+
assertIndexRefused(runIndexed(state), /reuse via release index: ambiguous: .* has 2 linux\/amd64 children/);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('release index check 4: an amd64 child with no promotion attestation is the existing miss', () => {
|
|
744
|
+
const amd64 = { attestations: { [`${RELEASE}@${OTHER}`]: { identity: MAIN_IDENTITY, statements: [provenance(OTHER)] } } };
|
|
745
|
+
assertIndexMiss(runIndexed(releasedIndex({ amd64 }).state));
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test('release index check 4: an amd64 promotion stating another tag is refused', () => {
|
|
749
|
+
const amd64 = promotedRelease({ promotedCommit: COMMIT, extra: { release: { repository: RELEASE, tag: 'a'.repeat(64), digest: OTHER } } });
|
|
750
|
+
assertIndexRefused(runIndexed(releasedIndex({ amd64 }).state), /states release tag a+, not c+/);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('release index check 4: an amd64 promotion signed off main is refused', () => {
|
|
754
|
+
assertIndexRefused(runIndexed(releasedIndex({ amd64: promotedRelease({ promotedCommit: COMMIT, identity: DEVELOP_IDENTITY }) }).state), /reuse: .* has no image-promotion\/v1 attestation signed by/);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('release index check 4: an amd64 promotion of no ancestor commit is refused', () => {
|
|
758
|
+
assertIndexRefused(runIndexed(releasedIndex({ amd64: promotedRelease({ lineage: [] }) }).state), /reuse: no image-promotion\/v1 statement on .* promotes/);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test('release index check 4: an amd64 promotion of an ancestor commit (a later commit, same content) falls through to the existing miss', () => {
|
|
762
|
+
const r = runIndexed(releasedIndex({ amd64: promotedRelease() }).state);
|
|
763
|
+
assertIndexMiss(r);
|
|
764
|
+
assert.equal(r.outputs.index_digest, undefined);
|
|
765
|
+
});
|