@xemahq/repo-build-tooling 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +7 -3
- package/src/arm64-supplement-needed.mjs +325 -0
- package/src/arm64-supplement-needed.test.mjs +257 -0
- package/src/promote-image-predicate.mjs +27 -0
- package/src/promote-image-provenance.mjs +175 -0
- package/src/promote-image-registry.mjs +63 -0
- package/src/promote-image.mjs +223 -0
- package/src/promote-image.test.mjs +453 -0
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
30
30
|
| `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
|
|
31
31
|
| `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
|
|
32
32
|
| `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
|
+
| `xema-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 |
|
|
33
35
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
34
36
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
35
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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)",
|
|
@@ -21,18 +21,22 @@
|
|
|
21
21
|
"src"
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
|
+
"xema-arm64-supplement-needed": "src/arm64-supplement-needed.mjs",
|
|
24
25
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
25
26
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
26
27
|
"xema-composed-image": "src/composed-image.mjs",
|
|
28
|
+
"xema-promote-image": "src/promote-image.mjs",
|
|
27
29
|
"xema-readiness": "src/readiness.mjs",
|
|
28
30
|
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
29
31
|
"xema-service-bootstrap": "src/service-bootstrap.mjs"
|
|
30
32
|
},
|
|
31
33
|
"exports": {
|
|
34
|
+
"./arm64-supplement-needed": "./src/arm64-supplement-needed.mjs",
|
|
32
35
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
33
36
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
34
37
|
"./composed-image": "./src/composed-image.mjs",
|
|
35
38
|
"./package.json": "./package.json",
|
|
39
|
+
"./promote-image": "./src/promote-image.mjs",
|
|
36
40
|
"./readiness": "./src/readiness.mjs",
|
|
37
41
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
38
42
|
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
@@ -41,9 +45,9 @@
|
|
|
41
45
|
"@xemahq/release-policy": "^14.0.0"
|
|
42
46
|
},
|
|
43
47
|
"scripts": {
|
|
44
|
-
"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"
|
|
48
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs"
|
|
45
49
|
},
|
|
46
50
|
"xemaRelease": {
|
|
47
|
-
"sourceDigest": "sha256:
|
|
51
|
+
"sourceDigest": "sha256:aba8ae8794d81525b38c0dfcfefae6abb58b1075eebdc18d261e661c0e07b2fd"
|
|
48
52
|
}
|
|
49
53
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// DOES THIS COORDINATE STILL NEED AN arm64 SUPPLEMENT?
|
|
4
|
+
//
|
|
5
|
+
// An arm64 supplement lane that rebuilds and re-merges UNCONDITIONALLY does
|
|
6
|
+
// more than waste a run. `imagetools create -t :<contentHash>` republishes the
|
|
7
|
+
// canonical coordinate by design, so supplementing an ALREADY-COMPLETE index
|
|
8
|
+
// MOVES A TAG somebody may already have sealed.
|
|
9
|
+
//
|
|
10
|
+
// ── WHY A RE-RUN MOVES THE DIGEST AT ALL ────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Two builds of the SAME source do not produce the same arm64 manifest unless
|
|
13
|
+
// the build is reproducible. Absent a `SOURCE_DATE_EPOCH`, the image config's
|
|
14
|
+
// `created` and `history[].created` are WALL-CLOCK, so a rebuild changes the
|
|
15
|
+
// config digest and therefore the manifest digest even when every layer byte is
|
|
16
|
+
// identical. The amd64 side does not move only because it is never rebuilt by a
|
|
17
|
+
// supplement lane — the merge REFERENCES its existing per-commit tag.
|
|
18
|
+
//
|
|
19
|
+
// Making the arm64 build reproducible is a separate and much larger decision:
|
|
20
|
+
// it would move every coordinate's digest on the next build. This probe instead
|
|
21
|
+
// makes the question MOOT for a coordinate that is already final, which is the
|
|
22
|
+
// cheap half and the one that stops a sealed tag moving under a release.
|
|
23
|
+
//
|
|
24
|
+
// ── WHY A PROOF AND NOT A READ ──────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// An index descriptor DECLARES a child's platform. The merge is exactly where a
|
|
27
|
+
// mislabelled arm64 child would enter, so trusting the declaration would let one
|
|
28
|
+
// bad index become permanent: the supplement lane is the only thing that can
|
|
29
|
+
// repair it, and a descriptor-only check would skip the repair forever.
|
|
30
|
+
//
|
|
31
|
+
// So a skip is EARNED — by resolving the child manifest BY DIGEST and reading
|
|
32
|
+
// the architecture out of its image CONFIG. A mislabelled index reads as NOT
|
|
33
|
+
// SUPPLEMENTED and is rebuilt.
|
|
34
|
+
//
|
|
35
|
+
// ── FAIL LOUD, NEVER GUESS ──────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Only 200 and 404 are answers. A 401/403 on a private package is
|
|
38
|
+
// indistinguishable from an image that was never pushed, so anything else
|
|
39
|
+
// throws. That direction is deliberate: a supplement lane is off the deploy
|
|
40
|
+
// graph, so a red run there costs nothing, whereas a guessed answer either
|
|
41
|
+
// moves a sealed tag or silently leaves a coordinate single-platform.
|
|
42
|
+
//
|
|
43
|
+
// The same posture governs the ENTRYPOINT GUARD at the foot of this file. It
|
|
44
|
+
// uses `invokedDirectly` (realpath on both sides) because the alternative fails
|
|
45
|
+
// in the dangerous direction: a guard that does not match runs no `main()`,
|
|
46
|
+
// writes no `needed=` output, and a workflow gating its merge on
|
|
47
|
+
// `needed == 'true'` then SKIPS a supplement that was genuinely owed —
|
|
48
|
+
// silently, and with a green tick.
|
|
49
|
+
//
|
|
50
|
+
// Registry transport (retry, redaction, the GHCR pull token) is
|
|
51
|
+
// `@xemahq/release-policy/registry`, not a copy of it.
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
import { appendFileSync } from 'node:fs';
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
acquireGhcrPullToken,
|
|
58
|
+
describeErrorChain,
|
|
59
|
+
registryFetch,
|
|
60
|
+
} from '@xemahq/release-policy/registry';
|
|
61
|
+
|
|
62
|
+
import { invokedDirectly } from './readiness.mjs';
|
|
63
|
+
|
|
64
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/u;
|
|
65
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
66
|
+
|
|
67
|
+
const MANIFEST_ACCEPT = [
|
|
68
|
+
'application/vnd.oci.image.index.v1+json',
|
|
69
|
+
'application/vnd.oci.image.manifest.v1+json',
|
|
70
|
+
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
71
|
+
'application/vnd.docker.distribution.manifest.v2+json',
|
|
72
|
+
].join(', ');
|
|
73
|
+
|
|
74
|
+
/** The platform a supplement exists to add. */
|
|
75
|
+
export const SUPPLEMENT_OS = 'linux';
|
|
76
|
+
export const SUPPLEMENT_ARCHITECTURE = 'arm64';
|
|
77
|
+
|
|
78
|
+
async function getJson({
|
|
79
|
+
registry,
|
|
80
|
+
repository,
|
|
81
|
+
reference,
|
|
82
|
+
bearer,
|
|
83
|
+
fetchImpl,
|
|
84
|
+
sleep,
|
|
85
|
+
}) {
|
|
86
|
+
const response = await registryFetch(
|
|
87
|
+
fetchImpl,
|
|
88
|
+
`https://${registry}/v2/${repository}/manifests/${reference}`,
|
|
89
|
+
{
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
92
|
+
},
|
|
93
|
+
{ sleep },
|
|
94
|
+
);
|
|
95
|
+
if (response.status === 404) return { status: 404, document: undefined };
|
|
96
|
+
if (response.status !== 200) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${registry}: manifest lookup for ${repository}:${reference} returned HTTP ${response.status}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
let document;
|
|
102
|
+
try {
|
|
103
|
+
document = await response.json();
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${registry}: manifest ${repository}:${reference} was not JSON`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { status: 200, document };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Children whose DESCRIPTOR claims the supplemented platform.
|
|
114
|
+
*
|
|
115
|
+
* BuildKit attestation manifests declare `unknown/unknown` and are excluded by
|
|
116
|
+
* the same predicate that selects the runnable child — deliberately, rather
|
|
117
|
+
* than by naming them, so an unrecognised child is never silently treated as a
|
|
118
|
+
* platform it did not claim.
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} document a manifest or an index
|
|
121
|
+
* @returns {string[]} candidate child digests
|
|
122
|
+
*/
|
|
123
|
+
export function declaredArm64Children(document) {
|
|
124
|
+
const manifests = document?.manifests;
|
|
125
|
+
if (!Array.isArray(manifests)) return [];
|
|
126
|
+
return manifests
|
|
127
|
+
.filter(
|
|
128
|
+
(child) =>
|
|
129
|
+
child?.platform?.os === SUPPLEMENT_OS &&
|
|
130
|
+
child?.platform?.architecture === SUPPLEMENT_ARCHITECTURE,
|
|
131
|
+
)
|
|
132
|
+
.map((child) => child?.digest)
|
|
133
|
+
.filter((digest) => typeof digest === 'string' && DIGEST.test(digest));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prove one child really is a linux/arm64 image by reading its CONFIG.
|
|
138
|
+
*
|
|
139
|
+
* @returns {Promise<boolean>}
|
|
140
|
+
*/
|
|
141
|
+
async function childIsArm64({
|
|
142
|
+
registry,
|
|
143
|
+
repository,
|
|
144
|
+
digest,
|
|
145
|
+
bearer,
|
|
146
|
+
fetchImpl,
|
|
147
|
+
sleep,
|
|
148
|
+
}) {
|
|
149
|
+
const { status, document } = await getJson({
|
|
150
|
+
registry,
|
|
151
|
+
repository,
|
|
152
|
+
reference: digest,
|
|
153
|
+
bearer,
|
|
154
|
+
fetchImpl,
|
|
155
|
+
sleep,
|
|
156
|
+
});
|
|
157
|
+
// A child an index REFERENCES must resolve. A 404 here is a broken index,
|
|
158
|
+
// not an absent supplement — say which, rather than quietly rebuilding.
|
|
159
|
+
if (status === 404) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`${registry}: ${repository} index references child ${digest}, which the registry does not serve`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const configDigest = document?.config?.digest;
|
|
165
|
+
if (typeof configDigest !== 'string' || !DIGEST.test(configDigest)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${registry}: ${repository}@${digest} declares no usable config digest`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const response = await registryFetch(
|
|
171
|
+
fetchImpl,
|
|
172
|
+
`https://${registry}/v2/${repository}/blobs/${configDigest}`,
|
|
173
|
+
{ method: 'GET', headers: { Authorization: `Bearer ${bearer}` } },
|
|
174
|
+
{ sleep },
|
|
175
|
+
);
|
|
176
|
+
if (response.status !== 200) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${registry}: config blob ${repository}@${configDigest} returned HTTP ${response.status}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
let config;
|
|
182
|
+
try {
|
|
183
|
+
config = await response.json();
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${registry}: config blob ${repository}@${configDigest} was not JSON`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return (
|
|
190
|
+
config?.os === SUPPLEMENT_OS &&
|
|
191
|
+
config?.architecture === SUPPLEMENT_ARCHITECTURE
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Is an arm64 supplement still owed at `registry/repository:contentHash`?
|
|
197
|
+
*
|
|
198
|
+
* `needed: false` ONLY when the canonical coordinate already serves an index
|
|
199
|
+
* whose linux/arm64 child is proven by its own config. Every other resolvable
|
|
200
|
+
* state — absent, single-platform, index without arm64, index whose arm64 child
|
|
201
|
+
* does not prove out — is `needed: true`.
|
|
202
|
+
*
|
|
203
|
+
* @returns {Promise<{ needed: boolean, reason: string }>}
|
|
204
|
+
*/
|
|
205
|
+
export async function arm64SupplementNeeded({
|
|
206
|
+
registry,
|
|
207
|
+
repository,
|
|
208
|
+
contentHash,
|
|
209
|
+
user,
|
|
210
|
+
token,
|
|
211
|
+
fetchImpl = fetch,
|
|
212
|
+
sleep,
|
|
213
|
+
}) {
|
|
214
|
+
if (!SHA256_HEX.test(contentHash ?? '')) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`'${contentHash}' is not a 64-character lowercase content hash`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const bearer = await acquireGhcrPullToken({
|
|
220
|
+
registry,
|
|
221
|
+
repository,
|
|
222
|
+
user,
|
|
223
|
+
token,
|
|
224
|
+
fetchImpl,
|
|
225
|
+
});
|
|
226
|
+
const { status, document } = await getJson({
|
|
227
|
+
registry,
|
|
228
|
+
repository,
|
|
229
|
+
reference: contentHash,
|
|
230
|
+
bearer,
|
|
231
|
+
fetchImpl,
|
|
232
|
+
sleep,
|
|
233
|
+
});
|
|
234
|
+
if (status === 404) {
|
|
235
|
+
return { needed: true, reason: 'the canonical coordinate does not exist' };
|
|
236
|
+
}
|
|
237
|
+
if (!Array.isArray(document?.manifests)) {
|
|
238
|
+
return {
|
|
239
|
+
needed: true,
|
|
240
|
+
reason: `the canonical coordinate is a single manifest (${document?.mediaType ?? 'unknown media type'}), not an index`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const candidates = declaredArm64Children(document);
|
|
244
|
+
if (candidates.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
needed: true,
|
|
247
|
+
reason: `the index declares no ${SUPPLEMENT_OS}/${SUPPLEMENT_ARCHITECTURE} child`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
for (const digest of candidates) {
|
|
251
|
+
if (
|
|
252
|
+
await childIsArm64({
|
|
253
|
+
registry,
|
|
254
|
+
repository,
|
|
255
|
+
digest,
|
|
256
|
+
bearer,
|
|
257
|
+
fetchImpl,
|
|
258
|
+
sleep,
|
|
259
|
+
})
|
|
260
|
+
) {
|
|
261
|
+
return {
|
|
262
|
+
needed: false,
|
|
263
|
+
reason: `the index already carries a proven ${SUPPLEMENT_OS}/${SUPPLEMENT_ARCHITECTURE} child (${digest})`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
needed: true,
|
|
269
|
+
reason: `the index declares ${candidates.length} ${SUPPLEMENT_OS}/${SUPPLEMENT_ARCHITECTURE} child(ren) but none proved out against its own config`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The CLI entry layer: the only reader of the process environment.
|
|
275
|
+
*
|
|
276
|
+
* @param {NodeJS.ProcessEnv} env
|
|
277
|
+
*/
|
|
278
|
+
async function main(env) {
|
|
279
|
+
const registry = env.REGISTRY;
|
|
280
|
+
const image = env.IMAGE;
|
|
281
|
+
const contentHash = env.CONTENT_TAG;
|
|
282
|
+
if (!registry) throw new Error('REGISTRY is required.');
|
|
283
|
+
if (!image) throw new Error('IMAGE is required.');
|
|
284
|
+
const prefix = `${registry}/`;
|
|
285
|
+
if (!image.startsWith(prefix)) {
|
|
286
|
+
throw new Error(`IMAGE '${image}' is not hosted on '${registry}'`);
|
|
287
|
+
}
|
|
288
|
+
const repository = image.slice(prefix.length);
|
|
289
|
+
|
|
290
|
+
const { needed, reason } = await arm64SupplementNeeded({
|
|
291
|
+
registry,
|
|
292
|
+
repository,
|
|
293
|
+
contentHash,
|
|
294
|
+
user: env.REGISTRY_USER,
|
|
295
|
+
token: env.REGISTRY_TOKEN,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// THE CORPUS LINE. A skipped supplement and a supplement that silently did
|
|
299
|
+
// nothing exit the same way and print the same tick; the coordinate it
|
|
300
|
+
// graded and the reason are the only things that tell them apart.
|
|
301
|
+
console.log(
|
|
302
|
+
`arm64 supplement ${needed ? 'NEEDED' : 'ALREADY PUBLISHED'} for ` +
|
|
303
|
+
`${image}:${contentHash} — ${reason}`,
|
|
304
|
+
);
|
|
305
|
+
if (!needed) {
|
|
306
|
+
console.log(
|
|
307
|
+
'::notice::Skipping the arm64 build and the index merge. Re-merging an ' +
|
|
308
|
+
'already-complete coordinate would republish the tag under a new digest ' +
|
|
309
|
+
'and move any seal that names it.',
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const out = env.GITHUB_OUTPUT;
|
|
314
|
+
if (out) appendFileSync(out, `needed=${needed ? 'true' : 'false'}\n`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
318
|
+
main(process.env).catch((error) => {
|
|
319
|
+
console.error(
|
|
320
|
+
`::error title=arm64 supplement probe failed::${error.message}`,
|
|
321
|
+
);
|
|
322
|
+
for (const line of describeErrorChain(error)) console.error(` ${line}`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Cases for `arm64-supplement-needed.mjs` (the `xema-arm64-supplement-needed` bin).
|
|
2
|
+
// Registry transport (retry, redaction, abort) is tested in @xemahq/release-policy.
|
|
3
|
+
//
|
|
4
|
+
// The probe decides whether the arm64 lane REBUILDS and RE-MERGES. A wrong
|
|
5
|
+
// answer is expensive in both directions and neither shows up as a failure:
|
|
6
|
+
//
|
|
7
|
+
// wrongly `true` -> the lane republishes `:<contentHash>` under a NEW digest
|
|
8
|
+
// and MOVES a seal that names it. `seal-image-digests`
|
|
9
|
+
// then refuses every edition carrying that coordinate —
|
|
10
|
+
// which is exactly what happened to app-forge-api on
|
|
11
|
+
// 2026-09-21 and blocked five of nine editions.
|
|
12
|
+
// wrongly `false` -> the coordinate stays amd64-only, forever: the amd64
|
|
13
|
+
// matrix is content-addressed and never re-selects an
|
|
14
|
+
// existing coordinate, so this lane never re-arms for it.
|
|
15
|
+
//
|
|
16
|
+
// So both directions are asserted, and the mislabelled-child case is asserted
|
|
17
|
+
// SEPARATELY from the absent-child case: it is the only one that fails if the
|
|
18
|
+
// config proof is downgraded to reading the index descriptor.
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import test from 'node:test';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
arm64SupplementNeeded,
|
|
25
|
+
declaredArm64Children,
|
|
26
|
+
} from './arm64-supplement-needed.mjs';
|
|
27
|
+
|
|
28
|
+
const HASH = 'a'.repeat(64);
|
|
29
|
+
const ARM_CHILD = `sha256:${'1'.repeat(64)}`;
|
|
30
|
+
const AMD_CHILD = `sha256:${'2'.repeat(64)}`;
|
|
31
|
+
const ARM_CONFIG = `sha256:${'3'.repeat(64)}`;
|
|
32
|
+
const AMD_CONFIG = `sha256:${'4'.repeat(64)}`;
|
|
33
|
+
|
|
34
|
+
const json = (body, status = 200) => ({
|
|
35
|
+
status,
|
|
36
|
+
json: async () => body,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const INDEX_BOTH = {
|
|
40
|
+
mediaType: 'application/vnd.oci.image.index.v1+json',
|
|
41
|
+
manifests: [
|
|
42
|
+
{ digest: AMD_CHILD, platform: { os: 'linux', architecture: 'amd64' } },
|
|
43
|
+
{ digest: ARM_CHILD, platform: { os: 'linux', architecture: 'arm64' } },
|
|
44
|
+
{
|
|
45
|
+
digest: `sha256:${'9'.repeat(64)}`,
|
|
46
|
+
platform: { os: 'unknown', architecture: 'unknown' },
|
|
47
|
+
annotations: { 'vnd.docker.reference.type': 'attestation-manifest' },
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A registry whose arm64 child's CONFIG declares `configArchitecture`.
|
|
54
|
+
* Setting it to `amd64` is the mislabelled index the proof exists to catch.
|
|
55
|
+
*/
|
|
56
|
+
function registry({
|
|
57
|
+
manifest = INDEX_BOTH,
|
|
58
|
+
manifestStatus = 200,
|
|
59
|
+
configArchitecture = 'arm64',
|
|
60
|
+
childStatus = 200,
|
|
61
|
+
tokenStatus = 200,
|
|
62
|
+
} = {}) {
|
|
63
|
+
const seen = [];
|
|
64
|
+
const fetchImpl = async (url, init = {}) => {
|
|
65
|
+
const href = String(url);
|
|
66
|
+
seen.push(href);
|
|
67
|
+
if (href.includes('/token?') || href.includes('/token&') || href.endsWith('/token')) {
|
|
68
|
+
return json({ token: 'bearer-value' }, tokenStatus);
|
|
69
|
+
}
|
|
70
|
+
if (href.endsWith(`/manifests/${HASH}`)) {
|
|
71
|
+
return manifestStatus === 200
|
|
72
|
+
? json(manifest)
|
|
73
|
+
: { status: manifestStatus, json: async () => ({}) };
|
|
74
|
+
}
|
|
75
|
+
if (href.endsWith(`/manifests/${ARM_CHILD}`)) {
|
|
76
|
+
return childStatus === 200
|
|
77
|
+
? json({ config: { digest: ARM_CONFIG } })
|
|
78
|
+
: { status: childStatus, json: async () => ({}) };
|
|
79
|
+
}
|
|
80
|
+
if (href.endsWith(`/blobs/${ARM_CONFIG}`)) {
|
|
81
|
+
return json({ os: 'linux', architecture: configArchitecture });
|
|
82
|
+
}
|
|
83
|
+
if (href.endsWith(`/blobs/${AMD_CONFIG}`)) {
|
|
84
|
+
return json({ os: 'linux', architecture: 'amd64' });
|
|
85
|
+
}
|
|
86
|
+
assert.fail(`unexpected request: ${href} ${JSON.stringify(init.method)}`);
|
|
87
|
+
};
|
|
88
|
+
return { fetchImpl, seen };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const noSleep = async () => {};
|
|
92
|
+
|
|
93
|
+
const probe = (options) =>
|
|
94
|
+
arm64SupplementNeeded({
|
|
95
|
+
registry: 'ghcr.io',
|
|
96
|
+
repository: 'xema-dev/app-forge-api',
|
|
97
|
+
contentHash: HASH,
|
|
98
|
+
user: 'x',
|
|
99
|
+
token: 'y',
|
|
100
|
+
fetchImpl: registry(options).fetchImpl,
|
|
101
|
+
sleep: noSleep,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a complete index with a PROVEN arm64 child needs no supplement', async () => {
|
|
105
|
+
const { needed, reason } = await probe();
|
|
106
|
+
assert.equal(needed, false);
|
|
107
|
+
assert.match(reason, /already carries a proven linux\/arm64 child/u);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('an index whose arm64 child is MISLABELLED still needs a supplement', async () => {
|
|
111
|
+
// The descriptor says linux/arm64; the image config says amd64. Reading the
|
|
112
|
+
// descriptor alone would skip the rebuild and make a broken index permanent,
|
|
113
|
+
// because this lane is the only thing that can repair one.
|
|
114
|
+
const { needed, reason } = await probe({ configArchitecture: 'amd64' });
|
|
115
|
+
assert.equal(needed, true);
|
|
116
|
+
assert.match(reason, /none proved out against its own config/u);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('an index with amd64 only needs a supplement', async () => {
|
|
120
|
+
const { needed, reason } = await probe({
|
|
121
|
+
manifest: {
|
|
122
|
+
mediaType: 'application/vnd.oci.image.index.v1+json',
|
|
123
|
+
manifests: [
|
|
124
|
+
{ digest: AMD_CHILD, platform: { os: 'linux', architecture: 'amd64' } },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
assert.equal(needed, true);
|
|
129
|
+
assert.match(reason, /declares no linux\/arm64 child/u);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an index carrying ONLY attestations needs a supplement', async () => {
|
|
133
|
+
const { needed } = await probe({
|
|
134
|
+
manifest: {
|
|
135
|
+
mediaType: 'application/vnd.oci.image.index.v1+json',
|
|
136
|
+
manifests: [
|
|
137
|
+
{
|
|
138
|
+
digest: `sha256:${'9'.repeat(64)}`,
|
|
139
|
+
platform: { os: 'unknown', architecture: 'unknown' },
|
|
140
|
+
annotations: { 'vnd.docker.reference.type': 'attestation-manifest' },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
assert.equal(needed, true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a single-platform manifest needs a supplement', async () => {
|
|
149
|
+
const { needed, reason } = await probe({
|
|
150
|
+
manifest: {
|
|
151
|
+
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
|
152
|
+
config: { digest: AMD_CONFIG },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
assert.equal(needed, true);
|
|
156
|
+
assert.match(reason, /single manifest/u);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('an ABSENT coordinate needs a supplement', async () => {
|
|
160
|
+
const { needed, reason } = await probe({ manifestStatus: 404 });
|
|
161
|
+
assert.equal(needed, true);
|
|
162
|
+
assert.match(reason, /does not exist/u);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a 403 THROWS rather than answering', async () => {
|
|
166
|
+
// A 403 on a private package is indistinguishable from an image that was
|
|
167
|
+
// never pushed. Answering either way is a guess; one moves a sealed tag and
|
|
168
|
+
// the other strands a coordinate amd64-only.
|
|
169
|
+
await assert.rejects(probe({ manifestStatus: 403 }), /returned HTTP 403/u);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('a 500 THROWS rather than answering', async () => {
|
|
173
|
+
await assert.rejects(probe({ manifestStatus: 500 }), /returned HTTP 500/u);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('an index referencing an UNRESOLVABLE child THROWS', async () => {
|
|
177
|
+
await assert.rejects(
|
|
178
|
+
probe({ childStatus: 404 }),
|
|
179
|
+
/does not serve/u,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a non-sha256 content hash THROWS before any request', async () => {
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
arm64SupplementNeeded({
|
|
186
|
+
registry: 'ghcr.io',
|
|
187
|
+
repository: 'xema-dev/app-forge-api',
|
|
188
|
+
contentHash: 'not-a-hash',
|
|
189
|
+
user: 'x',
|
|
190
|
+
token: 'y',
|
|
191
|
+
fetchImpl: async () => assert.fail('no request may be made'),
|
|
192
|
+
}),
|
|
193
|
+
/not a 64-character lowercase content hash/u,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('credentials never leave the configured registry host', async () => {
|
|
198
|
+
await assert.rejects(
|
|
199
|
+
arm64SupplementNeeded({
|
|
200
|
+
registry: 'evil.example.com',
|
|
201
|
+
repository: 'xema-dev/app-forge-api',
|
|
202
|
+
contentHash: HASH,
|
|
203
|
+
user: 'x',
|
|
204
|
+
token: 'y',
|
|
205
|
+
fetchImpl: async () => assert.fail('no request may be made'),
|
|
206
|
+
}),
|
|
207
|
+
/cannot send credentials to registry/u,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('declaredArm64Children selects only linux/arm64 descriptors', () => {
|
|
212
|
+
assert.deepEqual(declaredArm64Children(INDEX_BOTH), [ARM_CHILD]);
|
|
213
|
+
assert.deepEqual(declaredArm64Children({ mediaType: 'x' }), []);
|
|
214
|
+
assert.deepEqual(declaredArm64Children(undefined), []);
|
|
215
|
+
// A descriptor with the right architecture but the wrong OS is not a
|
|
216
|
+
// supplement for this lane.
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
declaredArm64Children({
|
|
219
|
+
manifests: [
|
|
220
|
+
{ digest: ARM_CHILD, platform: { os: 'windows', architecture: 'arm64' } },
|
|
221
|
+
],
|
|
222
|
+
}),
|
|
223
|
+
[],
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('HTTP 404 on the manifest request makes exactly 1 manifest call', async () => {
|
|
228
|
+
const { fetchImpl, seen } = registry({ manifestStatus: 404 });
|
|
229
|
+
const { needed } = await arm64SupplementNeeded({
|
|
230
|
+
registry: 'ghcr.io',
|
|
231
|
+
repository: 'xema-dev/app-forge-api',
|
|
232
|
+
contentHash: HASH,
|
|
233
|
+
user: 'x',
|
|
234
|
+
token: 'y',
|
|
235
|
+
fetchImpl,
|
|
236
|
+
sleep: noSleep,
|
|
237
|
+
});
|
|
238
|
+
assert.equal(needed, true);
|
|
239
|
+
assert.equal(seen.filter((href) => href.endsWith(`/manifests/${HASH}`)).length, 1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('HTTP 403 on the manifest request makes 1 manifest call', async () => {
|
|
243
|
+
const { fetchImpl, seen } = registry({ manifestStatus: 403 });
|
|
244
|
+
await assert.rejects(
|
|
245
|
+
arm64SupplementNeeded({
|
|
246
|
+
registry: 'ghcr.io',
|
|
247
|
+
repository: 'xema-dev/app-forge-api',
|
|
248
|
+
contentHash: HASH,
|
|
249
|
+
user: 'x',
|
|
250
|
+
token: 'y',
|
|
251
|
+
fetchImpl,
|
|
252
|
+
sleep: noSleep,
|
|
253
|
+
}),
|
|
254
|
+
/HTTP 403/u,
|
|
255
|
+
);
|
|
256
|
+
assert.equal(seen.filter((href) => href.endsWith(`/manifests/${HASH}`)).length, 1);
|
|
257
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The predicate main attests when it PUBLISHES a develop digest instead of
|
|
2
|
+
// building one: it states a promotion, never a build. Every value is a fact
|
|
3
|
+
// `xema-promote-image` proved before the copy (promote-image-provenance.mjs).
|
|
4
|
+
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { IMAGE_PROMOTION_PREDICATE_TYPE, SLSA_PROVENANCE_V1 } from './promote-image-provenance.mjs';
|
|
7
|
+
|
|
8
|
+
export { IMAGE_PROMOTION_PREDICATE_TYPE };
|
|
9
|
+
|
|
10
|
+
export function promotionPredicate({ releaseRef, tag, digest, source, revision, commit, provenanceDigest, signerPattern }) {
|
|
11
|
+
return {
|
|
12
|
+
release: { repository: releaseRef, tag, digest },
|
|
13
|
+
source: { reference: source, revision },
|
|
14
|
+
promotedCommit: commit,
|
|
15
|
+
developProvenance: { predicateType: SLSA_PROVENANCE_V1, payloadDigest: provenanceDigest, signerIdentityPattern: signerPattern },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The predicate of a REUSE: this run's release and commit, with `source` and `developProvenance` carried from main's earlier promotion. */
|
|
20
|
+
export function reusedPromotionPredicate({ releaseRef, tag, digest, commit, source, developProvenance }) {
|
|
21
|
+
return { release: { repository: releaseRef, tag, digest }, source, promotedCommit: commit, developProvenance };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Writes the predicate to a path that must not exist yet, so a stale file is never attested. */
|
|
25
|
+
export function writePromotionPredicate(path, predicate) {
|
|
26
|
+
writeFileSync(path, `${JSON.stringify(predicate, null, 2)}\n`, { flag: 'wx' });
|
|
27
|
+
}
|