@git.zone/tsrust 1.7.0 → 1.9.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.
Files changed (44) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/index.d.ts +2 -0
  3. package/dist_ts/index.js +3 -1
  4. package/dist_ts/mod_artifact/classes.artifactassembler.d.ts +48 -0
  5. package/dist_ts/mod_artifact/classes.artifactassembler.js +842 -0
  6. package/dist_ts/mod_artifact/index.d.ts +1 -0
  7. package/dist_ts/mod_artifact/index.js +2 -0
  8. package/dist_ts/mod_cargo/classes.cargorunner.d.ts +10 -8
  9. package/dist_ts/mod_cargo/classes.cargorunner.js +4 -3
  10. package/dist_ts/mod_cargo/index.d.ts +1 -1
  11. package/dist_ts/mod_cargo/index.js +2 -2
  12. package/dist_ts/mod_cli/classes.tsrustcli.d.ts +5 -2
  13. package/dist_ts/mod_cli/classes.tsrustcli.js +112 -75
  14. package/dist_ts/mod_cli/helpers.targets.d.ts +20 -0
  15. package/dist_ts/mod_cli/helpers.targets.js +106 -0
  16. package/dist_ts/mod_cli/index.d.ts +1 -0
  17. package/dist_ts/mod_cli/index.js +2 -1
  18. package/dist_ts/mod_elf/classes.provenance.d.ts +2 -1
  19. package/dist_ts/mod_elf/classes.provenance.js +0 -0
  20. package/dist_ts/mod_provenance/classes.gitstate.d.ts +7 -0
  21. package/dist_ts/mod_provenance/classes.gitstate.js +34 -0
  22. package/dist_ts/mod_provenance/classes.provenancestore.d.ts +11 -0
  23. package/dist_ts/mod_provenance/classes.provenancestore.js +184 -0
  24. package/dist_ts/mod_provenance/helpers.owneridentity.d.ts +2 -0
  25. package/dist_ts/mod_provenance/helpers.owneridentity.js +57 -0
  26. package/dist_ts/mod_provenance/index.d.ts +3 -0
  27. package/dist_ts/mod_provenance/index.js +4 -0
  28. package/package.json +7 -7
  29. package/readme.hints.md +6 -3
  30. package/readme.md +80 -5
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/index.ts +2 -0
  33. package/ts/mod_artifact/classes.artifactassembler.ts +1028 -0
  34. package/ts/mod_artifact/index.ts +6 -0
  35. package/ts/mod_cargo/classes.cargorunner.ts +14 -3
  36. package/ts/mod_cargo/index.ts +1 -1
  37. package/ts/mod_cli/classes.tsrustcli.ts +164 -94
  38. package/ts/mod_cli/helpers.targets.ts +141 -0
  39. package/ts/mod_cli/index.ts +10 -0
  40. package/ts/mod_elf/classes.provenance.ts +0 -0
  41. package/ts/mod_provenance/classes.gitstate.ts +49 -0
  42. package/ts/mod_provenance/classes.provenancestore.ts +194 -0
  43. package/ts/mod_provenance/helpers.owneridentity.ts +57 -0
  44. package/ts/mod_provenance/index.ts +10 -0
