@xemahq/xema 0.45.0 → 0.46.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/dist/commands/biome.d.ts.map +1 -1
- package/dist/commands/biome.js +3 -0
- package/dist/commands/biome.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +1613 -846
- package/dist/edition/verify-image-evidence.bundle.provenance.json +516 -474
- package/dist/runtime/biome-publish-image-layers.d.ts +20 -0
- package/dist/runtime/biome-publish-image-layers.d.ts.map +1 -0
- package/dist/runtime/biome-publish-image-layers.js +64 -0
- package/dist/runtime/biome-publish-image-layers.js.map +1 -0
- package/dist/runtime/biome-publish.d.ts +4 -0
- package/dist/runtime/biome-publish.d.ts.map +1 -1
- package/dist/runtime/biome-publish.js +25 -5
- package/dist/runtime/biome-publish.js.map +1 -1
- package/dist/runtime/config/schema.d.ts +4 -3
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/config/schema.js +8 -2
- package/dist/runtime/config/schema.js.map +1 -1
- package/dist/runtime/infra/infra-catalog.js +1 -1
- package/dist/runtime/installation/contract.d.ts +2 -2
- package/dist/runtime/installation/contract.d.ts.map +1 -1
- package/dist/runtime/installation/contract.js +1 -1
- package/dist/runtime/installation/contract.js.map +1 -1
- package/edition/README.md +2 -1
- package/edition/assemble-producer-input.mjs +2 -0
- package/edition/biome-content.mjs +6 -0
- package/edition/build-platform-service-index.mjs +2 -0
- package/edition/canonical-repository-url.mjs +12 -0
- package/edition/cosign-certificate-constraints.mjs +40 -0
- package/edition/customer-edition-verbs.mjs +6 -0
- package/edition/develop-coverage-readers.mjs +9 -4
- package/edition/develop-coverage-run.mjs +2 -1
- package/edition/develop-edition.mjs +20 -0
- package/edition/develop-record-ancestry.mjs +121 -0
- package/edition/develop-select-records.mjs +365 -0
- package/edition/distribution-evidence-set.mjs +15 -0
- package/edition/edition-config.mjs +27 -1
- package/edition/edition-protocol.mjs +8 -1
- package/edition/image-evidence.mjs +2 -12
- package/edition/kernel-signer-kind.mjs +23 -0
- package/edition/lib.mjs +104 -2
- package/edition/oci-evidence-verbs.mjs +7 -1
- package/edition/oci-image-source-evidence.mjs +129 -21
- package/edition/operation-grant-admission.mjs +98 -4
- package/edition/operation-requirements.mjs +118 -4
- package/edition/plan-image-evidence-refresh.mjs +21 -4
- package/edition/platform-service-class.mjs +23 -0
- package/edition/platform-service-ownership.mjs +23 -13
- package/edition/produce-distribution-completeness-evidence.mjs +2 -0
- package/edition/produce-image-source-evidence.mjs +9 -11
- package/edition/producer-evidence-bundle.mjs +43 -1
- package/edition/record-refused.mjs +11 -0
- package/edition/resolve-distribution.mjs +43 -1
- package/package.json +3 -3
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
comparablePlatformService,
|
|
16
16
|
declaredInstallationClass,
|
|
17
|
+
sourceDeclaredRequirements,
|
|
17
18
|
sourceOwnedCapabilityDomains,
|
|
18
19
|
} from './platform-service-class.mjs';
|
|
19
20
|
import {
|
|
@@ -144,6 +145,7 @@ function buildTrustedIndex(sourceAuthority) {
|
|
|
144
145
|
tier: declaration.tier,
|
|
145
146
|
...declaredInstallationClass(declaration),
|
|
146
147
|
...sourceOwnedCapabilityDomains(sourceManifest, `${binding.path} at ${before}`),
|
|
148
|
+
...sourceDeclaredRequirements(sourceManifest, `${binding.path} at ${before}`),
|
|
147
149
|
repositoryUrl,
|
|
148
150
|
repo: binding.repo,
|
|
149
151
|
path: binding.path,
|
|
@@ -33,3 +33,15 @@ export function canonicalPublicHttpsRepositoryUrl(raw, context) {
|
|
|
33
33
|
}
|
|
34
34
|
return parsed.toString();
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The `owner/name` of a canonical public HTTPS repository URL (`https://host/owner/name[.git]`): exactly two
|
|
39
|
+
* path segments, the optional `.git` suffix dropped. Anything else is refused, naming `context`.
|
|
40
|
+
*/
|
|
41
|
+
export function repositoryOwnerName(canonicalUrl, context) {
|
|
42
|
+
const segments = new URL(canonicalUrl).pathname.replace(/\.git$/, '').split('/').filter((segment) => segment.length > 0);
|
|
43
|
+
if (segments.length !== 2) {
|
|
44
|
+
throw new Error(`${context} must name exactly <owner>/<name> on its host; got ${JSON.stringify(canonicalUrl)}`);
|
|
45
|
+
}
|
|
46
|
+
return segments.join('/');
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// cosign-certificate-constraints — the certificate claims every keyless verification here pins.
|
|
2
|
+
//
|
|
3
|
+
// One signer shape, `{certificateIdentity, oidcIssuer, githubWorkflow: {repository, ref}}`, plus the exact
|
|
4
|
+
// workflow commit the certificate must carry. Every `cosign verify*` of this CLI takes its constraints
|
|
5
|
+
// from here, so no call site can leave one of the five out.
|
|
6
|
+
|
|
7
|
+
const WORKFLOW_SHA = /^[0-9a-f]{40}$/;
|
|
8
|
+
|
|
9
|
+
function requireText(value, what) {
|
|
10
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
11
|
+
throw new Error(`certificate constraints: ${what} must be a non-empty string; got ${JSON.stringify(value)}`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
|
|
18
|
+
* @param {string} workflowSha the 40-hex commit the signing workflow ran at
|
|
19
|
+
* @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
|
|
20
|
+
*/
|
|
21
|
+
export function certificateConstraintArgs(signer, workflowSha) {
|
|
22
|
+
if (signer === null || typeof signer !== 'object') {
|
|
23
|
+
throw new Error('certificate constraints: the signer must be an object');
|
|
24
|
+
}
|
|
25
|
+
if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
|
|
26
|
+
throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
|
|
27
|
+
}
|
|
28
|
+
return [
|
|
29
|
+
'--certificate-identity',
|
|
30
|
+
requireText(signer.certificateIdentity, 'certificateIdentity'),
|
|
31
|
+
'--certificate-oidc-issuer',
|
|
32
|
+
requireText(signer.oidcIssuer, 'oidcIssuer'),
|
|
33
|
+
'--certificate-github-workflow-repository',
|
|
34
|
+
requireText(signer.githubWorkflow?.repository, 'githubWorkflow.repository'),
|
|
35
|
+
'--certificate-github-workflow-ref',
|
|
36
|
+
requireText(signer.githubWorkflow?.ref, 'githubWorkflow.ref'),
|
|
37
|
+
'--certificate-github-workflow-sha',
|
|
38
|
+
workflowSha,
|
|
39
|
+
];
|
|
40
|
+
}
|
|
@@ -213,6 +213,12 @@ const COMMANDS = Object.freeze({
|
|
|
213
213
|
switches: ['--json'],
|
|
214
214
|
required: ['--pins', '--cosign'],
|
|
215
215
|
},
|
|
216
|
+
'select-records': {
|
|
217
|
+
positional: false,
|
|
218
|
+
values: ['--branch', '--registry', '--cosign', '--out', '--at', '--published-pins'],
|
|
219
|
+
switches: [],
|
|
220
|
+
required: ['--branch', '--registry', '--cosign', '--out'],
|
|
221
|
+
},
|
|
216
222
|
'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
|
|
217
223
|
'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
|
|
218
224
|
'check-existing': {
|
|
@@ -12,7 +12,7 @@ import { promisify } from 'node:util';
|
|
|
12
12
|
|
|
13
13
|
import { AttestationStatus, DEVELOP_EVIDENCE_CHANNEL } from './develop-coverage-verdict.mjs';
|
|
14
14
|
import { COSIGN_ENV, policyCandidates, sha256Digest, validatePinnedTrustedRoot, verifySigstoreEvidenceDescriptor } from './image-evidence.mjs';
|
|
15
|
-
import { resolveImageSourceEvidenceReferrer } from './oci-image-source-evidence.mjs';
|
|
15
|
+
import { resolveImageSourceEvidenceReferrer, writeInvalidReferrerReport } from './oci-image-source-evidence.mjs';
|
|
16
16
|
import { BUNDLE_MEDIA_TYPE } from './produce-image-source-evidence.mjs';
|
|
17
17
|
import { loadRegistryCredentialDeclarations, resolveRegistryCredential, splitImageRef } from './registry-credentials.mjs';
|
|
18
18
|
import { isNotPublishedYet } from './seal-image-digests.mjs';
|
|
@@ -59,7 +59,8 @@ export function parseCosignVerifyOutput(stdout, digest) {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/** Run cosign once; resolves `{code, stdout, stderr}` for a cosign exit, throws when cosign could not run at all. */
|
|
63
|
+
export function defaultRunCosign(cosignPath, args, env) {
|
|
63
64
|
return execFileAsync(cosignPath, args, { env, timeout: COSIGN_CALL_TIMEOUT_MS, maxBuffer: COSIGN_MAX_OUTPUT_BYTES }).then(
|
|
64
65
|
({ stdout }) => ({ code: 0, stdout, stderr: '' }),
|
|
65
66
|
(error) => {
|
|
@@ -69,7 +70,8 @@ function defaultRunCosign(cosignPath, args, env) {
|
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
/** The username and password of a resolved basic-auth registry credential. */
|
|
74
|
+
export function basicAuthParts(credential) {
|
|
73
75
|
const decoded = Buffer.from(credential.basicAuth, 'base64').toString('utf8');
|
|
74
76
|
const colon = decoded.indexOf(':');
|
|
75
77
|
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
|
@@ -81,13 +83,14 @@ function basicAuthParts(credential) {
|
|
|
81
83
|
* @param {string} request.policyRoot the directory the policy's trusted root is read from (the data root)
|
|
82
84
|
* @param {object} request.trustedCosign the digest-pinned cosign handle (prepareTrustedCosignBinary)
|
|
83
85
|
* @param {object} request.schemas kernel distribution schemas
|
|
86
|
+
* @param {object} request.expectedContractAuthority the kernel contract authority the data root pins
|
|
84
87
|
* @param {string} request.dataRoot declares how each registry is read
|
|
85
88
|
* @param {object} request.env environment the registry credentials are named in
|
|
86
89
|
* @param {Function} [request.runCosign] (path, args, env) => { code, stdout, stderr }; replaced by tests
|
|
87
90
|
* @param {Function} [request.fetchImpl] @param {Function} [request.run] oras runner, replaced by tests
|
|
88
91
|
* @param {Function} [request.clientFor] (authority) => registry client, replaced by tests
|
|
89
92
|
*/
|
|
90
|
-
export function createCoverageReaders({ policy, policyRoot, trustedCosign, schemas, dataRoot, env, runCosign = defaultRunCosign, fetchImpl, run, clientFor: injectedClientFor }) {
|
|
93
|
+
export function createCoverageReaders({ policy, policyRoot, trustedCosign, schemas, expectedContractAuthority, dataRoot, env, runCosign = defaultRunCosign, fetchImpl, run, clientFor: injectedClientFor }) {
|
|
91
94
|
const cosignPath = assertPreparedExecutable(trustedCosign, 'cosign');
|
|
92
95
|
const declarations = injectedClientFor === undefined ? loadRegistryCredentialDeclarations(dataRoot) : null;
|
|
93
96
|
const scratch = mkdtempSync(join(tmpdir(), 'xema-coverage-'));
|
|
@@ -141,6 +144,8 @@ export function createCoverageReaders({ policy, policyRoot, trustedCosign, schem
|
|
|
141
144
|
imageRepository: subject.imageRef,
|
|
142
145
|
subjectDigest: digest,
|
|
143
146
|
schemas,
|
|
147
|
+
expectedContractAuthority,
|
|
148
|
+
onInvalidReferrer: writeInvalidReferrerReport,
|
|
144
149
|
});
|
|
145
150
|
if (evidence === null) {return { status: AttestationStatus.None };}
|
|
146
151
|
if (rows.length === 0) {return { status: AttestationStatus.NoRow };}
|
|
@@ -8,6 +8,7 @@ import { collectCoverage, coverageExitCode, renderCoverageTable } from './develo
|
|
|
8
8
|
import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition-resolve.mjs';
|
|
9
9
|
import { loadDeclaredPinnedPolicy } from './evidence-trust-policy.mjs';
|
|
10
10
|
import { prepareTrustedCosignBinary } from './image-evidence.mjs';
|
|
11
|
+
import { readPinnedContractAuthority } from './produce-image-source-evidence.mjs';
|
|
11
12
|
|
|
12
13
|
const stderrProgress = (line) => process.stderr.write(`${line}\n`);
|
|
13
14
|
|
|
@@ -34,7 +35,7 @@ export async function runDevelopCoverage({
|
|
|
34
35
|
const { schemas, inputs, lock } = await resolveDevelopSnapshot({ id, pins, steps, phase: (name) => progress(`[coverage] ${name}`) });
|
|
35
36
|
const { imageLock } = await steps.resolveImages(id, { inputs, lock });
|
|
36
37
|
progress(`[coverage] image content hashes computed (${imageLock.subjects.length} subjects)`);
|
|
37
|
-
const readers = createCoverageReaders({ policy, policyRoot: dataRoot, trustedCosign, schemas, dataRoot, env, ...readerOptions });
|
|
38
|
+
const readers = createCoverageReaders({ policy, policyRoot: dataRoot, trustedCosign, schemas, expectedContractAuthority: readPinnedContractAuthority({ dataRoot, schemas }), dataRoot, env, ...readerOptions });
|
|
38
39
|
try {
|
|
39
40
|
const report = await collectCoverage({ distributionId: id, subjects: imageLock.subjects, policy, readers, progress });
|
|
40
41
|
write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageTable(report));
|
|
@@ -45,6 +45,22 @@
|
|
|
45
45
|
// annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
|
|
46
46
|
// and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
|
|
47
47
|
// not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
|
|
48
|
+
// select-records --branch develop --registry <root> --cosign <absolute-path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>]
|
|
49
|
+
// for every develop-graph member, take the newest build record the data root's record workflow signed for
|
|
50
|
+
// the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
|
|
51
|
+
// signed by <data root>/.github/workflows/record-build.yaml at refs/heads/<branch>, called by the member at the
|
|
52
|
+
// record's own commit) and write { pins, snapshot, images }, once (wx). Any missing, unsigned, mismatched or
|
|
53
|
+
// unreadable record refuses the whole run and writes nothing: a newer bad record is never skipped for an older
|
|
54
|
+
// one. --at takes the newest record completed at or before that time. Only `develop` has a declared signer; the
|
|
55
|
+
// data root must declare dataRootRepositoryUrl. Takes the roots `resolve` takes (no registry root is implied).
|
|
56
|
+
// "Newest" is the newest COMMIT on develop, not the highest run: EVERY record of the branch is read and cosign-verified,
|
|
57
|
+
// the selected commit must equal or descend from every other record's commit (git merge-base on develop fetched
|
|
58
|
+
// from the member), and among that commit's records the highest run and attempt wins. --published-pins takes a file
|
|
59
|
+
// this verb wrote (only .pins is read): a member's published commit is kept when the records are not ahead of it
|
|
60
|
+
// (its own record must exist), a diverged one is refused. Without the flag the rule applies among the records only;
|
|
61
|
+
// part 07 passes the flag.
|
|
62
|
+
// Residual: anyone who can delete registry tags can remove newer records, and this then pins the newest remaining
|
|
63
|
+
// valid one; the guard is part 07's, deploy-latest's owner refuses a snapshot that goes backwards.
|
|
48
64
|
// choose-kernel [--kernel-pin <40-hex>] --out <file>
|
|
49
65
|
// choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
|
|
50
66
|
// the newest first-parent commit whose declared edition protocol this CLI supports
|
|
@@ -74,6 +90,7 @@ import { runDevelopCoverage } from './develop-coverage-run.mjs';
|
|
|
74
90
|
import { CoverageExit } from './develop-coverage.mjs';
|
|
75
91
|
import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
|
|
76
92
|
import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
|
|
93
|
+
import { runSelectRecords } from './develop-select-records.mjs';
|
|
77
94
|
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
78
95
|
import { EditionChannel } from './edition-constants.mjs';
|
|
79
96
|
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
|
|
@@ -162,6 +179,9 @@ async function run(argv, env) {
|
|
|
162
179
|
json: options['--json'] === true,
|
|
163
180
|
env,
|
|
164
181
|
});
|
|
182
|
+
case 'select-records':
|
|
183
|
+
await runSelectRecords({ options, env });
|
|
184
|
+
return;
|
|
165
185
|
case 'check-existing': {
|
|
166
186
|
if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
|
|
167
187
|
assertExistingArtifactMatches({
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// develop-record-ancestry — which verified record commit is the newest COMMIT on develop.
|
|
2
|
+
//
|
|
3
|
+
// "Newest" is the commit that equals or descends from every other candidate's commit, never the highest run:
|
|
4
|
+
// a re-run of an old commit carries a higher run number and must lose. History comes from git alone: one bare
|
|
5
|
+
// scratch repository per member, fetched blobless from the member's own URL through the same hold and credential
|
|
6
|
+
// environment freeze uses (`withSourceGitEnv`; no token on argv, none beyond the workspace's). Candidates are
|
|
7
|
+
// compared with each other only, never with a published pin.
|
|
8
|
+
//
|
|
9
|
+
// With a published commit for the member, the records may not take it backwards: behind or equal keeps the published
|
|
10
|
+
// commit (its own record must then exist), ahead selects the records' commit, otherwise the run is refused.
|
|
11
|
+
//
|
|
12
|
+
// Refusals (`RecordRefused`): a commit absent from the fetched develop history, two commits that are not
|
|
13
|
+
// ancestor-related. Operational (plain Error): git cannot be spawned, a fetch fails, or merge-base answers
|
|
14
|
+
// anything but 0 (ancestor) or 1 (not an ancestor).
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { withSourceGitEnv } from './git-source-auth.mjs';
|
|
22
|
+
import { RecordRefused } from './record-refused.mjs';
|
|
23
|
+
|
|
24
|
+
const DEVELOP_REF = 'refs/heads/develop';
|
|
25
|
+
// One external call to a git remote; a fetch that exceeds it fails naming the member, never a verdict.
|
|
26
|
+
const GIT_CALL_TIMEOUT_MS = 300_000;
|
|
27
|
+
const GIT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
export class IncomparableRecordCommitsError extends RecordRefused {
|
|
30
|
+
constructor(repo, first, second) {
|
|
31
|
+
super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop`);
|
|
32
|
+
this.name = 'IncomparableRecordCommitsError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RecordCommitNotOnDevelopError extends RecordRefused {
|
|
37
|
+
constructor(repo, commit, tags) {
|
|
38
|
+
super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history`);
|
|
39
|
+
this.name = 'RecordCommitNotOnDevelopError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DivergedFromPublishedError extends RecordRefused {
|
|
44
|
+
constructor(repo, selected, published) {
|
|
45
|
+
super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop`);
|
|
46
|
+
this.name = 'DivergedFromPublishedError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The published commit is newer than every record, and none of its own survives among the candidates. */
|
|
51
|
+
export class PublishedRecordMissingError extends RecordRefused {
|
|
52
|
+
constructor(repo, published) {
|
|
53
|
+
super(repo, `no verified build record exists for the published commit ${published}; it is newer than every candidate record`);
|
|
54
|
+
this.name = 'PublishedRecordMissingError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `{ status, stdout, stderr }` of one git run; a git that could not be spawned or timed out throws. */
|
|
59
|
+
export function defaultRunGit(args, { cwd, env }) {
|
|
60
|
+
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: GIT_CALL_TIMEOUT_MS, maxBuffer: GIT_MAX_OUTPUT_BYTES });
|
|
61
|
+
if (result.error !== undefined || result.status === null) {
|
|
62
|
+
throw new Error(`git ${args[0]} could not run: ${result.error?.message ?? `killed by ${result.signal}`}`);
|
|
63
|
+
}
|
|
64
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} request
|
|
69
|
+
* @param {{repo: string, repositoryUrl: string}} request.member
|
|
70
|
+
* @param {{commit: string, tag: string}[]} request.candidates verified records' commits with their tags
|
|
71
|
+
* @param {string} [request.published] the commit already published for this member: kept when the records are not ahead of it
|
|
72
|
+
* @param {object} request.workspace the workspace config the source hold reads
|
|
73
|
+
* @param {object} request.env
|
|
74
|
+
* @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
|
|
75
|
+
* @returns {string} the selected commit
|
|
76
|
+
*/
|
|
77
|
+
export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
|
|
78
|
+
const byCommit = new Map();
|
|
79
|
+
for (const { commit, tag } of candidates) {
|
|
80
|
+
byCommit.set(commit, [...(byCommit.get(commit) ?? []), tag]);
|
|
81
|
+
}
|
|
82
|
+
const commits = [...byCommit].map(([commit, tags]) => ({ commit, tags }));
|
|
83
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-select-history-'));
|
|
84
|
+
try {
|
|
85
|
+
return withSourceGitEnv({ name: member.repo, url: member.repositoryUrl }, { workspace, env }, (gitEnv) => {
|
|
86
|
+
const git = (args, extraEnv = {}) => runGit(args, { cwd: scratch, env: { ...gitEnv, ...extraEnv } });
|
|
87
|
+
const init = git(['init', '--bare', '--quiet']);
|
|
88
|
+
if (init.status !== 0) {throw new Error(`select-records: ${member.repo}: git init failed: ${init.stderr.trim()}`);}
|
|
89
|
+
const fetched = git(['fetch', '--quiet', '--filter=blob:none', '--no-tags', member.repositoryUrl, `+${DEVELOP_REF}:${DEVELOP_REF}`]);
|
|
90
|
+
if (fetched.status !== 0) {
|
|
91
|
+
throw new Error(`select-records: ${member.repo}: git fetch of develop from ${member.repositoryUrl} failed (exit ${fetched.status}): ${fetched.stderr.trim()}`);
|
|
92
|
+
}
|
|
93
|
+
// 0 = ancestor (or equal), 1 = not; anything else is git failing, never a verdict
|
|
94
|
+
const isAncestor = (older, newer) => {
|
|
95
|
+
const result = git(['merge-base', '--is-ancestor', older, newer]);
|
|
96
|
+
if (result.status === 0) {return true;}
|
|
97
|
+
if (result.status === 1) {return false;}
|
|
98
|
+
throw new Error(`select-records: ${member.repo}: git merge-base ${older} ${newer} failed (exit ${result.status}): ${result.stderr.trim()}`);
|
|
99
|
+
};
|
|
100
|
+
const assertOnDevelop = (commit, tags) => {
|
|
101
|
+
// no lazy fetch: a commit the develop fetch did not bring is not on develop
|
|
102
|
+
const known = git(['cat-file', '-e', `${commit}^{commit}`], { GIT_NO_LAZY_FETCH: '1' });
|
|
103
|
+
if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags);}
|
|
104
|
+
};
|
|
105
|
+
for (const { commit, tags } of commits) {assertOnDevelop(commit, tags);}
|
|
106
|
+
let selected = commits[0];
|
|
107
|
+
for (const other of commits.slice(1)) {
|
|
108
|
+
if (isAncestor(other.commit, selected.commit)) {continue;}
|
|
109
|
+
if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
|
|
110
|
+
selected = other;
|
|
111
|
+
}
|
|
112
|
+
if (published === undefined) {return selected.commit;}
|
|
113
|
+
assertOnDevelop(published, ['published pin']);
|
|
114
|
+
if (isAncestor(selected.commit, published)) {return published;} // equal, or the records are behind it
|
|
115
|
+
if (isAncestor(published, selected.commit)) {return selected.commit;}
|
|
116
|
+
throw new DivergedFromPublishedError(member.repo, selected.commit, published);
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|