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