@@ -0,0 +1,1028 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import type { ITsrustBuildInfo } from '../mod_elf/index.js';
6
+ import {
7
+ captureGitSnapshot,
8
+ getHostIdentity,
9
+ isLocalProcessRunning,
10
+ PROVENANCE_SIDECAR_SUFFIX,
11
+ ProvenanceStore,
12
+ } from '../mod_provenance/index.js';
13
+
14
+ const TRANSACTION_FORMAT = 'tsrust.artifact-assembly.v1';
15
+ const LOCK_FORMAT = 'tsrust.artifact-assembly-lock.v1';
16
+ const RECOVERY_CLAIM_FORMAT = 'tsrust.artifact-assembly-recovery.v1';
17
+ const TRANSACTION_DIRECTORY = 'tsrust-assembly';
18
+ const activeOwnerTokens = new Set<string>();
19
+ const processOwnerTokens = new Set<string>();
20
+
21
+ type TAssemblyPhase =
22
+ | 'preparing'
23
+ | 'prepared'
24
+ | 'movingOld'
25
+ | 'oldMoved'
26
+ | 'publishing'
27
+ | 'published'
28
+ | 'committing'
29
+ | 'committed';
30
+
31
+ export interface IArtifactTarget {
32
+ triple: string;
33
+ friendly: string;
34
+ }
35
+
36
+ export interface IArtifactAssemblerOptions {
37
+ workspace: string;
38
+ sourceDirectories: string[];
39
+ expectedTargets: IArtifactTarget[];
40
+ expectedBinaries: string[];
41
+ expectedProjectName: string;
42
+ expectedProjectVersion: string;
43
+ expectedGitCommit: string;
44
+ expectedTsrustVersion: string;
45
+ }
46
+
47
+ export interface IArtifactAssemblyResult {
48
+ outputDirectory: string;
49
+ artifactCount: number;
50
+ binaries: string[];
51
+ targets: string[];
52
+ cleanupPending: boolean;
53
+ }
54
+
55
+ interface IArtifactCandidate {
56
+ binaryPath: string;
57
+ sidecarPath: string;
58
+ destinationName: string;
59
+ buildInfo: ITsrustBuildInfo;
60
+ }
61
+
62
+ interface IAssemblyPaths {
63
+ workspace: string;
64
+ outputDirectory: string;
65
+ transactionParent: string;
66
+ transactionDirectory: string;
67
+ lockPath: string;
68
+ lockSourcePath: string;
69
+ recoveryClaimPath: string;
70
+ statePath: string;
71
+ stateTemporaryPath: string;
72
+ stagingDirectory: string;
73
+ backupDirectory: string;
74
+ failedDirectory: string;
75
+ }
76
+
77
+ interface IAssemblyTransactionState {
78
+ format: typeof TRANSACTION_FORMAT;
79
+ pid: number;
80
+ hostname: string;
81
+ hostIdentity: string;
82
+ ownerToken: string;
83
+ startedAt: string;
84
+ phase: TAssemblyPhase;
85
+ hadExistingOutput: boolean;
86
+ }
87
+
88
+ interface IAssemblyOwnerIdentity {
89
+ pid: number;
90
+ hostname: string;
91
+ hostIdentity: string;
92
+ ownerToken: string;
93
+ startedAt: string;
94
+ }
95
+
96
+ interface IAssemblyLockOwner extends IAssemblyOwnerIdentity {
97
+ format: typeof LOCK_FORMAT;
98
+ }
99
+
100
+ interface IAssemblyRecoveryClaim extends IAssemblyOwnerIdentity {
101
+ format: typeof RECOVERY_CLAIM_FORMAT;
102
+ targetOwnerToken: string;
103
+ }
104
+
105
+ function isSameOrWithin(parentArg: string, candidateArg: string): boolean {
106
+ const relative = path.relative(parentArg, candidateArg);
107
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
108
+ }
109
+
110
+ async function lstatOptional(filePathArg: string): Promise<fs.Stats | undefined> {
111
+ try {
112
+ return await fs.promises.lstat(filePathArg);
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ async function syncFile(filePathArg: string): Promise<void> {
120
+ const handle = await fs.promises.open(filePathArg, 'r');
121
+ try {
122
+ await handle.sync();
123
+ } finally {
124
+ await handle.close();
125
+ }
126
+ }
127
+
128
+ async function syncDirectory(directoryPathArg: string): Promise<void> {
129
+ const handle = await fs.promises.open(directoryPathArg, 'r');
130
+ try {
131
+ await handle.sync();
132
+ } finally {
133
+ await handle.close();
134
+ }
135
+ }
136
+
137
+ function assertArtifactName(nameArg: string, labelArg: string): void {
138
+ if (!nameArg || nameArg.length > 255 || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(nameArg)) {
139
+ throw new Error(`Invalid ${labelArg}: ${JSON.stringify(nameArg)}`);
140
+ }
141
+ }
142
+
143
+ function isProvenanceTemporaryFile(nameArg: string): boolean {
144
+ return /\.tsrust-build\.json\.[a-f0-9]{64}\.\d+\.[a-f0-9-]{36}\.tmp$/.test(nameArg);
145
+ }
146
+
147
+ function isTransactionState(valueArg: unknown): valueArg is IAssemblyTransactionState {
148
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
149
+ const value = valueArg as Record<string, unknown>;
150
+ return (
151
+ value.format === TRANSACTION_FORMAT &&
152
+ typeof value.pid === 'number' &&
153
+ typeof value.hostname === 'string' &&
154
+ typeof value.hostIdentity === 'string' &&
155
+ /^[a-f0-9]{64}$/.test(value.hostIdentity) &&
156
+ typeof value.ownerToken === 'string' &&
157
+ /^[a-f0-9-]{36}$/.test(value.ownerToken) &&
158
+ typeof value.startedAt === 'string' &&
159
+ [
160
+ 'preparing',
161
+ 'prepared',
162
+ 'movingOld',
163
+ 'oldMoved',
164
+ 'publishing',
165
+ 'published',
166
+ 'committing',
167
+ 'committed',
168
+ ].includes(String(value.phase)) &&
169
+ typeof value.hadExistingOutput === 'boolean'
170
+ );
171
+ }
172
+
173
+ function isLockOwner(valueArg: unknown): valueArg is IAssemblyLockOwner {
174
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
175
+ const value = valueArg as Record<string, unknown>;
176
+ return (
177
+ value.format === LOCK_FORMAT &&
178
+ typeof value.pid === 'number' &&
179
+ typeof value.hostname === 'string' &&
180
+ typeof value.hostIdentity === 'string' &&
181
+ /^[a-f0-9]{64}$/.test(value.hostIdentity) &&
182
+ typeof value.ownerToken === 'string' &&
183
+ /^[a-f0-9-]{36}$/.test(value.ownerToken) &&
184
+ typeof value.startedAt === 'string'
185
+ );
186
+ }
187
+
188
+ function isRecoveryClaim(valueArg: unknown): valueArg is IAssemblyRecoveryClaim {
189
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return false;
190
+ const value = valueArg as Record<string, unknown>;
191
+ return (
192
+ value.format === RECOVERY_CLAIM_FORMAT &&
193
+ typeof value.pid === 'number' &&
194
+ typeof value.hostname === 'string' &&
195
+ typeof value.hostIdentity === 'string' &&
196
+ /^[a-f0-9]{64}$/.test(value.hostIdentity) &&
197
+ typeof value.ownerToken === 'string' &&
198
+ /^[a-f0-9-]{36}$/.test(value.ownerToken) &&
199
+ typeof value.startedAt === 'string' &&
200
+ typeof value.targetOwnerToken === 'string' &&
201
+ /^[a-f0-9-]{36}$/.test(value.targetOwnerToken)
202
+ );
203
+ }
204
+
205
+ export class ArtifactAssembler {
206
+ private options: IArtifactAssemblerOptions;
207
+
208
+ constructor(optionsArg: IArtifactAssemblerOptions) {
209
+ this.options = optionsArg;
210
+ }
211
+
212
+ public async assemble(): Promise<IArtifactAssemblyResult> {
213
+ const ownerToken = crypto.randomUUID();
214
+ const paths = this.createPaths(ownerToken);
215
+ const sourceDirectories = this.options.sourceDirectories.map((sourceDirectory) =>
216
+ path.resolve(paths.workspace, sourceDirectory),
217
+ );
218
+ this.validateOptions(paths, sourceDirectories);
219
+
220
+ let state: IAssemblyTransactionState = {
221
+ format: TRANSACTION_FORMAT,
222
+ pid: process.pid,
223
+ hostname: os.hostname(),
224
+ hostIdentity: getHostIdentity(),
225
+ ownerToken,
226
+ startedAt: new Date().toISOString(),
227
+ phase: 'preparing',
228
+ hadExistingOutput: false,
229
+ };
230
+ await this.ensureTransactionParent(paths);
231
+ processOwnerTokens.add(ownerToken);
232
+ try {
233
+ await this.acquireTransaction(paths, state);
234
+ } catch (error) {
235
+ try {
236
+ const lockOwner = await this.readLock(paths);
237
+ if (lockOwner?.ownerToken !== ownerToken) processOwnerTokens.delete(ownerToken);
238
+ } catch {
239
+ // Retain the token only when lock ownership cannot be determined safely.
240
+ }
241
+ throw error;
242
+ }
243
+ let commitReached = false;
244
+ let retainOwnerToken = false;
245
+ activeOwnerTokens.add(ownerToken);
246
+ try {
247
+ const output = await lstatOptional(paths.outputDirectory);
248
+ if (output && (!output.isDirectory() || output.isSymbolicLink())) {
249
+ throw new Error(`Assembly output is not a regular directory: ${paths.outputDirectory}`);
250
+ }
251
+ state.hadExistingOutput = !!output;
252
+ await this.writeState(paths, state);
253
+
254
+ const candidates = await this.collectCandidates(sourceDirectories);
255
+ await fs.promises.mkdir(paths.stagingDirectory, { mode: 0o700 });
256
+ for (const candidate of candidates) {
257
+ const destinationBinary = path.join(
258
+ paths.stagingDirectory,
259
+ candidate.destinationName,
260
+ );
261
+ const destinationSidecar = ProvenanceStore.sidecarPath(destinationBinary);
262
+ await fs.promises.copyFile(
263
+ candidate.binaryPath,
264
+ destinationBinary,
265
+ fs.constants.COPYFILE_EXCL,
266
+ );
267
+ await fs.promises.copyFile(
268
+ candidate.sidecarPath,
269
+ destinationSidecar,
270
+ fs.constants.COPYFILE_EXCL,
271
+ );
272
+ await syncFile(destinationBinary);
273
+ await syncFile(destinationSidecar);
274
+ const copiedBuildInfo = await ProvenanceStore.readSidecar(destinationBinary);
275
+ if (JSON.stringify(copiedBuildInfo) !== JSON.stringify(candidate.buildInfo)) {
276
+ throw new Error(`Artifact provenance changed while copying ${candidate.binaryPath}`);
277
+ }
278
+ }
279
+ await fs.promises.chmod(paths.stagingDirectory, 0o755);
280
+ await syncDirectory(paths.stagingDirectory);
281
+ state = { ...state, phase: 'prepared' };
282
+ await this.writeState(paths, state);
283
+ await this.assertAssemblySourceUnchanged(paths.workspace);
284
+
285
+ if (state.hadExistingOutput) {
286
+ state = { ...state, phase: 'movingOld' };
287
+ await this.writeState(paths, state);
288
+ await fs.promises.rename(paths.outputDirectory, paths.backupDirectory);
289
+ await syncDirectory(paths.workspace);
290
+ state = { ...state, phase: 'oldMoved' };
291
+ await this.writeState(paths, state);
292
+ }
293
+
294
+ state = { ...state, phase: 'publishing' };
295
+ await this.writeState(paths, state);
296
+ state = { ...state, phase: 'committing' };
297
+ try {
298
+ await this.writeState(paths, state);
299
+ commitReached = true;
300
+ } catch (error) {
301
+ let persistedState: IAssemblyTransactionState | undefined;
302
+ try {
303
+ persistedState = await this.readState(paths);
304
+ } catch (stateError) {
305
+ commitReached = true;
306
+ throw new AggregateError(
307
+ [error, stateError],
308
+ `Artifact commit state is unreadable in ${paths.transactionDirectory}`,
309
+ );
310
+ }
311
+ if (
312
+ persistedState?.ownerToken === ownerToken &&
313
+ persistedState.phase === 'publishing'
314
+ ) {
315
+ state = persistedState;
316
+ } else {
317
+ // Recovery follows whichever commit phase reached state.json; do not
318
+ // make a second, potentially contradictory decision here.
319
+ commitReached = true;
320
+ }
321
+ throw new Error(
322
+ commitReached
323
+ ? `Artifact commit intent is pending in ${paths.transactionDirectory}`
324
+ : 'Failed to persist artifact commit intent',
325
+ { cause: error },
326
+ );
327
+ }
328
+ const cleanupPending = await this.completeCommittedTransaction(paths, state);
329
+ retainOwnerToken = cleanupPending;
330
+ return {
331
+ outputDirectory: paths.outputDirectory,
332
+ artifactCount: candidates.length,
333
+ binaries: [...this.options.expectedBinaries].sort(),
334
+ targets: this.options.expectedTargets.map((target) => target.friendly).sort(),
335
+ cleanupPending,
336
+ };
337
+ } catch (error) {
338
+ if (!commitReached) {
339
+ try {
340
+ await this.rollbackTransaction(paths, state);
341
+ } catch (rollbackError) {
342
+ retainOwnerToken = true;
343
+ throw new AggregateError(
344
+ [error, rollbackError],
345
+ `Artifact assembly failed and rollback is pending in ${paths.transactionDirectory}`,
346
+ );
347
+ }
348
+ } else {
349
+ retainOwnerToken = true;
350
+ }
351
+ throw error;
352
+ } finally {
353
+ activeOwnerTokens.delete(ownerToken);
354
+ if (!retainOwnerToken) {
355
+ processOwnerTokens.delete(ownerToken);
356
+ } else {
357
+ try {
358
+ if (!(await lstatOptional(paths.lockPath))) {
359
+ processOwnerTokens.delete(ownerToken);
360
+ }
361
+ } catch {
362
+ // Keep the token so a same-process retry can recover the transaction.
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ private createPaths(ownerTokenArg: string): IAssemblyPaths {
369
+ const workspace = path.resolve(this.options.workspace);
370
+ const transactionParent = path.join(workspace, '.nogit');
371
+ const transactionDirectory = path.join(transactionParent, TRANSACTION_DIRECTORY);
372
+ return {
373
+ workspace,
374
+ outputDirectory: path.join(workspace, 'dist_rust'),
375
+ transactionParent,
376
+ transactionDirectory,
377
+ lockPath: path.join(transactionParent, 'tsrust-assembly.lock'),
378
+ lockSourcePath: path.join(
379
+ transactionParent,
380
+ `.tsrust-assembly-owner.${ownerTokenArg}.tmp`,
381
+ ),
382
+ recoveryClaimPath: path.join(transactionParent, 'tsrust-assembly.recovery.lock'),
383
+ statePath: path.join(transactionDirectory, 'state.json'),
384
+ stateTemporaryPath: path.join(transactionDirectory, 'state.json.tmp'),
385
+ stagingDirectory: path.join(transactionDirectory, 'staging'),
386
+ backupDirectory: path.join(transactionDirectory, 'backup'),
387
+ failedDirectory: path.join(transactionDirectory, 'failed'),
388
+ };
389
+ }
390
+
391
+ private validateOptions(pathsArg: IAssemblyPaths, sourceDirectoriesArg: string[]): void {
392
+ if (sourceDirectoriesArg.length === 0) {
393
+ throw new Error('Artifact assembly requires at least one source directory');
394
+ }
395
+ if (new Set(sourceDirectoriesArg).size !== sourceDirectoriesArg.length) {
396
+ throw new Error('Artifact assembly source directories must be unique');
397
+ }
398
+ if (this.options.expectedTargets.length === 0) {
399
+ throw new Error('Artifact assembly requires at least one expected target');
400
+ }
401
+ if (this.options.expectedBinaries.length === 0) {
402
+ throw new Error('Artifact assembly requires at least one expected binary');
403
+ }
404
+ for (const binary of this.options.expectedBinaries) {
405
+ assertArtifactName(binary, 'binary name');
406
+ }
407
+ if (new Set(this.options.expectedBinaries).size !== this.options.expectedBinaries.length) {
408
+ throw new Error('Expected artifact binary names must be unique');
409
+ }
410
+ const targetNames = this.options.expectedTargets.map((target) => target.friendly);
411
+ const targetTriples = this.options.expectedTargets.map((target) => target.triple);
412
+ if (
413
+ new Set(targetNames).size !== targetNames.length ||
414
+ new Set(targetTriples).size !== targetTriples.length
415
+ ) {
416
+ throw new Error('Expected artifact targets must be unique');
417
+ }
418
+ if (!/^[a-f0-9]{40}([a-f0-9]{24})?$/.test(this.options.expectedGitCommit)) {
419
+ throw new Error('Artifact assembly requires an exact Git commit');
420
+ }
421
+ for (const target of this.options.expectedTargets) {
422
+ assertArtifactName(target.friendly, 'target name');
423
+ assertArtifactName(target.triple, 'target triple');
424
+ }
425
+ for (const sourceDirectory of sourceDirectoriesArg) {
426
+ if (
427
+ isSameOrWithin(sourceDirectory, pathsArg.outputDirectory) ||
428
+ isSameOrWithin(pathsArg.outputDirectory, sourceDirectory) ||
429
+ isSameOrWithin(sourceDirectory, pathsArg.transactionDirectory) ||
430
+ isSameOrWithin(pathsArg.transactionDirectory, sourceDirectory)
431
+ ) {
432
+ throw new Error(`Artifact source overlaps tsrust-owned output: ${sourceDirectory}`);
433
+ }
434
+ }
435
+ }
436
+
437
+ private async ensureTransactionParent(pathsArg: IAssemblyPaths): Promise<void> {
438
+ const existing = await lstatOptional(pathsArg.transactionParent);
439
+ if (existing) {
440
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
441
+ throw new Error(`tsrust state path is not a regular directory: ${pathsArg.transactionParent}`);
442
+ }
443
+ } else {
444
+ await fs.promises.mkdir(pathsArg.transactionParent, { mode: 0o700 });
445
+ }
446
+ const workspace = await fs.promises.stat(pathsArg.workspace);
447
+ const transactionParent = await fs.promises.stat(pathsArg.transactionParent);
448
+ if (workspace.dev !== transactionParent.dev) {
449
+ throw new Error('tsrust assembly state and dist_rust must be on the same filesystem');
450
+ }
451
+ }
452
+
453
+ private async acquireTransaction(
454
+ pathsArg: IAssemblyPaths,
455
+ stateArg: IAssemblyTransactionState,
456
+ ): Promise<void> {
457
+ await this.cleanupAbandonedLockSources(pathsArg);
458
+ if (
459
+ (await lstatOptional(pathsArg.transactionDirectory)) &&
460
+ !(await lstatOptional(pathsArg.lockPath))
461
+ ) {
462
+ throw new Error(`Assembly data exists without its owner lock: ${pathsArg.transactionDirectory}`);
463
+ }
464
+ const owner = this.lockOwnerFromState(stateArg);
465
+ await this.writeLockSource(pathsArg, owner);
466
+ let ownsLock = false;
467
+ let transactionCreated = false;
468
+ try {
469
+ for (let attempt = 0; attempt < 2; attempt += 1) {
470
+ await this.clearAbandonedRecoveryClaim(pathsArg);
471
+ try {
472
+ await fs.promises.link(pathsArg.lockSourcePath, pathsArg.lockPath);
473
+ ownsLock = true;
474
+ await syncDirectory(pathsArg.transactionParent);
475
+ break;
476
+ } catch (error) {
477
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
478
+ if (attempt > 0) {
479
+ throw new Error(`Unable to clear stale assembly lock: ${pathsArg.lockPath}`);
480
+ }
481
+ await this.recoverExistingTransaction(pathsArg);
482
+ }
483
+ }
484
+ await fs.promises.unlink(pathsArg.lockSourcePath);
485
+ await fs.promises.mkdir(pathsArg.transactionDirectory, { mode: 0o700 });
486
+ transactionCreated = true;
487
+ await syncDirectory(pathsArg.transactionParent);
488
+ await this.writeState(pathsArg, stateArg);
489
+ } catch (error) {
490
+ if (transactionCreated) {
491
+ await fs.promises.rm(pathsArg.transactionDirectory, { recursive: true, force: true });
492
+ }
493
+ if (ownsLock) {
494
+ await this.releaseLock(pathsArg, owner.ownerToken);
495
+ }
496
+ throw error;
497
+ } finally {
498
+ await Promise.allSettled([fs.promises.rm(pathsArg.lockSourcePath, { force: true })]);
499
+ }
500
+ }
501
+
502
+ private lockOwnerFromState(stateArg: IAssemblyTransactionState): IAssemblyLockOwner {
503
+ return {
504
+ format: LOCK_FORMAT,
505
+ pid: stateArg.pid,
506
+ hostname: stateArg.hostname,
507
+ hostIdentity: stateArg.hostIdentity,
508
+ ownerToken: stateArg.ownerToken,
509
+ startedAt: stateArg.startedAt,
510
+ };
511
+ }
512
+
513
+ private async writeLockSource(
514
+ pathsArg: IAssemblyPaths,
515
+ ownerArg: IAssemblyLockOwner,
516
+ ): Promise<void> {
517
+ const lockSource = await fs.promises.open(pathsArg.lockSourcePath, 'wx', 0o600);
518
+ try {
519
+ await lockSource.writeFile(`${JSON.stringify(ownerArg, null, 2)}\n`, 'utf8');
520
+ await lockSource.sync();
521
+ } finally {
522
+ await lockSource.close();
523
+ }
524
+ }
525
+
526
+ private async cleanupAbandonedLockSources(pathsArg: IAssemblyPaths): Promise<void> {
527
+ const entries = await fs.promises.readdir(pathsArg.transactionParent, {
528
+ withFileTypes: true,
529
+ });
530
+ for (const entry of entries) {
531
+ if (
532
+ !entry.isFile() ||
533
+ (!entry.name.startsWith('.tsrust-assembly-owner.') &&
534
+ !entry.name.startsWith('.tsrust-assembly-recovery.')) ||
535
+ !entry.name.endsWith('.tmp')
536
+ ) {
537
+ continue;
538
+ }
539
+ const filePath = path.join(pathsArg.transactionParent, entry.name);
540
+ let owner: IAssemblyOwnerIdentity;
541
+ try {
542
+ const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
543
+ if (isLockOwner(parsed) || isRecoveryClaim(parsed)) {
544
+ owner = parsed;
545
+ } else {
546
+ continue;
547
+ }
548
+ } catch (error) {
549
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
550
+ continue;
551
+ }
552
+ if (
553
+ activeOwnerTokens.has(owner.ownerToken) ||
554
+ processOwnerTokens.has(owner.ownerToken) ||
555
+ owner.hostIdentity !== getHostIdentity() ||
556
+ isLocalProcessRunning(owner.pid)
557
+ ) {
558
+ continue;
559
+ }
560
+ await fs.promises.rm(filePath, { force: true });
561
+ }
562
+ }
563
+
564
+ private async readLock(pathsArg: IAssemblyPaths): Promise<IAssemblyLockOwner | undefined> {
565
+ const lock = await lstatOptional(pathsArg.lockPath);
566
+ if (!lock) return undefined;
567
+ if (!lock.isFile() || lock.isSymbolicLink()) {
568
+ throw new Error(`Assembly lock is not a regular file: ${pathsArg.lockPath}`);
569
+ }
570
+ try {
571
+ const parsed = JSON.parse(await fs.promises.readFile(pathsArg.lockPath, 'utf8'));
572
+ if (!isLockOwner(parsed)) {
573
+ throw new Error(`Assembly lock has an invalid shape: ${pathsArg.lockPath}`);
574
+ }
575
+ return parsed;
576
+ } catch (error) {
577
+ if (error instanceof SyntaxError) {
578
+ throw new Error(`Assembly lock is invalid JSON: ${pathsArg.lockPath}`);
579
+ }
580
+ throw error;
581
+ }
582
+ }
583
+
584
+ private async readRecoveryClaim(
585
+ pathsArg: IAssemblyPaths,
586
+ ): Promise<IAssemblyRecoveryClaim | undefined> {
587
+ const claim = await lstatOptional(pathsArg.recoveryClaimPath);
588
+ if (!claim) return undefined;
589
+ if (!claim.isFile() || claim.isSymbolicLink()) {
590
+ throw new Error(`Assembly recovery claim is not a regular file: ${pathsArg.recoveryClaimPath}`);
591
+ }
592
+ try {
593
+ const parsed = JSON.parse(
594
+ await fs.promises.readFile(pathsArg.recoveryClaimPath, 'utf8'),
595
+ );
596
+ if (!isRecoveryClaim(parsed)) {
597
+ throw new Error(`Assembly recovery claim has an invalid shape: ${pathsArg.recoveryClaimPath}`);
598
+ }
599
+ return parsed;
600
+ } catch (error) {
601
+ if (error instanceof SyntaxError) {
602
+ throw new Error(`Assembly recovery claim is invalid JSON: ${pathsArg.recoveryClaimPath}`);
603
+ }
604
+ throw error;
605
+ }
606
+ }
607
+
608
+ private async clearAbandonedRecoveryClaim(pathsArg: IAssemblyPaths): Promise<void> {
609
+ const claim = await this.readRecoveryClaim(pathsArg);
610
+ if (!claim) return;
611
+ const targetOwner = await this.readLock(pathsArg);
612
+ if (targetOwner?.ownerToken === claim.targetOwnerToken) {
613
+ this.assertOwnerCanRecover(claim);
614
+ }
615
+ await this.releaseRecoveryClaim(pathsArg, claim.ownerToken);
616
+ processOwnerTokens.delete(claim.ownerToken);
617
+ }
618
+
619
+ private async acquireRecoveryClaim(
620
+ pathsArg: IAssemblyPaths,
621
+ targetOwnerArg: IAssemblyLockOwner,
622
+ ): Promise<IAssemblyRecoveryClaim> {
623
+ const claim: IAssemblyRecoveryClaim = {
624
+ format: RECOVERY_CLAIM_FORMAT,
625
+ pid: process.pid,
626
+ hostname: os.hostname(),
627
+ hostIdentity: getHostIdentity(),
628
+ ownerToken: crypto.randomUUID(),
629
+ startedAt: new Date().toISOString(),
630
+ targetOwnerToken: targetOwnerArg.ownerToken,
631
+ };
632
+ const sourcePath = path.join(
633
+ pathsArg.transactionParent,
634
+ `.tsrust-assembly-recovery.${claim.ownerToken}.tmp`,
635
+ );
636
+ let source: fs.promises.FileHandle | undefined;
637
+ let claimAcquired = false;
638
+ try {
639
+ source = await fs.promises.open(sourcePath, 'wx', 0o600);
640
+ await source.writeFile(`${JSON.stringify(claim, null, 2)}\n`, 'utf8');
641
+ await source.sync();
642
+ await source.close();
643
+ source = undefined;
644
+ for (let attempt = 0; attempt < 2; attempt += 1) {
645
+ try {
646
+ await fs.promises.link(sourcePath, pathsArg.recoveryClaimPath);
647
+ claimAcquired = true;
648
+ processOwnerTokens.add(claim.ownerToken);
649
+ activeOwnerTokens.add(claim.ownerToken);
650
+ await syncDirectory(pathsArg.transactionParent);
651
+ break;
652
+ } catch (error) {
653
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
654
+ if (attempt > 0) {
655
+ throw new Error(`Unable to acquire assembly recovery claim: ${pathsArg.recoveryClaimPath}`);
656
+ }
657
+ await this.clearAbandonedRecoveryClaim(pathsArg);
658
+ }
659
+ }
660
+ const currentOwner = await this.readLock(pathsArg);
661
+ if (currentOwner?.ownerToken !== targetOwnerArg.ownerToken) {
662
+ await this.releaseRecoveryClaim(pathsArg, claim.ownerToken);
663
+ claimAcquired = false;
664
+ activeOwnerTokens.delete(claim.ownerToken);
665
+ processOwnerTokens.delete(claim.ownerToken);
666
+ throw new Error('Assembly owner changed while recovery was being claimed');
667
+ }
668
+ return claim;
669
+ } catch (error) {
670
+ if (claimAcquired) {
671
+ try {
672
+ await this.releaseRecoveryClaim(pathsArg, claim.ownerToken);
673
+ } finally {
674
+ activeOwnerTokens.delete(claim.ownerToken);
675
+ if (!(await lstatOptional(pathsArg.recoveryClaimPath))) {
676
+ processOwnerTokens.delete(claim.ownerToken);
677
+ }
678
+ }
679
+ }
680
+ throw error;
681
+ } finally {
682
+ await Promise.allSettled([
683
+ source?.close() || Promise.resolve(),
684
+ fs.promises.rm(sourcePath, { force: true }),
685
+ ]);
686
+ }
687
+ }
688
+
689
+ private async releaseRecoveryClaim(
690
+ pathsArg: IAssemblyPaths,
691
+ ownerTokenArg: string,
692
+ ): Promise<void> {
693
+ const claim = await this.readRecoveryClaim(pathsArg);
694
+ if (!claim) return;
695
+ if (claim.ownerToken !== ownerTokenArg) {
696
+ throw new Error('Assembly recovery claim ownership changed before release');
697
+ }
698
+ await fs.promises.unlink(pathsArg.recoveryClaimPath);
699
+ await syncDirectory(pathsArg.transactionParent);
700
+ }
701
+
702
+ private assertOwnerCanRecover(ownerArg: IAssemblyOwnerIdentity): void {
703
+ if (activeOwnerTokens.has(ownerArg.ownerToken)) {
704
+ throw new Error(
705
+ `Artifact assembly is already active on ${ownerArg.hostname} with pid ${ownerArg.pid}`,
706
+ );
707
+ }
708
+ if (processOwnerTokens.has(ownerArg.ownerToken)) return;
709
+ if (ownerArg.hostIdentity !== getHostIdentity()) {
710
+ throw new Error(
711
+ `Assembly transaction belongs to another host (${ownerArg.hostname}); refusing automatic recovery`,
712
+ );
713
+ }
714
+ if (isLocalProcessRunning(ownerArg.pid)) {
715
+ throw new Error(
716
+ `Artifact assembly owner is still running on ${ownerArg.hostname} with pid ${ownerArg.pid}`,
717
+ );
718
+ }
719
+ }
720
+
721
+ private async recoverExistingTransaction(pathsArg: IAssemblyPaths): Promise<void> {
722
+ const owner = await this.readLock(pathsArg);
723
+ if (!owner) return;
724
+ this.assertOwnerCanRecover(owner);
725
+ const claim = await this.acquireRecoveryClaim(pathsArg, owner);
726
+ try {
727
+ const currentOwner = await this.readLock(pathsArg);
728
+ if (currentOwner?.ownerToken !== owner.ownerToken) {
729
+ throw new Error('Assembly owner changed after recovery was claimed');
730
+ }
731
+ const transaction = await lstatOptional(pathsArg.transactionDirectory);
732
+ if (!transaction) {
733
+ await this.releaseLock(pathsArg, owner.ownerToken);
734
+ processOwnerTokens.delete(owner.ownerToken);
735
+ return;
736
+ }
737
+ if (!transaction.isDirectory() || transaction.isSymbolicLink()) {
738
+ throw new Error(`Assembly transaction path is not a regular directory: ${pathsArg.transactionDirectory}`);
739
+ }
740
+ const state = await this.readState(pathsArg);
741
+ if (!state) {
742
+ await fs.promises.rm(pathsArg.transactionDirectory, { recursive: true });
743
+ await this.releaseLock(pathsArg, owner.ownerToken);
744
+ processOwnerTokens.delete(owner.ownerToken);
745
+ return;
746
+ }
747
+ if (
748
+ state.ownerToken !== owner.ownerToken ||
749
+ state.hostIdentity !== owner.hostIdentity ||
750
+ state.pid !== owner.pid
751
+ ) {
752
+ throw new Error('Assembly lock and transaction state have different owners');
753
+ }
754
+
755
+ if (
756
+ state.phase === 'committing' ||
757
+ state.phase === 'published' ||
758
+ state.phase === 'committed'
759
+ ) {
760
+ const cleanupPending = await this.completeCommittedTransaction(pathsArg, state);
761
+ if (cleanupPending) {
762
+ throw new Error(`Unable to clean committed assembly state: ${pathsArg.transactionDirectory}`);
763
+ }
764
+ processOwnerTokens.delete(state.ownerToken);
765
+ return;
766
+ }
767
+ await this.rollbackTransaction(pathsArg, state);
768
+ processOwnerTokens.delete(state.ownerToken);
769
+ } finally {
770
+ activeOwnerTokens.delete(claim.ownerToken);
771
+ try {
772
+ await this.releaseRecoveryClaim(pathsArg, claim.ownerToken);
773
+ } finally {
774
+ if (!(await lstatOptional(pathsArg.recoveryClaimPath))) {
775
+ processOwnerTokens.delete(claim.ownerToken);
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ private async releaseLock(pathsArg: IAssemblyPaths, ownerTokenArg: string): Promise<void> {
782
+ const owner = await this.readLock(pathsArg);
783
+ if (!owner) return;
784
+ if (owner.ownerToken !== ownerTokenArg) {
785
+ throw new Error('Assembly lock ownership changed before release');
786
+ }
787
+ await fs.promises.unlink(pathsArg.lockPath);
788
+ await syncDirectory(pathsArg.transactionParent);
789
+ }
790
+
791
+ private async readState(
792
+ pathsArg: IAssemblyPaths,
793
+ ): Promise<IAssemblyTransactionState | undefined> {
794
+ const state = await lstatOptional(pathsArg.statePath);
795
+ if (!state) return undefined;
796
+ if (!state.isFile() || state.isSymbolicLink()) {
797
+ throw new Error(`Assembly state is not a regular file: ${pathsArg.statePath}`);
798
+ }
799
+ try {
800
+ const parsed = JSON.parse(await fs.promises.readFile(pathsArg.statePath, 'utf8'));
801
+ if (!isTransactionState(parsed)) {
802
+ throw new Error(`Assembly state has an invalid shape: ${pathsArg.statePath}`);
803
+ }
804
+ return parsed;
805
+ } catch (error) {
806
+ if (error instanceof SyntaxError) {
807
+ throw new Error(`Assembly state is invalid JSON: ${pathsArg.statePath}`);
808
+ }
809
+ throw error;
810
+ }
811
+ }
812
+
813
+ private async writeState(
814
+ pathsArg: IAssemblyPaths,
815
+ stateArg: IAssemblyTransactionState,
816
+ ): Promise<void> {
817
+ const temporary = await fs.promises.open(pathsArg.stateTemporaryPath, 'w', 0o600);
818
+ try {
819
+ await temporary.writeFile(`${JSON.stringify(stateArg, null, 2)}\n`, 'utf8');
820
+ await temporary.sync();
821
+ } finally {
822
+ await temporary.close();
823
+ }
824
+ await fs.promises.rename(pathsArg.stateTemporaryPath, pathsArg.statePath);
825
+ await syncDirectory(pathsArg.transactionDirectory);
826
+ }
827
+
828
+ private async assertAssemblySourceUnchanged(workspaceArg: string): Promise<void> {
829
+ const snapshot = await captureGitSnapshot(workspaceArg);
830
+ if (
831
+ !snapshot.available ||
832
+ snapshot.commit !== this.options.expectedGitCommit ||
833
+ snapshot.status.length > 0
834
+ ) {
835
+ throw new Error('Git source state changed before artifact publication');
836
+ }
837
+ let projectName = 'unknown';
838
+ let projectVersion = 'unknown';
839
+ try {
840
+ const packageJson = JSON.parse(
841
+ await fs.promises.readFile(path.join(workspaceArg, 'package.json'), 'utf8'),
842
+ );
843
+ projectName = packageJson.name || projectName;
844
+ projectVersion = packageJson.version || projectVersion;
845
+ } catch {
846
+ // package.json is optional for pure Rust workspaces.
847
+ }
848
+ if (
849
+ projectName !== this.options.expectedProjectName ||
850
+ projectVersion !== this.options.expectedProjectVersion
851
+ ) {
852
+ throw new Error('Project package identity changed before artifact publication');
853
+ }
854
+ }
855
+
856
+ private async rollbackTransaction(
857
+ pathsArg: IAssemblyPaths,
858
+ stateArg: IAssemblyTransactionState,
859
+ ): Promise<void> {
860
+ const backup = await lstatOptional(pathsArg.backupDirectory);
861
+ if (backup) {
862
+ if (!backup.isDirectory() || backup.isSymbolicLink()) {
863
+ throw new Error(`Assembly backup is not a regular directory: ${pathsArg.backupDirectory}`);
864
+ }
865
+ const output = await lstatOptional(pathsArg.outputDirectory);
866
+ if (output) {
867
+ await fs.promises.rm(pathsArg.failedDirectory, { recursive: true, force: true });
868
+ await fs.promises.rename(pathsArg.outputDirectory, pathsArg.failedDirectory);
869
+ }
870
+ await fs.promises.rename(pathsArg.backupDirectory, pathsArg.outputDirectory);
871
+ await syncDirectory(pathsArg.workspace);
872
+ await fs.promises.rm(pathsArg.failedDirectory, { recursive: true, force: true });
873
+ } else if (
874
+ !stateArg.hadExistingOutput &&
875
+ (stateArg.phase === 'publishing' ||
876
+ stateArg.phase === 'published' ||
877
+ stateArg.phase === 'committing')
878
+ ) {
879
+ await fs.promises.rm(pathsArg.outputDirectory, { recursive: true, force: true });
880
+ await syncDirectory(pathsArg.workspace);
881
+ }
882
+ await fs.promises.rm(pathsArg.stagingDirectory, { recursive: true, force: true });
883
+ await fs.promises.rm(pathsArg.stateTemporaryPath, { force: true });
884
+ await fs.promises.rm(pathsArg.transactionDirectory, { recursive: true });
885
+ await this.releaseLock(pathsArg, stateArg.ownerToken);
886
+ }
887
+
888
+ private async completeCommittedTransaction(
889
+ pathsArg: IAssemblyPaths,
890
+ stateArg: IAssemblyTransactionState,
891
+ ): Promise<boolean> {
892
+ const output = await lstatOptional(pathsArg.outputDirectory);
893
+ const staging = await lstatOptional(pathsArg.stagingDirectory);
894
+ if (!output) {
895
+ if (!staging || !staging.isDirectory() || staging.isSymbolicLink()) {
896
+ throw new Error(`Committed artifact publication is incomplete: ${pathsArg.transactionDirectory}`);
897
+ }
898
+ await fs.promises.rename(pathsArg.stagingDirectory, pathsArg.outputDirectory);
899
+ await syncDirectory(pathsArg.workspace);
900
+ } else {
901
+ if (!output.isDirectory() || output.isSymbolicLink()) {
902
+ throw new Error(`Committed assembly output is not a regular directory: ${pathsArg.outputDirectory}`);
903
+ }
904
+ if (staging) {
905
+ throw new Error(`Committed assembly has both staging and output directories: ${pathsArg.transactionDirectory}`);
906
+ }
907
+ }
908
+ return this.cleanupCommittedTransaction(pathsArg, { ...stateArg, phase: 'published' });
909
+ }
910
+
911
+ private async cleanupCommittedTransaction(
912
+ pathsArg: IAssemblyPaths,
913
+ stateArg: IAssemblyTransactionState,
914
+ ): Promise<boolean> {
915
+ try {
916
+ const output = await fs.promises.lstat(pathsArg.outputDirectory);
917
+ if (!output.isDirectory() || output.isSymbolicLink()) {
918
+ throw new Error(`Committed assembly output is not a regular directory: ${pathsArg.outputDirectory}`);
919
+ }
920
+ await fs.promises.rm(pathsArg.backupDirectory, { recursive: true, force: true });
921
+ await fs.promises.rm(pathsArg.stagingDirectory, { recursive: true, force: true });
922
+ await fs.promises.rm(pathsArg.failedDirectory, { recursive: true, force: true });
923
+ await this.writeState(pathsArg, { ...stateArg, phase: 'committed' });
924
+ await fs.promises.rm(pathsArg.transactionDirectory, { recursive: true });
925
+ await this.releaseLock(pathsArg, stateArg.ownerToken);
926
+ return false;
927
+ } catch {
928
+ return true;
929
+ }
930
+ }
931
+
932
+ private async collectCandidates(sourceDirectoriesArg: string[]): Promise<IArtifactCandidate[]> {
933
+ const expectedTargets = new Map(
934
+ this.options.expectedTargets.map((target) => [target.friendly, target]),
935
+ );
936
+ const expectedBinaries = new Set(this.options.expectedBinaries);
937
+ const candidates = new Map<string, IArtifactCandidate>();
938
+
939
+ for (const sourceDirectory of sourceDirectoriesArg) {
940
+ const source = await fs.promises.lstat(sourceDirectory);
941
+ if (!source.isDirectory() || source.isSymbolicLink()) {
942
+ throw new Error(`Artifact source is not a regular directory: ${sourceDirectory}`);
943
+ }
944
+ const entries = await fs.promises.readdir(sourceDirectory, { withFileTypes: true });
945
+ const entryNames = new Set(entries.map((entry) => entry.name));
946
+ const usedSidecars = new Set<string>();
947
+
948
+ for (const entry of entries) {
949
+ const entryPath = path.join(sourceDirectory, entry.name);
950
+ if (entry.isSymbolicLink() || !entry.isFile()) {
951
+ throw new Error(`Artifact source contains a non-regular entry: ${entryPath}`);
952
+ }
953
+ if (isProvenanceTemporaryFile(entry.name)) continue;
954
+ if (entry.name.endsWith(PROVENANCE_SIDECAR_SUFFIX)) continue;
955
+
956
+ const sidecarName = `${entry.name}${PROVENANCE_SIDECAR_SUFFIX}`;
957
+ if (!entryNames.has(sidecarName)) {
958
+ throw new Error(`Artifact is missing its provenance sidecar: ${entryPath}`);
959
+ }
960
+ const binary = await fs.promises.lstat(entryPath);
961
+ if ((binary.mode & 0o111) === 0) {
962
+ throw new Error(`Artifact is not executable: ${entryPath}`);
963
+ }
964
+
965
+ const buildInfo = await ProvenanceStore.readSidecar(entryPath);
966
+ assertArtifactName(buildInfo.binary, 'provenance binary name');
967
+ assertArtifactName(buildInfo.target, 'provenance target name');
968
+ if (buildInfo.gitDirty !== false) {
969
+ throw new Error(`Artifact was not built from a clean Git worktree: ${entryPath}`);
970
+ }
971
+ if (
972
+ buildInfo.projectName !== this.options.expectedProjectName ||
973
+ buildInfo.projectVersion !== this.options.expectedProjectVersion ||
974
+ buildInfo.gitCommit !== this.options.expectedGitCommit
975
+ ) {
976
+ throw new Error(`Artifact provenance does not match the assembly source: ${entryPath}`);
977
+ }
978
+ if (buildInfo.tsrustVersion !== this.options.expectedTsrustVersion) {
979
+ throw new Error(`Artifact was built with a different tsrust version: ${entryPath}`);
980
+ }
981
+
982
+ const target = expectedTargets.get(buildInfo.target);
983
+ if (!target) {
984
+ throw new Error(`Artifact has an unexpected target ${buildInfo.target}: ${entryPath}`);
985
+ }
986
+ if (!expectedBinaries.has(buildInfo.binary)) {
987
+ throw new Error(`Artifact has an unexpected binary ${buildInfo.binary}: ${entryPath}`);
988
+ }
989
+ const destinationName = `${buildInfo.binary}_${target.friendly}`;
990
+ if (entry.name !== destinationName) {
991
+ throw new Error(
992
+ `Artifact filename ${entry.name} does not match provenance name ${destinationName}`,
993
+ );
994
+ }
995
+ const key = `${buildInfo.binary}\u0000${target.friendly}`;
996
+ if (candidates.has(key)) {
997
+ throw new Error(`Artifact inputs contain a duplicate ${destinationName}`);
998
+ }
999
+ const sidecarPath = path.join(sourceDirectory, sidecarName);
1000
+ candidates.set(key, {
1001
+ binaryPath: entryPath,
1002
+ sidecarPath,
1003
+ destinationName,
1004
+ buildInfo,
1005
+ });
1006
+ usedSidecars.add(sidecarName);
1007
+ }
1008
+
1009
+ for (const entry of entries) {
1010
+ if (entry.name.endsWith(PROVENANCE_SIDECAR_SUFFIX) && !usedSidecars.has(entry.name)) {
1011
+ throw new Error(`Artifact source contains an orphan provenance sidecar: ${entry.name}`);
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ for (const binary of expectedBinaries) {
1017
+ for (const target of expectedTargets.keys()) {
1018
+ const key = `${binary}\u0000${target}`;
1019
+ if (!candidates.has(key)) {
1020
+ throw new Error(`Artifact assembly is missing ${binary}_${target}`);
1021
+ }
1022
+ }
1023
+ }
1024
+ return [...candidates.values()].sort((a, b) =>
1025
+ a.destinationName.localeCompare(b.destinationName),
1026
+ );
1027
+ }
1028
+ }