@xemahq/repo-build-tooling 0.19.0 → 0.21.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 +11 -1
- package/package.json +5 -3
- package/src/buildx-builder-limits.mjs +46 -0
- package/src/buildx-builder.mjs +153 -0
- package/src/buildx-builder.test.mjs +151 -0
- package/src/composed-image-coordinate.mjs +8 -4
- package/src/composed-image-coordinate.test.mjs +24 -0
- package/src/composed-image.mjs +7 -4
- package/src/prepush-sanity.mjs +6 -6
- package/src/prepush-sanity.test.mjs +7 -7
- package/src/promote-image-provenance.mjs +76 -14
- package/src/promote-image.mjs +3 -2
- package/src/promote-image.test.mjs +152 -11
package/README.md
CHANGED
|
@@ -32,14 +32,24 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
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
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
|
+
| `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). Pinned to the runner's cgroup cpuset; recreated once when the image, caps or config change. Prints `buildx-boot path= secs= builder=`. |
|
|
35
36
|
| `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 (
|
|
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 |
|
|
37
38
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
38
39
|
|
|
39
40
|
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. `pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)` runs on the CI runner after `pnpm install --frozen-lockfile` and builds the offline `xema-pnpm` build context (`staged.json`, `corepack/pnpm.tgz`, and, with `--image-dir` only, a warmed `cache/` of registry metadata; `--subject` writes no `cache/` and `staged.json` says `cache: false`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` on stdout (everything else on stderr) and refuses a non-empty `<dir>`. `dockerfile --prebuilt` renders an OFFLINE image: every install RUN is `--network=none` and reads the runner's pnpm store and the `pnpm-context` output as the named build contexts `xema-pnpm-store` and `xema-pnpm`; without them the build fails with a message (no network fallback). `pnpm rebuild` runs only for `onlyBuiltDependencies` in the members' prod closure (less prisma, @prisma/client, @prisma/engines, @nestjs/core, esbuild, @swc/core) and the render needs `pnpm install` first; a node-gyp module with no optional prebuilt packages is refused. `pnpm-context --image-dir` reads the deps stage's COPY directories from the committed Dockerfile. Run `xema-composed-image` with no arguments for the full usage.
|
|
40
41
|
|
|
41
42
|
Every step refuses rather than skips. The package has no dependencies.
|
|
42
43
|
|
|
44
|
+
## Security model of `xema-buildx-builder`
|
|
45
|
+
|
|
46
|
+
The persistent builder and its cache mounts are for signed develop and main builds only.
|
|
47
|
+
|
|
48
|
+
- The verb refuses any `GITHUB_EVENT_NAME` other than `push` and `workflow_dispatch`, and any `GITHUB_REF` other than `refs/heads/main` and `refs/heads/develop`, before any docker call. A pull request never touches the persistent builder or its cache.
|
|
49
|
+
- Callers also gate the step on the opt-in org variable: the develop-images lane runs the verb only `if: vars.XEMA_BUILDKIT_MODE == 'persistent'` and uses the stock `setup-buildx-action` otherwise.
|
|
50
|
+
- The builder, its container and its state volume are named `xema-buildkit-signed-<runner>`; the `xema-buildkit-signed-*` prefix is reserved for signed lanes, so no other lane shares those cache mounts.
|
|
51
|
+
- Owner decision D97 (2026-10-14): pull request jobs run on the same runners. The in-process guards above are the accepted mitigation, because only we push to our repositories.
|
|
52
|
+
|
|
43
53
|
## Resolution
|
|
44
54
|
|
|
45
55
|
Inside the `xema-monorepo` aggregator, `@xemahq/repo-build-tooling` resolves to this directory through a workspace override. In an independent repository's CI it resolves from npm by the declared range, so a change here reaches a consumer only after it is published and the consumer's range admits it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
24
|
"xema-arm64-supplement-needed": "src/arm64-supplement-needed.mjs",
|
|
25
|
+
"xema-buildx-builder": "src/buildx-builder.mjs",
|
|
25
26
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
26
27
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
27
28
|
"xema-composed-image": "src/composed-image.mjs",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
},
|
|
34
35
|
"exports": {
|
|
35
36
|
"./arm64-supplement-needed": "./src/arm64-supplement-needed.mjs",
|
|
37
|
+
"./buildx-builder": "./src/buildx-builder.mjs",
|
|
36
38
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
37
39
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
38
40
|
"./composed-image": "./src/composed-image.mjs",
|
|
@@ -47,9 +49,9 @@
|
|
|
47
49
|
"@xemahq/release-policy": "^14.0.0"
|
|
48
50
|
},
|
|
49
51
|
"scripts": {
|
|
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"
|
|
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"
|
|
51
53
|
},
|
|
52
54
|
"xemaRelease": {
|
|
53
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:bc219d66a33857aaeb74bad77ae50b0f53228368249eb16803ecfd7ef6a8fe03"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The caps of the persistent buildkitd: parsing and the config derived from them. A persistent builder with
|
|
2
|
+
// no caps is unrepresentable, so every field is required and positive; nothing here loosens a cap.
|
|
3
|
+
|
|
4
|
+
const CPUS = /^[0-9]+(\.[0-9]+)?$/;
|
|
5
|
+
const MEMORY = /^([0-9]+)[bkmgBKMG]?$/;
|
|
6
|
+
const GC_SPACE = /^[0-9]+(\.[0-9]+)?[KMGT]?B$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse BUILDKIT_LIMITS, JSON `{"cpus":"<n>","memory":"<n>[bkmg]","maxParallelism":<n>,"gcMaxUsedSpace":"<n>GB"}`.
|
|
10
|
+
* @returns {{ cpus: string, memory: string, maxParallelism: number, gcMaxUsedSpace: string }}
|
|
11
|
+
*/
|
|
12
|
+
export function parseLimits(raw) {
|
|
13
|
+
if (raw === undefined || raw === '') {
|
|
14
|
+
throw new Error('BUILDKIT_LIMITS is empty or unset: a persistent builder must be capped (set the XEMA_BUILDKIT_LIMITS org variable)');
|
|
15
|
+
}
|
|
16
|
+
let value;
|
|
17
|
+
try {
|
|
18
|
+
value = JSON.parse(raw);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(`BUILDKIT_LIMITS is invalid (${error.message})`);
|
|
21
|
+
}
|
|
22
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('BUILDKIT_LIMITS is invalid (not a JSON object)');
|
|
23
|
+
const { cpus, memory, maxParallelism, gcMaxUsedSpace } = value;
|
|
24
|
+
if (typeof cpus !== 'string' || !CPUS.test(cpus) || Number(cpus) <= 0) throw new Error('BUILDKIT_LIMITS is invalid (cpus must be a positive number string)');
|
|
25
|
+
const size = typeof memory === 'string' ? MEMORY.exec(memory) : null;
|
|
26
|
+
if (size === null || Number(size[1]) <= 0) throw new Error('BUILDKIT_LIMITS is invalid (memory must be a positive size such as 2g)');
|
|
27
|
+
if (typeof gcMaxUsedSpace !== 'string' || !GC_SPACE.test(gcMaxUsedSpace)) throw new Error('BUILDKIT_LIMITS is invalid (gcMaxUsedSpace must be a size string such as 5GB)');
|
|
28
|
+
if (!Number.isInteger(maxParallelism) || maxParallelism <= 0) throw new Error('BUILDKIT_LIMITS is invalid (maxParallelism must be a positive integer)');
|
|
29
|
+
return { cpus, memory, maxParallelism, gcMaxUsedSpace };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildkitdToml(limits) {
|
|
33
|
+
return `[worker.oci]\n gc = true\n maxUsedSpace = "${limits.gcMaxUsedSpace}"\n max-parallelism = ${limits.maxParallelism}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The label value: a change of image, caps, cpuset or config recreates the container exactly once. */
|
|
37
|
+
export function configHash(image, limits, cpuset, toml, sha256) {
|
|
38
|
+
const caps = `cpus=${limits.cpus} memory=${limits.memory} memory-swap=${limits.memory} cpuset=${cpuset}`;
|
|
39
|
+
return sha256(`${image}\n${caps}\n${toml}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The builder key: `xema-buildkit-signed-*` is reserved for signed lanes (T133), so a PR build can never share its cache mounts. The lowercased runner name with anything outside [a-z0-9_.-] mapped to '-'. */
|
|
43
|
+
export function builderName(runnerName) {
|
|
44
|
+
if (runnerName === undefined || runnerName === '') throw new Error('RUNNER_NAME is empty or unset: the builder key is derived from it');
|
|
45
|
+
return `xema-buildkit-signed-${runnerName.toLowerCase().replace(/[^a-z0-9_.-]/g, '-')}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// INVOKER: a signed develop/main workflow job, once before its first build-push-action step:
|
|
3
|
+
// BUILDKIT_IMAGE=<pinned image> BUILDKIT_LIMITS=<json> pnpm exec xema-buildx-builder ensure
|
|
4
|
+
//
|
|
5
|
+
// Ensures the runner's persistent buildkitd container is up and is the current buildx builder.
|
|
6
|
+
// Key: the runner's registered name, RUNNER_NAME. The container is --restart unless-stopped with a named
|
|
7
|
+
// state volume; a label holds sha256(image + caps + config) so a change recreates it exactly once, keeping
|
|
8
|
+
// the volume. The sibling container escapes the runner's own cap, so it is bounded explicitly: the runner's
|
|
9
|
+
// own cpuset plus the caller's BUILDKIT_LIMITS. Readiness is ONE explicit deadline (not a retry).
|
|
10
|
+
//
|
|
11
|
+
// A pull_request build must never reach this: only push and workflow_dispatch on refs/heads/main or
|
|
12
|
+
// refs/heads/develop are admitted, refused BEFORE any docker call.
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { invokedDirectly } from './readiness.mjs';
|
|
20
|
+
import { buildkitdToml, builderName, configHash, parseLimits } from './buildx-builder-limits.mjs';
|
|
21
|
+
|
|
22
|
+
const ADMITTED_EVENTS = ['push', 'workflow_dispatch'];
|
|
23
|
+
const ADMITTED_REFS = ['refs/heads/main', 'refs/heads/develop'];
|
|
24
|
+
const LABEL = 'xema.buildkit.cfg';
|
|
25
|
+
const CHECK_INTERVAL_MS = 2000;
|
|
26
|
+
const DEFAULT_CPUSET_FILE = '/sys/fs/cgroup/cpuset.cpus.effective';
|
|
27
|
+
const DEFAULT_DEADLINE_SECONDS = 180;
|
|
28
|
+
|
|
29
|
+
export function refuseUnsignedLane(env) {
|
|
30
|
+
if (!ADMITTED_EVENTS.includes(env.GITHUB_EVENT_NAME ?? '')) {
|
|
31
|
+
throw new Error(`GITHUB_EVENT_NAME='${env.GITHUB_EVENT_NAME ?? ''}' may not use the persistent buildx builder: only push and workflow_dispatch builds may; a pull_request gets an ephemeral builder or no build`);
|
|
32
|
+
}
|
|
33
|
+
if (!ADMITTED_REFS.includes(env.GITHUB_REF ?? '')) {
|
|
34
|
+
throw new Error(`GITHUB_REF='${env.GITHUB_REF ?? ''}' may not use the persistent buildx builder: only refs/heads/main and refs/heads/develop may`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function docker(args, { allowFailure = false } = {}) {
|
|
39
|
+
const result = spawnSync('docker', args, { encoding: 'utf8' });
|
|
40
|
+
if (result.error) throw new Error(`docker ${args[0]}: ${result.error.message}`);
|
|
41
|
+
if (result.status !== 0 && !allowFailure) throw new Error(`docker ${args.join(' ')} failed (${result.status}): ${(result.stderr || result.stdout).trim()}`);
|
|
42
|
+
return { ok: result.status === 0, out: `${result.stdout}${result.stderr}`.trim(), stdout: result.stdout.trim() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
46
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
47
|
+
|
|
48
|
+
/** Unset is the default; anything else must be a positive integer, or the readiness loop could never end. */
|
|
49
|
+
export function parseDeadlineSeconds(raw) {
|
|
50
|
+
if (raw === undefined) return DEFAULT_DEADLINE_SECONDS;
|
|
51
|
+
if (!/^[1-9][0-9]*$/.test(raw)) throw new Error(`BUILDKIT_READY_DEADLINE_SECONDS='${raw}' must be a positive integer`);
|
|
52
|
+
return Number(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function ensure(env, log = console.log) {
|
|
56
|
+
refuseUnsignedLane(env);
|
|
57
|
+
const image = env.BUILDKIT_IMAGE;
|
|
58
|
+
if (image === undefined || image === '') throw new Error('BUILDKIT_IMAGE is required');
|
|
59
|
+
const limits = parseLimits(env.BUILDKIT_LIMITS);
|
|
60
|
+
const key = builderName(env.RUNNER_NAME);
|
|
61
|
+
const cpusetFile = env.BUILDKIT_CPUSET_FILE ?? DEFAULT_CPUSET_FILE;
|
|
62
|
+
let cpuset = '';
|
|
63
|
+
try {
|
|
64
|
+
cpuset = readFileSync(cpusetFile, 'utf8').trim();
|
|
65
|
+
} catch {
|
|
66
|
+
// reported below with the file name
|
|
67
|
+
}
|
|
68
|
+
if (cpuset === '') throw new Error(`${cpusetFile} is missing or empty: cannot bound the builder to the runner's cpuset (cgroup v2 required)`);
|
|
69
|
+
const deadlineSeconds = parseDeadlineSeconds(env.BUILDKIT_READY_DEADLINE_SECONDS);
|
|
70
|
+
const toml = buildkitdToml(limits);
|
|
71
|
+
const cfg = configHash(image, limits, cpuset, toml, sha256);
|
|
72
|
+
|
|
73
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-buildkitd-'));
|
|
74
|
+
try {
|
|
75
|
+
const configFile = join(scratch, 'buildkitd.toml');
|
|
76
|
+
writeFileSync(configFile, toml);
|
|
77
|
+
const start = Date.now();
|
|
78
|
+
const create = () => {
|
|
79
|
+
docker(['create', '--name', key, '--restart', 'unless-stopped', '--privileged',
|
|
80
|
+
'--cpus', limits.cpus, '--memory', limits.memory, '--memory-swap', limits.memory, '--cpuset-cpus', cpuset,
|
|
81
|
+
'-v', `${key}-state:/var/lib/buildkit`, '--label', `${LABEL}=${cfg}`,
|
|
82
|
+
image, '--config', '/etc/buildkitd.toml']);
|
|
83
|
+
docker(['cp', configFile, `${key}:/etc/buildkitd.toml`]);
|
|
84
|
+
docker(['start', key]);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let path;
|
|
88
|
+
if (docker(['ps', '-a', '--filter', `name=^/${key}$`, '--format', '{{.Names}}']).stdout === '') {
|
|
89
|
+
path = 'created';
|
|
90
|
+
create();
|
|
91
|
+
} else {
|
|
92
|
+
const have = docker(['inspect', '-f', `{{ index .Config.Labels "${LABEL}" }}`, key]).stdout;
|
|
93
|
+
const haveImage = docker(['inspect', '-f', '{{.Config.Image}}', key]).stdout;
|
|
94
|
+
if (have !== cfg || haveImage !== image) {
|
|
95
|
+
log(`::notice::buildkitd ${key} config or image changed (${have || 'none'} ${haveImage} -> ${cfg} ${image}): recreating once, state volume kept`);
|
|
96
|
+
docker(['rm', '-f', key]);
|
|
97
|
+
path = 'recreated';
|
|
98
|
+
create();
|
|
99
|
+
} else if (docker(['inspect', '-f', '{{.State.Running}}', key]).stdout === 'true') {
|
|
100
|
+
path = 'running';
|
|
101
|
+
} else {
|
|
102
|
+
path = 'started';
|
|
103
|
+
docker(['start', key]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
log(`buildx-boot ${key}: ${path}; caps cpus=${limits.cpus} memory=${limits.memory} cpuset=${cpuset} max-parallelism=${limits.maxParallelism}; waiting for workers (deadline ${deadlineSeconds}s)`);
|
|
107
|
+
|
|
108
|
+
let check = 0;
|
|
109
|
+
for (;;) {
|
|
110
|
+
const probe = docker(['exec', key, 'buildctl', 'debug', 'workers'], { allowFailure: true });
|
|
111
|
+
check += 1;
|
|
112
|
+
const waited = Math.floor((Date.now() - start) / 1000);
|
|
113
|
+
if (probe.ok) {
|
|
114
|
+
log(`check ${check} at ${waited}s: ready`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const last = probe.out.split('\n').pop();
|
|
118
|
+
log(`check ${check} at ${waited}s: not ready: ${last}`);
|
|
119
|
+
if (waited >= deadlineSeconds) {
|
|
120
|
+
const state = docker(['inspect', '-f', '{{.State.Status}}', key], { allowFailure: true }).out;
|
|
121
|
+
log(docker(['logs', '--tail', '50', key], { allowFailure: true }).out);
|
|
122
|
+
throw new Error(`runner ${key}: buildkitd (path=${path}) not ready after ${deadlineSeconds}s; state=${state}; last buildctl error: ${last}`);
|
|
123
|
+
}
|
|
124
|
+
await sleep(CHECK_INTERVAL_MS);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!docker(['buildx', 'inspect', key], { allowFailure: true }).ok) {
|
|
128
|
+
docker(['buildx', 'create', '--name', key, '--driver', 'remote', `docker-container://${key}`]);
|
|
129
|
+
}
|
|
130
|
+
docker(['buildx', 'use', key]);
|
|
131
|
+
docker(['buildx', 'inspect', '--bootstrap', key]);
|
|
132
|
+
|
|
133
|
+
const secs = Math.floor((Date.now() - start) / 1000);
|
|
134
|
+
const line = `buildx-boot path=${path} secs=${secs} builder=${key}`;
|
|
135
|
+
log(line);
|
|
136
|
+
return { path, secs, builder: key, line };
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main(argv, env) {
|
|
143
|
+
if (argv[0] !== 'ensure' || argv.length !== 1) throw new Error('usage: xema-buildx-builder ensure');
|
|
144
|
+
const { line } = await ensure(env);
|
|
145
|
+
if (env.GITHUB_STEP_SUMMARY) writeFileSync(env.GITHUB_STEP_SUMMARY, `### Docker Buildx\n${line}\n`, { flag: 'a' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
149
|
+
main(process.argv.slice(2), process.env).catch((error) => {
|
|
150
|
+
console.error(`::error::${error.message}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmodSync, mkdtempSync, readFileSync, 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
|
+
|
|
9
|
+
const BIN = fileURLToPath(new URL('./buildx-builder.mjs', import.meta.url));
|
|
10
|
+
const LIMITS = '{"cpus":"4","memory":"8g","maxParallelism":4,"gcMaxUsedSpace":"5GB"}';
|
|
11
|
+
|
|
12
|
+
// A fake docker on PATH: appends every call to calls.log; `ps` answers $FAKE_PS, `inspect -f ...Labels...` answers $FAKE_LABEL,
|
|
13
|
+
// `inspect ...Running...` answers $FAKE_RUNNING, `exec` fails $FAKE_EXEC_FAIL times then succeeds.
|
|
14
|
+
function sandbox() {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), 'buildx-builder-test-'));
|
|
16
|
+
const log = join(dir, 'calls.log');
|
|
17
|
+
const fake = join(dir, 'docker');
|
|
18
|
+
writeFileSync(fake, `#!/bin/sh
|
|
19
|
+
echo "$@" >> "${log}"
|
|
20
|
+
case "$1" in
|
|
21
|
+
ps) printf '%s' "$FAKE_PS" ;;
|
|
22
|
+
inspect) case "$*" in *Labels*) printf '%s' "$FAKE_LABEL" ;; *Config.Image*) printf '%s' "\${FAKE_IMAGE:-moby/buildkit:v0.1}" ;; *Running*) printf '%s' "$FAKE_RUNNING" ;; esac ;;
|
|
23
|
+
exec) if [ "\${FAKE_EXEC_FAIL:-0}" = 1 ]; then echo "no workers" >&2; exit 1; fi ;;
|
|
24
|
+
buildx) [ "$2" = inspect ] && [ "$3" != --bootstrap ] && exit 1; exit 0 ;;
|
|
25
|
+
esac
|
|
26
|
+
exit 0
|
|
27
|
+
`);
|
|
28
|
+
chmodSync(fake, 0o755);
|
|
29
|
+
const cpuset = join(dir, 'cpuset');
|
|
30
|
+
writeFileSync(cpuset, '10-27\n');
|
|
31
|
+
const run = (extra = {}) => {
|
|
32
|
+
const env = {
|
|
33
|
+
PATH: `${dir}:${process.env.PATH}`, RUNNER_NAME: 'Owl Runner/1', BUILDKIT_IMAGE: 'moby/buildkit:v0.1',
|
|
34
|
+
BUILDKIT_LIMITS: LIMITS, BUILDKIT_CPUSET_FILE: cpuset, GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/develop', ...extra,
|
|
35
|
+
};
|
|
36
|
+
for (const [k, v] of Object.entries(env)) if (v === undefined) delete env[k];
|
|
37
|
+
const r = spawnSync(process.execPath, [BIN, 'ensure'], { env, encoding: 'utf8' });
|
|
38
|
+
let calls = '';
|
|
39
|
+
try { calls = readFileSync(log, 'utf8'); } catch { /* no docker call */ }
|
|
40
|
+
return { ...r, calls };
|
|
41
|
+
};
|
|
42
|
+
return { dir, run, cpuset };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('a pull_request, an empty event and any non push/workflow_dispatch event is refused before any docker call', () => {
|
|
46
|
+
const s = sandbox();
|
|
47
|
+
try {
|
|
48
|
+
for (const GITHUB_EVENT_NAME of ['pull_request', 'pull_request_target', 'schedule', 'workflow_call', '']) {
|
|
49
|
+
const r = s.run({ GITHUB_EVENT_NAME });
|
|
50
|
+
assert.equal(r.status, 1, GITHUB_EVENT_NAME);
|
|
51
|
+
assert.match(r.stderr, /may not use the persistent buildx builder/);
|
|
52
|
+
assert.equal(r.calls, '');
|
|
53
|
+
}
|
|
54
|
+
assert.equal(s.run({ GITHUB_EVENT_NAME: undefined }).calls, '');
|
|
55
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a ref other than refs/heads/main and refs/heads/develop is refused before any docker call', () => {
|
|
59
|
+
const s = sandbox();
|
|
60
|
+
try {
|
|
61
|
+
for (const GITHUB_REF of ['refs/heads/feature/x', 'refs/pull/1/merge', 'refs/tags/v1', '']) {
|
|
62
|
+
const r = s.run({ GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF });
|
|
63
|
+
assert.equal(r.status, 1, GITHUB_REF);
|
|
64
|
+
assert.match(r.stderr, /GITHUB_REF/);
|
|
65
|
+
assert.equal(r.calls, '');
|
|
66
|
+
}
|
|
67
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('missing or invalid limits, runner name, image and cpuset fail before any docker call', () => {
|
|
71
|
+
const s = sandbox();
|
|
72
|
+
try {
|
|
73
|
+
const cases = [
|
|
74
|
+
[{ BUILDKIT_LIMITS: undefined }, /BUILDKIT_LIMITS is empty/],
|
|
75
|
+
[{ BUILDKIT_LIMITS: '{"cpus":"0","memory":"8g","maxParallelism":4,"gcMaxUsedSpace":"5GB"}' }, /cpus must be positive|cpus must be a positive/],
|
|
76
|
+
[{ BUILDKIT_LIMITS: '{"cpus":"4","memory":"8g","maxParallelism":0,"gcMaxUsedSpace":"5GB"}' }, /maxParallelism/],
|
|
77
|
+
[{ RUNNER_NAME: '' }, /RUNNER_NAME/],
|
|
78
|
+
[{ BUILDKIT_IMAGE: '' }, /BUILDKIT_IMAGE/],
|
|
79
|
+
[{ BUILDKIT_READY_DEADLINE_SECONDS: 'abc' }, /positive integer/],
|
|
80
|
+
[{ BUILDKIT_READY_DEADLINE_SECONDS: '0' }, /positive integer/],
|
|
81
|
+
[{ BUILDKIT_READY_DEADLINE_SECONDS: '-5' }, /positive integer/],
|
|
82
|
+
[{ BUILDKIT_READY_DEADLINE_SECONDS: '' }, /positive integer/],
|
|
83
|
+
[{ BUILDKIT_READY_DEADLINE_SECONDS: '1.5' }, /positive integer/],
|
|
84
|
+
[{ BUILDKIT_CPUSET_FILE: join(s.dir, 'absent') }, /cpuset/],
|
|
85
|
+
];
|
|
86
|
+
for (const [extra, pattern] of cases) {
|
|
87
|
+
const r = s.run(extra);
|
|
88
|
+
assert.equal(r.status, 1, JSON.stringify(extra));
|
|
89
|
+
assert.match(r.stderr, pattern);
|
|
90
|
+
assert.equal(r.calls, '');
|
|
91
|
+
}
|
|
92
|
+
writeFileSync(s.cpuset, '\n');
|
|
93
|
+
assert.match(s.run().stderr, /missing or empty/);
|
|
94
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('first run creates the capped container pinned to the runner cpuset, then prints the buildx-boot line', () => {
|
|
98
|
+
const s = sandbox();
|
|
99
|
+
try {
|
|
100
|
+
const r = s.run({ FAKE_PS: '' });
|
|
101
|
+
assert.equal(r.status, 0, r.stderr);
|
|
102
|
+
const create = r.calls.split('\n').find((l) => l.startsWith('create '));
|
|
103
|
+
assert.match(create, /--name xema-buildkit-signed-owl-runner-1 /);
|
|
104
|
+
assert.match(create, /--cpus 4 --memory 8g --memory-swap 8g --cpuset-cpus 10-27/);
|
|
105
|
+
assert.match(create, /--privileged/);
|
|
106
|
+
assert.match(create, /-v xema-buildkit-signed-owl-runner-1-state:\/var\/lib\/buildkit/);
|
|
107
|
+
assert.match(r.calls, /cp .*buildkitd\.toml xema-buildkit-signed-owl-runner-1:\/etc\/buildkitd\.toml/);
|
|
108
|
+
assert.match(r.calls, /buildx create --name xema-buildkit-signed-owl-runner-1 --driver remote/);
|
|
109
|
+
assert.match(r.calls, /buildx use xema-buildkit-signed-owl-runner-1/);
|
|
110
|
+
assert.match(r.stdout, /^buildx-boot path=created secs=\d+ builder=xema-buildkit-signed-owl-runner-1$/m);
|
|
111
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a changed hash recreates once, keeping the state volume; the same hash reuses', () => {
|
|
115
|
+
const s = sandbox();
|
|
116
|
+
try {
|
|
117
|
+
const first = s.run({ FAKE_PS: '' });
|
|
118
|
+
const label = /--label xema\.buildkit\.cfg=([0-9a-f]{64})/.exec(first.calls)[1];
|
|
119
|
+
rmSync(join(s.dir, 'calls.log'));
|
|
120
|
+
const recreated = s.run({ FAKE_PS: 'xema-buildkit-signed-owl-runner-1', FAKE_LABEL: 'stale' });
|
|
121
|
+
assert.match(recreated.calls, /rm -f xema-buildkit-signed-owl-runner-1/);
|
|
122
|
+
assert.doesNotMatch(recreated.calls, /volume rm/);
|
|
123
|
+
assert.match(recreated.stdout, /path=recreated/);
|
|
124
|
+
rmSync(join(s.dir, 'calls.log'));
|
|
125
|
+
const reused = s.run({ FAKE_PS: 'xema-buildkit-signed-owl-runner-1', FAKE_LABEL: label, FAKE_RUNNING: 'true' });
|
|
126
|
+
assert.doesNotMatch(reused.calls, /^(create|rm) /m);
|
|
127
|
+
assert.match(reused.stdout, /path=running/);
|
|
128
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a matching label but a different running image recreates once', () => {
|
|
132
|
+
const s = sandbox();
|
|
133
|
+
try {
|
|
134
|
+
const first = s.run({ FAKE_PS: '' });
|
|
135
|
+
const label = /--label xema\.buildkit\.cfg=([0-9a-f]{64})/.exec(first.calls)[1];
|
|
136
|
+
rmSync(join(s.dir, 'calls.log'));
|
|
137
|
+
const r = s.run({ FAKE_PS: 'xema-buildkit-signed-owl-runner-1', FAKE_LABEL: label, FAKE_RUNNING: 'true', FAKE_IMAGE: 'moby/buildkit:old' });
|
|
138
|
+
assert.match(r.calls, /rm -f xema-buildkit-signed-owl-runner-1/);
|
|
139
|
+
assert.match(r.stdout, /path=recreated/);
|
|
140
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a builder that never becomes ready fails at the deadline with the container logs', () => {
|
|
144
|
+
const s = sandbox();
|
|
145
|
+
try {
|
|
146
|
+
const r = s.run({ FAKE_PS: '', FAKE_EXEC_FAIL: '1', BUILDKIT_READY_DEADLINE_SECONDS: '1' });
|
|
147
|
+
assert.equal(r.status, 1);
|
|
148
|
+
assert.match(r.stderr, /not ready after 1s/);
|
|
149
|
+
assert.match(r.calls, /logs --tail 50/);
|
|
150
|
+
} finally { rmSync(s.dir, { recursive: true, force: true }); }
|
|
151
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `xema-composed-image coordinate --image-name <name
|
|
1
|
+
// `xema-composed-image coordinate --image-name <name> [--service <subjectId>]`: the release coordinate of a
|
|
2
2
|
// repository's composed-process image and how many cross-repository inputs it hashes.
|
|
3
3
|
//
|
|
4
4
|
// The tag is the ORDINARY build-input key, `xema-compute-image-tag` over the
|
|
@@ -77,8 +77,12 @@ export function resolveHashBin(root) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** `{ image, content_tag, cross_repo_pins, entry }` for the composed image of the repository at `root`. */
|
|
80
|
-
export function composedCoordinate({ root, imageName, imageDir, env = process.env, hash }) {
|
|
80
|
+
export function composedCoordinate({ root, imageName, imageDir, service, env = process.env, hash }) {
|
|
81
81
|
if (typeof imageName !== 'string' || imageName.length === 0) throw new Error('coordinate needs --image-name');
|
|
82
|
+
// The hash SERVICE is the subject id (`<source id>-composed-process`), which differs from the
|
|
83
|
+
// published image name when the name carries a namespace prefix; it defaults to the name.
|
|
84
|
+
const subjectId = service ?? imageName;
|
|
85
|
+
if (typeof subjectId !== 'string' || subjectId.length === 0) throw new Error('--service must not be empty');
|
|
82
86
|
const sha = required(env, 'SHA');
|
|
83
87
|
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error(`SHA '${sha}' is not an exact 40-hex commit`);
|
|
84
88
|
const registry = required(env, 'REGISTRY');
|
|
@@ -90,7 +94,7 @@ export function composedCoordinate({ root, imageName, imageDir, env = process.en
|
|
|
90
94
|
tagArgs = readFileSync(tagArgsFile, 'utf8').split('\n').filter(Boolean);
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
const hashArguments = ['--service',
|
|
97
|
+
const hashArguments = ['--service', subjectId, '--path', imageDir, '--commit', sha, '--repo-dir', root, ...tagArgs];
|
|
94
98
|
const contentHash = (
|
|
95
99
|
hash ??
|
|
96
100
|
((args) =>
|
|
@@ -108,7 +112,7 @@ export function composedCoordinate({ root, imageName, imageDir, env = process.en
|
|
|
108
112
|
// The same entry shape a service matrix entry has, so the arm64 supplement
|
|
109
113
|
// lane reads this image's receipt exactly as it reads a service's.
|
|
110
114
|
const entry = {
|
|
111
|
-
service:
|
|
115
|
+
service: subjectId,
|
|
112
116
|
path: imageDir,
|
|
113
117
|
workspace: false,
|
|
114
118
|
runtimeImage: false,
|
|
@@ -116,3 +116,27 @@ test('a read error other than absence is rethrown with its real message, not map
|
|
|
116
116
|
fs.mkdirSync(path.join(root, 'node_modules/@xemahq/distribution-source-hash/package.json'), { recursive: true });
|
|
117
117
|
assert.throws(() => resolveHashBin(root), (error) => error.code === 'EISDIR' && !/not installed/.test(error.message));
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test('--service sets the hash subject id and entry.service; the published image name stays the prefixed name', () => {
|
|
121
|
+
const root = consumer();
|
|
122
|
+
const out = composedCoordinate({
|
|
123
|
+
root,
|
|
124
|
+
imageName: 'presenca-biomes-composed-process-prefixed',
|
|
125
|
+
service: 'biomes-composed-process',
|
|
126
|
+
imageDir: 'infra/composed-process',
|
|
127
|
+
env: ENV,
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'args.json'), 'utf8')), [
|
|
130
|
+
'--service', 'biomes-composed-process', '--path', 'infra/composed-process', '--commit', SHA, '--repo-dir', root,
|
|
131
|
+
]);
|
|
132
|
+
assert.equal(out.image, 'ghcr.io/xema-dev/presenca-biomes-composed-process-prefixed');
|
|
133
|
+
const entry = JSON.parse(out.entry);
|
|
134
|
+
assert.equal(entry.service, 'biomes-composed-process');
|
|
135
|
+
assert.equal(entry.imageRepository, out.image);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('without --service the output is identical to passing the image name as the service', () => {
|
|
139
|
+
const a = composedCoordinate({ root: consumer(), imageName: 'x', imageDir: 'd', env: ENV });
|
|
140
|
+
const b = composedCoordinate({ root: consumer(), imageName: 'x', service: 'x', imageDir: 'd', env: ENV });
|
|
141
|
+
assert.deepEqual(a, b);
|
|
142
|
+
});
|
package/src/composed-image.mjs
CHANGED
|
@@ -58,7 +58,10 @@
|
|
|
58
58
|
// load-image <image>
|
|
59
59
|
// the same check against the SHIPPED image (`docker run`), as its own
|
|
60
60
|
// user and NODE_ENV: `load` runs in the builder, which is not what ships.
|
|
61
|
-
// coordinate --image-name <name>
|
|
61
|
+
// coordinate --image-name <name> [--service <subjectId>]
|
|
62
|
+
// --service is the hash subject id (default: the image name); a prefixed
|
|
63
|
+
// image name needs it, e.g. `--service presenca-biomes-composed-process
|
|
64
|
+
// --image-name presenca-presenca-biomes-composed-process`.
|
|
62
65
|
// the image's content tag (computed ONLY by the repository's own
|
|
63
66
|
// `xema-compute-image-tag`) and its cross-repository pin count; pure, no
|
|
64
67
|
// registry probe or credential (`composed-image-coordinate.mjs`;
|
|
@@ -321,7 +324,7 @@ const USAGE =
|
|
|
321
324
|
' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
|
|
322
325
|
' pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)\n' +
|
|
323
326
|
' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
324
|
-
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
327
|
+
' coordinate --image-name <name> [--service <subjectId>] (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
325
328
|
' load-image <image>\n' +
|
|
326
329
|
' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
|
|
327
330
|
' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
@@ -330,7 +333,7 @@ const USAGE =
|
|
|
330
333
|
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also', 'subject']);
|
|
331
334
|
const BOOLEAN = new Set(['prebuilt', 'each']);
|
|
332
335
|
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
|
|
333
|
-
'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
336
|
+
'image-name', 'service', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
334
337
|
]);
|
|
335
338
|
|
|
336
339
|
export function parseArguments(argv) {
|
|
@@ -432,7 +435,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
432
435
|
|
|
433
436
|
}
|
|
434
437
|
case 'coordinate':
|
|
435
|
-
emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'] }), { log });
|
|
438
|
+
emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'], service: options.service }), { log });
|
|
436
439
|
return;
|
|
437
440
|
case 'load-image':
|
|
438
441
|
runImageLoad({ image: argument, log });
|
package/src/prepush-sanity.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// the pushed commits change plus their dependents. A change to the lockfile,
|
|
9
9
|
// root package.json or turbo.json invalidates everything and runs the full
|
|
10
10
|
// graph. A repo whose turbo carries no `lint` task also runs its root `lint`
|
|
11
|
-
// script. Without turbo: root `
|
|
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
|
|
14
14
|
// is HEAD, or when no differing path lies in a package the run reads or at the
|
|
@@ -182,10 +182,10 @@ function hasRootLint(dir) {
|
|
|
182
182
|
/** Run the sanity tasks in `dir`; `range` '' is the full graph. Returns the verdict. */
|
|
183
183
|
function runHere(ctx, dir, range, bin) {
|
|
184
184
|
if (!bin) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return
|
|
185
|
+
// Without turbo nothing orders the builds a workspace typecheck needs, and the hook never
|
|
186
|
+
// builds: typecheck there is CI's.
|
|
187
|
+
console.log('pre-push: pnpm run lint (this repository declares no turbo; typecheck is CI-only)');
|
|
188
|
+
return ctx.inherit('pnpm', ['run', 'lint'], dir);
|
|
189
189
|
}
|
|
190
190
|
let tasks;
|
|
191
191
|
try {
|
|
@@ -325,7 +325,7 @@ export function main(stdin) {
|
|
|
325
325
|
}
|
|
326
326
|
const failed = refs.filter((ref) => !gradeSha(ctx, ref, bin)).map((ref) => ctx.gitOut(['rev-parse', '--short', ref.sha])?.trim() ?? ref.sha);
|
|
327
327
|
if (failed.length === 0) {
|
|
328
|
-
console.log(`pre-push: GREEN —
|
|
328
|
+
console.log(`pre-push: GREEN — sanity passed on ${refs.length} pushed commit(s).`);
|
|
329
329
|
return 0;
|
|
330
330
|
}
|
|
331
331
|
console.log(`::error::pre-push FAILED — ${failed.join(' ')}`);
|
|
@@ -60,19 +60,19 @@ function hook(dir, stdin) {
|
|
|
60
60
|
return spawnSync('node', [VERB], { cwd: dir, input: stdin, encoding: 'utf8' });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
test('no turbo:
|
|
64
|
-
const
|
|
65
|
-
const
|
|
63
|
+
test('no turbo: lint only. A failing lint refuses the push; a failing typecheck is CI-only', () => {
|
|
64
|
+
const badLint = repo({ typecheck: 'exit 0', lint: 'exit 1' });
|
|
65
|
+
const badTypecheck = repo({ typecheck: 'exit 1', lint: 'exit 0' });
|
|
66
66
|
try {
|
|
67
|
-
const refused = hook(
|
|
67
|
+
const refused = hook(badLint.dir, `refs/heads/x ${badLint.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
|
|
68
68
|
assert.equal(refused.status, 1, refused.stdout + refused.stderr);
|
|
69
69
|
assert.match(refused.stdout, /pre-push: FAILED/);
|
|
70
|
-
const green = hook(
|
|
70
|
+
const green = hook(badTypecheck.dir, `refs/heads/x ${badTypecheck.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
|
|
71
71
|
assert.equal(green.status, 0, green.stdout + green.stderr);
|
|
72
72
|
assert.match(green.stdout, /GREEN/);
|
|
73
73
|
} finally {
|
|
74
|
-
rmSync(
|
|
75
|
-
rmSync(
|
|
74
|
+
rmSync(badLint.dir, { recursive: true, force: true });
|
|
75
|
+
rmSync(badTypecheck.dir, { recursive: true, force: true });
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
78
|
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
// (c) content: the caller's tag command at that revision AND at the promoted
|
|
12
12
|
// commit both print the content tag being promoted.
|
|
13
13
|
// Every refusal names its leg and is an error, never a miss.
|
|
14
|
+
//
|
|
15
|
+
// A release digest main already promoted is REUSED from its image-promotion/v1 statements. Two main commits that
|
|
16
|
+
// promote identical content share one release digest, so the digest carries one statement per promotedCommit. The
|
|
17
|
+
// evidence is SELECTED by promotedCommit, mirroring xema-deploy's deploy verifier (`assertPromotionPredicate`): only
|
|
18
|
+
// statements whose promotedCommit is a 40-hex ancestor of (or equal to) the commit being promoted are candidates, and
|
|
19
|
+
// the nearest such commit wins (the one every other candidate is an ancestor of). Statements of other commits are
|
|
20
|
+
// ignored, whatever their shape or subject. Only then is every selected statement checked in full (predicate type,
|
|
21
|
+
// subject digest, release bindings, source, developProvenance); one failing is an error. No candidate, or no single
|
|
22
|
+
// nearest commit, is an error; the selected statements must all yield the same source and developProvenance.
|
|
14
23
|
|
|
15
24
|
import { createHash } from 'node:crypto';
|
|
16
25
|
import { describeFailure, runTool } from './promote-image-registry.mjs';
|
|
@@ -46,11 +55,8 @@ function readStatements(stdout, subject, label) {
|
|
|
46
55
|
return byDigest;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
* statement whose subject is the digest. Returns `{ statement, payloadDigest }`.
|
|
52
|
-
*/
|
|
53
|
-
function verifiedStatement({ subject, digest, type, predicateType, what, noun, pattern, label }, run) {
|
|
58
|
+
/** Runs `cosign verify-attestation` of `type` on `subject` under `pattern`; returns every distinct verified statement by payload digest. */
|
|
59
|
+
function verifiedStatements({ subject, type, what, pattern, label }, run) {
|
|
54
60
|
const verified = run('cosign', [
|
|
55
61
|
'verify-attestation',
|
|
56
62
|
'--type', type,
|
|
@@ -63,18 +69,55 @@ function verifiedStatement({ subject, digest, type, predicateType, what, noun, p
|
|
|
63
69
|
const detail = describeFailure(verified);
|
|
64
70
|
throw new Error(`${label}: ${subject} has no ${what} signed by ${pattern}: ${detail}`);
|
|
65
71
|
}
|
|
66
|
-
|
|
72
|
+
return readStatements(verified.stdout, subject, label);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isStatementAbout = (statement, digest, predicateType) =>
|
|
76
|
+
statement?.predicateType === predicateType && Boolean(statement.subject?.some((entry) => entry?.digest?.sha256 === digest.slice('sha256:'.length)));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs `cosign verify-attestation` of `type` on `subject` under `pattern` and requires exactly one distinct verified
|
|
80
|
+
* statement whose subject is the digest. Returns `{ statement, payloadDigest }`.
|
|
81
|
+
*/
|
|
82
|
+
function verifiedStatement({ subject, digest, type, predicateType, what, noun, pattern, label }, run) {
|
|
83
|
+
const statements = verifiedStatements({ subject, type, what, pattern, label }, run);
|
|
67
84
|
if (statements.size !== 1) {
|
|
68
85
|
throw new Error(`${label}: ${subject} carries ${statements.size} distinct verified ${noun} statements; exactly one is promotable`);
|
|
69
86
|
}
|
|
70
87
|
const [[payloadDigest, statement]] = statements;
|
|
71
|
-
|
|
72
|
-
if (statement.predicateType !== predicateType || !statement.subject?.some((entry) => entry?.digest?.sha256 === hex)) {
|
|
88
|
+
if (!isStatementAbout(statement, digest, predicateType)) {
|
|
73
89
|
throw new Error(`${label}: the verified statement is not ${what} about ${subject}`);
|
|
74
90
|
}
|
|
75
91
|
return { statement, payloadDigest };
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/** Whether `ancestor` is `descendant` or one of its ancestors; any git failure other than "no" is an error. */
|
|
95
|
+
function isAncestorOrEqual(ancestor, descendant, label, run) {
|
|
96
|
+
if (ancestor === descendant) return true;
|
|
97
|
+
const ordered = run('git', ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
98
|
+
if (ordered.error || (ordered.status !== 0 && ordered.status !== 1)) {
|
|
99
|
+
throw new Error(`${label}: git could not order ${ancestor} against ${descendant}: ${describeFailure(ordered)}`);
|
|
100
|
+
}
|
|
101
|
+
return ordered.status === 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The promotedCommit whose statements are the evidence for a reuse at `commit`: the nearest ancestor of (or equal to)
|
|
106
|
+
* `commit` among the 40-hex `promotedCommits` the digest carries. Anything not a commit is never passed to git.
|
|
107
|
+
*/
|
|
108
|
+
function nearestPromotedCommit(promotedCommits, commit, subject, label, run) {
|
|
109
|
+
const found = [...promotedCommits].sort();
|
|
110
|
+
const ancestors = found.filter((candidate) => isAncestorOrEqual(candidate, commit, label, run));
|
|
111
|
+
if (ancestors.length === 0) {
|
|
112
|
+
throw new Error(`${label}: no image-promotion/v1 statement on ${subject} promotes ${commit} or an ancestor of it; the digest carries promotions of: ${found.join(', ') || 'none'}`);
|
|
113
|
+
}
|
|
114
|
+
const nearest = ancestors.filter((candidate) => ancestors.every((other) => isAncestorOrEqual(other, candidate, label, run)));
|
|
115
|
+
if (nearest.length !== 1) {
|
|
116
|
+
throw new Error(`${label}: ambiguous: the promotions of ${subject} at ${ancestors.join(', ')} are ancestors of ${commit} but none contains all the others`);
|
|
117
|
+
}
|
|
118
|
+
return nearest[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
78
121
|
// cosign v2 exits 1 with this exact stderr line (and empty stdout) for an image that carries no attestations.
|
|
79
122
|
const NO_ATTESTATIONS_LINE = 'Error: found no attestations';
|
|
80
123
|
|
|
@@ -102,7 +145,7 @@ function attestationTypes(subject, label, run) {
|
|
|
102
145
|
* Returns `undefined` when the digest carries no promotion attestation (a legacy image); a registry error, a signature or identity
|
|
103
146
|
* failure, or a source outside `<registry>/<owner>/dev/`, is an error.
|
|
104
147
|
*/
|
|
105
|
-
export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows }, run = runTool) {
|
|
148
|
+
export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, releaseWorkflows, commit }, run = runTool) {
|
|
106
149
|
const subject = `${releaseRef}@${digest}`;
|
|
107
150
|
const label = 'reuse';
|
|
108
151
|
// (a) presence, without identity verification: only the predicate types are read
|
|
@@ -112,12 +155,31 @@ export function reuseEarlierPromotion({ releaseRef, tag, digest, repository, rel
|
|
|
112
155
|
}
|
|
113
156
|
if (!types.includes(IMAGE_PROMOTION_PREDICATE_TYPE)) return undefined;
|
|
114
157
|
// (b) a promotion attestation is present: any verification failure is an error
|
|
115
|
-
const
|
|
116
|
-
subject,
|
|
117
|
-
|
|
118
|
-
label,
|
|
158
|
+
const verified = verifiedStatements({
|
|
159
|
+
subject, type: IMAGE_PROMOTION_PREDICATE_TYPE, what: 'image-promotion/v1 attestation',
|
|
160
|
+
pattern: workflowIdentityPattern(repository, releaseWorkflows, 'main'), label,
|
|
119
161
|
}, run);
|
|
120
|
-
|
|
162
|
+
// (c) select by promotedCommit first; a statement about any other commit is ignored, whatever its shape or subject
|
|
163
|
+
const about = [...verified.values()];
|
|
164
|
+
const promoted = (statement) => statement.predicate?.promotedCommit;
|
|
165
|
+
const nearest = nearestPromotedCommit(
|
|
166
|
+
new Set(about.map(promoted).filter((value) => typeof value === 'string' && COMMIT.test(value))), commit, subject, label, run,
|
|
167
|
+
);
|
|
168
|
+
const selected = about.filter((statement) => promoted(statement) === nearest);
|
|
169
|
+
const admitted = new Set(selected.map((statement) => JSON.stringify(admissible(statement, { subject, tag, digest, releaseRef, repository, label }))));
|
|
170
|
+
if (admitted.size !== 1) {
|
|
171
|
+
throw new Error(`${label}: ${subject} carries ${admitted.size} distinct promotion statements for ${nearest}; exactly one is admissible`);
|
|
172
|
+
}
|
|
173
|
+
const [only] = admitted;
|
|
174
|
+
return JSON.parse(only);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Checks one promotion statement in full against this release; returns `{ source, developProvenance }` copied from it. */
|
|
178
|
+
function admissible(statement, { subject, tag, digest, releaseRef, repository, label }) {
|
|
179
|
+
if (!isStatementAbout(statement, digest, IMAGE_PROMOTION_PREDICATE_TYPE)) {
|
|
180
|
+
throw new Error(`${label}: the selected statement is not an image-promotion/v1 attestation about ${subject}`);
|
|
181
|
+
}
|
|
182
|
+
const { source, developProvenance, release } = statement.predicate ?? {};
|
|
121
183
|
for (const [field, expected, stated] of [['digest', digest, release?.digest], ['repository', releaseRef, release?.repository], ['tag', tag, release?.tag]]) {
|
|
122
184
|
if (stated !== expected) throw new Error(`${label}: the promotion attestation of ${subject} states release ${field} ${stated}, not ${expected}`);
|
|
123
185
|
}
|
package/src/promote-image.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// rebuilding them, and only when the develop bytes are PROVEN promotable.
|
|
5
5
|
//
|
|
6
6
|
// 1. `<release-ref>:<tag>` exists: its image-promotion/v1 attestation must verify under one of main's
|
|
7
|
-
// `--release-workflow` identities
|
|
7
|
+
// `--release-workflow` identities; the statement of the nearest promotedCommit that is an ancestor of
|
|
8
|
+
// (or equal to) `--commit` is the evidence (as xema-deploy's deploy verifier selects), and its source must be a
|
|
8
9
|
// `ghcr.io/<owner>/dev/<name>` reference -> reused (evidence=prior-promotion)
|
|
9
10
|
// - no image-promotion/v1 attestation (a legacy image) -> miss (reason=reuse-no-promotion): the caller builds
|
|
10
11
|
// - a signature / identity failure, or a source outside dev/ -> error, never a miss
|
|
@@ -152,7 +153,7 @@ export function promoteImage(options, run = runTool) {
|
|
|
152
153
|
const releaseTagged = `${releaseRef}:${tag}`;
|
|
153
154
|
const released = probeDigest(releaseTagged, run);
|
|
154
155
|
if (released !== undefined) {
|
|
155
|
-
const earlier = reuseEarlierPromotion({ releaseRef, tag, digest: released, repository, releaseWorkflows }, run);
|
|
156
|
+
const earlier = reuseEarlierPromotion({ releaseRef, tag, digest: released, repository, releaseWorkflows, commit }, run);
|
|
156
157
|
if (earlier === undefined) return { ...MISS, reason: PromoteMissReason.NO_PROMOTION };
|
|
157
158
|
return {
|
|
158
159
|
result: PromoteResult.REUSED,
|
|
@@ -25,7 +25,8 @@ const PROMOTION_TYPE = 'https://xema.dev/attestations/image-promotion/v1';
|
|
|
25
25
|
// signatures[subject] the identity `cosign verify` finds
|
|
26
26
|
// downloadErrors[subject] what `cosign download attestation` fails with
|
|
27
27
|
// attestations[subject] { identity, statements, type? } (type defaults to slsaprovenance1) `cosign verify-attestation` finds
|
|
28
|
-
// ancestors revisions `git merge-base --is-ancestor` accepts
|
|
28
|
+
// ancestors revisions `git merge-base --is-ancestor` accepts against origin/main
|
|
29
|
+
// lineage [ancestor, descendant] pairs it accepts against any other ref (equal commits are ancestors)
|
|
29
30
|
// hashes[commit] what the tag command prints for `--commit <commit>`
|
|
30
31
|
// Every call is recorded in `calls` as [tool, ...args].
|
|
31
32
|
const SHIM = `#!${process.execPath}
|
|
@@ -87,6 +88,10 @@ if (tool === 'cosign') {
|
|
|
87
88
|
}
|
|
88
89
|
if (tool === 'git') {
|
|
89
90
|
if (args[0] === 'merge-base' && args[1] === '--is-ancestor' && args[3] === 'origin/main') process.exit(state.ancestors.includes(args[2]) ? 0 : 1);
|
|
91
|
+
if (args[0] === 'merge-base' && args[1] === '--is-ancestor') {
|
|
92
|
+
const yes = args[2] === args[3] || (state.lineage ?? []).some(([ancestor, descendant]) => ancestor === args[2] && descendant === args[3]);
|
|
93
|
+
process.exit(yes ? 0 : 1);
|
|
94
|
+
}
|
|
90
95
|
fail('unexpected git ' + args.join(' '), 2);
|
|
91
96
|
}
|
|
92
97
|
if (tool === 'tag-command') {
|
|
@@ -180,23 +185,29 @@ const EARLIER_PROVENANCE = {
|
|
|
180
185
|
};
|
|
181
186
|
const EARLIER_COMMIT = '5'.repeat(40);
|
|
182
187
|
|
|
183
|
-
//
|
|
184
|
-
function
|
|
185
|
-
|
|
188
|
+
// One signed image-promotion/v1 statement about the release digest.
|
|
189
|
+
function promotionStatement({ subjectDigest = OTHER, predicateType = PROMOTION_TYPE, release = { repository: RELEASE, tag: TAG, digest: OTHER }, source = `${DEV}@${OTHER}`, revision = REVISION, promotedCommit = EARLIER_COMMIT, provenance = EARLIER_PROVENANCE, extra = {} } = {}) {
|
|
190
|
+
return JSON.stringify({
|
|
186
191
|
_type: 'https://in-toto.io/Statement/v1',
|
|
187
|
-
subject: [{ name: RELEASE, digest: { sha256:
|
|
188
|
-
predicateType
|
|
192
|
+
subject: [{ name: RELEASE, digest: { sha256: subjectDigest.slice('sha256:'.length) } }],
|
|
193
|
+
predicateType,
|
|
189
194
|
predicate: {
|
|
190
|
-
release
|
|
191
|
-
source: { reference: source, revision
|
|
192
|
-
promotedCommit
|
|
193
|
-
developProvenance:
|
|
195
|
+
release,
|
|
196
|
+
source: { reference: source, revision },
|
|
197
|
+
promotedCommit,
|
|
198
|
+
developProvenance: provenance,
|
|
194
199
|
...extra,
|
|
195
200
|
},
|
|
196
201
|
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A release digest main already promoted: its image-promotion/v1 attestation(s), signed by a main workflow.
|
|
205
|
+
// `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]] } = {}) {
|
|
197
207
|
return {
|
|
198
208
|
tags: { [`${RELEASE}:${TAG}`]: OTHER },
|
|
199
|
-
attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: [
|
|
209
|
+
attestations: { [`${RELEASE}@${OTHER}`]: { identity, type, statements: statements ?? [promotionStatement({ source, extra })] } },
|
|
210
|
+
lineage,
|
|
200
211
|
};
|
|
201
212
|
}
|
|
202
213
|
|
|
@@ -277,6 +288,136 @@ test('a promotion attestation stating another release digest is refused', () =>
|
|
|
277
288
|
refused(promotedRelease({ extra: { release: { repository: RELEASE, tag: TAG, digest: DIGEST } } }), /states release digest sha256:d+, not /);
|
|
278
289
|
});
|
|
279
290
|
|
|
291
|
+
const MIDDLE_COMMIT = '6'.repeat(40);
|
|
292
|
+
const OTHER_BRANCH_COMMIT = '7'.repeat(40);
|
|
293
|
+
const gitCalls = (state) => state.calls.filter((call) => call[0] === 'git');
|
|
294
|
+
|
|
295
|
+
test('reuse: statements only about non-ancestor commits are refused, naming them', () => {
|
|
296
|
+
refused(
|
|
297
|
+
promotedRelease({ lineage: [], statements: [promotionStatement({ promotedCommit: EARLIER_COMMIT }), promotionStatement({ promotedCommit: OTHER_BRANCH_COMMIT })] }),
|
|
298
|
+
new RegExp(`reuse: no image-promotion/v1 statement on .* promotes ${COMMIT} or an ancestor of it; the digest carries promotions of: ${EARLIER_COMMIT}, ${OTHER_BRANCH_COMMIT}`),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('reuse: two incomparable ancestor promotions are refused as ambiguous', () => {
|
|
303
|
+
refused(
|
|
304
|
+
promotedRelease({
|
|
305
|
+
lineage: [[EARLIER_COMMIT, COMMIT], [OTHER_BRANCH_COMMIT, COMMIT]],
|
|
306
|
+
statements: [promotionStatement({ promotedCommit: EARLIER_COMMIT }), promotionStatement({ promotedCommit: OTHER_BRANCH_COMMIT })],
|
|
307
|
+
}),
|
|
308
|
+
new RegExp(`reuse: ambiguous: .*${EARLIER_COMMIT}, ${OTHER_BRANCH_COMMIT}`),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('reuse: two statements of the nearest commit that disagree are refused', () => {
|
|
313
|
+
refused(
|
|
314
|
+
promotedRelease({ statements: [promotionStatement(), promotionStatement({ revision: '8'.repeat(40) })] }),
|
|
315
|
+
new RegExp(`reuse: .* carries 2 distinct promotion statements for ${EARLIER_COMMIT}; exactly one is admissible`),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('reuse: two selected statements citing different developProvenance are refused', () => {
|
|
320
|
+
refused(
|
|
321
|
+
promotedRelease({ statements: [promotionStatement(), promotionStatement({ provenance: { ...EARLIER_PROVENANCE, payloadDigest: sha('other') } })] }),
|
|
322
|
+
/carries 2 distinct promotion statements for .*; exactly one is admissible/,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('reuse: two selected statements with different release.tag are refused', () => {
|
|
327
|
+
refused(
|
|
328
|
+
promotedRelease({ statements: [promotionStatement(), promotionStatement({ release: { repository: RELEASE, tag: 'other-tag', digest: OTHER } })] }),
|
|
329
|
+
/states release tag other-tag, not /,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('reuse: a selected statement whose subject is another digest is refused', () => {
|
|
334
|
+
refused(
|
|
335
|
+
promotedRelease({ statements: [promotionStatement({ subjectDigest: DIGEST })] }),
|
|
336
|
+
/the selected statement is not an image-promotion\/v1 attestation about /,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('reuse: a selected statement of another predicateType is refused', () => {
|
|
341
|
+
refused(
|
|
342
|
+
// the registry listing must still show an image-promotion/v1 attestation, or the digest is a legacy miss
|
|
343
|
+
promotedRelease({ statements: [promotionStatement({ promotedCommit: OTHER_BRANCH_COMMIT }), promotionStatement({ predicateType: 'https://slsa.dev/provenance/v1' })] }),
|
|
344
|
+
/the selected statement is not an image-promotion\/v1 attestation about /,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('reuse: a promotedCommit that is not a 40-hex commit is ignored and never reaches git', () => {
|
|
349
|
+
const { run, cleanup } = scenario(promotedRelease({
|
|
350
|
+
statements: [promotionStatement(), promotionStatement({ promotedCommit: '--output=/tmp/x' }), promotionStatement({ promotedCommit: 'main' })],
|
|
351
|
+
}));
|
|
352
|
+
try {
|
|
353
|
+
const r = run();
|
|
354
|
+
assert.equal(r.status, 0, r.stderr);
|
|
355
|
+
assert.equal(r.outputs.result, 'reused');
|
|
356
|
+
assert.deepEqual(gitCalls(r.state), [['git', 'merge-base', '--is-ancestor', EARLIER_COMMIT, COMMIT]]);
|
|
357
|
+
} finally {
|
|
358
|
+
cleanup();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('reuse: an invalid statement about a non-ancestor commit is ignored while the valid nearest one is reused', () => {
|
|
363
|
+
const { run, cleanup } = scenario(promotedRelease({
|
|
364
|
+
statements: [promotionStatement({ promotedCommit: OTHER_BRANCH_COMMIT, source: 'ghcr.io/acme/svc@x', revision: 'nope' }), promotionStatement()],
|
|
365
|
+
}));
|
|
366
|
+
try {
|
|
367
|
+
const r = run();
|
|
368
|
+
assert.equal(r.status, 0, r.stderr);
|
|
369
|
+
assert.equal(r.outputs.revision, REVISION);
|
|
370
|
+
} finally {
|
|
371
|
+
cleanup();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('reuse: the nearest ancestor promotion is the evidence', () => {
|
|
376
|
+
const middleRevision = '9'.repeat(40);
|
|
377
|
+
const middleProvenance = { ...EARLIER_PROVENANCE, payloadDigest: sha('middle-provenance') };
|
|
378
|
+
const { run, cleanup } = scenario(promotedRelease({
|
|
379
|
+
lineage: [[EARLIER_COMMIT, MIDDLE_COMMIT], [EARLIER_COMMIT, COMMIT], [MIDDLE_COMMIT, COMMIT]],
|
|
380
|
+
statements: [promotionStatement(), promotionStatement({ promotedCommit: MIDDLE_COMMIT, revision: middleRevision, provenance: middleProvenance })],
|
|
381
|
+
}));
|
|
382
|
+
try {
|
|
383
|
+
const r = run();
|
|
384
|
+
assert.equal(r.status, 0, r.stderr);
|
|
385
|
+
assert.equal(r.predicate.source.revision, middleRevision);
|
|
386
|
+
assert.deepEqual(r.predicate.developProvenance, middleProvenance);
|
|
387
|
+
assert.equal(r.predicate.promotedCommit, COMMIT);
|
|
388
|
+
} finally {
|
|
389
|
+
cleanup();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('reuse: a re-run (a statement promoting this very commit) selects that statement over an older one', () => {
|
|
394
|
+
const rerunRevision = '9'.repeat(40);
|
|
395
|
+
const { run, cleanup } = scenario(promotedRelease({
|
|
396
|
+
statements: [promotionStatement(), promotionStatement({ promotedCommit: COMMIT, revision: rerunRevision })],
|
|
397
|
+
}));
|
|
398
|
+
try {
|
|
399
|
+
const r = run();
|
|
400
|
+
assert.equal(r.status, 0, r.stderr);
|
|
401
|
+
assert.equal(r.predicate.source.revision, rerunRevision);
|
|
402
|
+
} finally {
|
|
403
|
+
cleanup();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('reuse: two identical-evidence statements for the nearest commit are reused', () => {
|
|
408
|
+
const { run, cleanup } = scenario(promotedRelease({
|
|
409
|
+
statements: [promotionStatement(), promotionStatement({ extra: { attestedAt: 'second' } })],
|
|
410
|
+
}));
|
|
411
|
+
try {
|
|
412
|
+
const r = run();
|
|
413
|
+
assert.equal(r.status, 0, r.stderr);
|
|
414
|
+
assert.equal(r.outputs.result, 'reused');
|
|
415
|
+
assert.equal(r.predicate.source.revision, REVISION);
|
|
416
|
+
} finally {
|
|
417
|
+
cleanup();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
280
421
|
test('a proven develop digest is copied by digest and the promotion predicate states the facts', () => {
|
|
281
422
|
const { run, predicatePath, cleanup } = scenario(provenDevelop());
|
|
282
423
|
try {
|