vouchington-tooling 0.0.14 → 0.0.15
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 +1 -0
- package/dist/coverage-transport/constants.d.mts +3 -0
- package/dist/coverage-transport/constants.mjs +11 -0
- package/dist/coverage-transport/control.d.mts +47 -0
- package/dist/coverage-transport/control.mjs +112 -0
- package/dist/coverage-transport/http.d.mts +6 -0
- package/dist/coverage-transport/http.mjs +99 -0
- package/dist/coverage-transport/index.d.mts +6 -0
- package/dist/coverage-transport/index.mjs +6 -0
- package/dist/coverage-transport/lib.d.mts +17 -0
- package/dist/coverage-transport/lib.mjs +84 -0
- package/dist/coverage-transport/outcome.d.mts +29 -0
- package/dist/coverage-transport/outcome.mjs +70 -0
- package/dist/coverage-transport/presign.d.mts +22 -0
- package/dist/coverage-transport/presign.mjs +51 -0
- package/dist/coverage-transport/vitest-blob-transport.d.mts +5 -0
- package/dist/coverage-transport/vitest-blob-transport.mjs +129 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/transient-retry/decision-evaluator.d.mts +2 -0
- package/dist/transient-retry/decision-evaluator.mjs +6 -0
- package/dist/transient-retry/types.d.mts +3 -3
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ import { readResponseBody } from 'vouchington-tooling/http-body'
|
|
|
79
79
|
import { runAstGrepRule } from 'vouchington-tooling/ast-grep-rule'
|
|
80
80
|
import { parseReviewPayload, remapReviewComments } from 'vouchington-tooling/gha-review-payload'
|
|
81
81
|
import { nextPageUrlFromLinkHeader } from 'vouchington-tooling/http-link-pagination'
|
|
82
|
+
import { cmdUpload, mintPresignedControl } from 'vouchington-tooling/coverage-transport'
|
|
82
83
|
```
|
|
83
84
|
|
|
84
85
|
The artifact, review-payload, HTTP body, and pagination APIs validate untrusted inputs at their
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DEFAULT_COVERAGE_MANIFEST_FILENAME = 'coverage-manifest.json';
|
|
2
|
+
export const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
const MANIFEST_FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/;
|
|
4
|
+
export function assertCoverageManifestFilename(filename) {
|
|
5
|
+
if (filename.includes('/') ||
|
|
6
|
+
filename.includes('\\') ||
|
|
7
|
+
filename.includes('..') ||
|
|
8
|
+
!MANIFEST_FILENAME_PATTERN.test(filename)) {
|
|
9
|
+
throw new Error('Coverage manifest filename is invalid');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface PresignedCoverageUrls {
|
|
2
|
+
lcovPut: string;
|
|
3
|
+
lcovGet: string;
|
|
4
|
+
manifestPut: string;
|
|
5
|
+
manifestGet: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PresignedBlobUrls {
|
|
8
|
+
put: string;
|
|
9
|
+
get: string;
|
|
10
|
+
}
|
|
11
|
+
interface TransportControlBase {
|
|
12
|
+
readonly version: 1;
|
|
13
|
+
readonly repository: string;
|
|
14
|
+
readonly revision: string;
|
|
15
|
+
readonly run: {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly controlAttempt: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface PresignedTransportControl extends TransportControlBase {
|
|
21
|
+
readonly mode: 'presigned';
|
|
22
|
+
readonly expiresAt: string;
|
|
23
|
+
readonly coverage: Record<string, PresignedCoverageUrls>;
|
|
24
|
+
readonly blobs: Record<string, PresignedBlobUrls>;
|
|
25
|
+
}
|
|
26
|
+
export interface FallbackOnlyTransportControl extends TransportControlBase {
|
|
27
|
+
readonly mode: 'fallback-only';
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
}
|
|
30
|
+
export type TransportControl = PresignedTransportControl | FallbackOnlyTransportControl;
|
|
31
|
+
export interface ExpectedTransportIdentity {
|
|
32
|
+
readonly repository: string;
|
|
33
|
+
readonly revision: string;
|
|
34
|
+
readonly runId: string;
|
|
35
|
+
readonly currentAttempt: number;
|
|
36
|
+
}
|
|
37
|
+
export interface RequestOptions {
|
|
38
|
+
readonly retryDelayMs?: number;
|
|
39
|
+
readonly timeoutMs?: number;
|
|
40
|
+
readonly maxBodyBytes?: number;
|
|
41
|
+
readonly maxMemberBytes?: number;
|
|
42
|
+
readonly log?: (line: string) => void;
|
|
43
|
+
}
|
|
44
|
+
export declare function parseTransportControl(raw: unknown): TransportControl;
|
|
45
|
+
export declare function writeTransportControl(path: string, control: TransportControl): void;
|
|
46
|
+
export declare function readTransportControl(path: string, expected?: ExpectedTransportIdentity): TransportControl;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { VITEST_SUITE_PATTERN } from '../vitest-blob-manifest/index.mjs';
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function positiveInteger(value) {
|
|
8
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
9
|
+
}
|
|
10
|
+
function assertUrlMap(raw, fields, label) {
|
|
11
|
+
if (!isRecord(raw))
|
|
12
|
+
throw new Error(`${label} URL map must be an object`);
|
|
13
|
+
for (const [suite, urls] of Object.entries(raw)) {
|
|
14
|
+
if (!VITEST_SUITE_PATTERN.test(suite) ||
|
|
15
|
+
!isRecord(urls) ||
|
|
16
|
+
Object.keys(urls).toSorted().join('\0') !== fields.toSorted().join('\0')) {
|
|
17
|
+
throw new Error(`${label} URL map has an invalid entry`);
|
|
18
|
+
}
|
|
19
|
+
for (const field of fields) {
|
|
20
|
+
const value = urls[field];
|
|
21
|
+
if (typeof value !== 'string' || !URL.canParse(value) || !/^https?:/i.test(value)) {
|
|
22
|
+
throw new Error(`${label} URL map has an invalid URL`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function parseTransportControl(raw) {
|
|
28
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.run)) {
|
|
29
|
+
throw new Error('Coverage transport control has an unsupported schema');
|
|
30
|
+
}
|
|
31
|
+
if (typeof raw.repository !== 'string' ||
|
|
32
|
+
!raw.repository ||
|
|
33
|
+
typeof raw.revision !== 'string' ||
|
|
34
|
+
!/^[0-9a-f]{40}$/.test(raw.revision) ||
|
|
35
|
+
typeof raw.run.id !== 'string' ||
|
|
36
|
+
!/^[1-9][0-9]*$/.test(raw.run.id) ||
|
|
37
|
+
!positiveInteger(raw.run.controlAttempt)) {
|
|
38
|
+
throw new Error('Coverage transport control has invalid identity fields');
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys(raw.run).toSorted().join('\0') !== ['controlAttempt', 'id'].join('\0')) {
|
|
41
|
+
throw new Error('Coverage transport control run schema is invalid');
|
|
42
|
+
}
|
|
43
|
+
if (raw.mode === 'fallback-only') {
|
|
44
|
+
const fallbackKeys = ['mode', 'reason', 'repository', 'revision', 'run', 'version'];
|
|
45
|
+
if (Object.keys(raw).toSorted().join('\0') !== fallbackKeys.toSorted().join('\0') ||
|
|
46
|
+
typeof raw.reason !== 'string' ||
|
|
47
|
+
!raw.reason) {
|
|
48
|
+
throw new Error('Fallback-only coverage transport control is invalid');
|
|
49
|
+
}
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
const presignedKeys = [
|
|
53
|
+
'blobs',
|
|
54
|
+
'coverage',
|
|
55
|
+
'expiresAt',
|
|
56
|
+
'mode',
|
|
57
|
+
'repository',
|
|
58
|
+
'revision',
|
|
59
|
+
'run',
|
|
60
|
+
'version',
|
|
61
|
+
];
|
|
62
|
+
if (raw.mode !== 'presigned' ||
|
|
63
|
+
Object.keys(raw).toSorted().join('\0') !== presignedKeys.toSorted().join('\0') ||
|
|
64
|
+
typeof raw.expiresAt !== 'string' ||
|
|
65
|
+
!Number.isFinite(Date.parse(raw.expiresAt))) {
|
|
66
|
+
throw new Error('Presigned coverage transport control is invalid');
|
|
67
|
+
}
|
|
68
|
+
assertUrlMap(raw.coverage, ['lcovGet', 'lcovPut', 'manifestGet', 'manifestPut'], 'Coverage');
|
|
69
|
+
assertUrlMap(raw.blobs, ['get', 'put'], 'Blob');
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
export function writeTransportControl(path, control) {
|
|
73
|
+
const validated = parseTransportControl(control);
|
|
74
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
77
|
+
flag: 'wx',
|
|
78
|
+
mode: 0o600,
|
|
79
|
+
});
|
|
80
|
+
chmodSync(temporary, 0o600);
|
|
81
|
+
renameSync(temporary, path);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(temporary);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// The write/rename failure is the actionable error.
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function readTransportControl(path, expected) {
|
|
94
|
+
if ((statSync(path).mode & 0o777) !== 0o600) {
|
|
95
|
+
throw new Error('Coverage transport control file must have mode 0600');
|
|
96
|
+
}
|
|
97
|
+
const control = parseTransportControl(JSON.parse(readFileSync(path, 'utf8')));
|
|
98
|
+
if (expected && !positiveInteger(expected.currentAttempt)) {
|
|
99
|
+
throw new Error('Coverage transport control identity does not match this run');
|
|
100
|
+
}
|
|
101
|
+
if (expected &&
|
|
102
|
+
(control.repository !== expected.repository ||
|
|
103
|
+
control.revision !== expected.revision ||
|
|
104
|
+
control.run.id !== expected.runId ||
|
|
105
|
+
control.run.controlAttempt > expected.currentAttempt)) {
|
|
106
|
+
throw new Error('Coverage transport control identity does not match this run');
|
|
107
|
+
}
|
|
108
|
+
if (control.mode === 'presigned' && Date.parse(control.expiresAt) <= Date.now()) {
|
|
109
|
+
throw new Error('Coverage transport control has expired');
|
|
110
|
+
}
|
|
111
|
+
return control;
|
|
112
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RequestOptions } from './control.mts';
|
|
2
|
+
export declare function redactTransportLog(value: unknown): string;
|
|
3
|
+
export declare function coveragePresignFailureLog(error: unknown): string;
|
|
4
|
+
export declare function logTransport(options: RequestOptions, line: string): void;
|
|
5
|
+
export declare function fetchPut(url: string, body: Buffer, options?: RequestOptions): Promise<boolean>;
|
|
6
|
+
export declare function fetchGet(url: string, options?: RequestOptions): Promise<Buffer | null>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DEFAULT_MAX_BODY_BYTES } from './constants.mjs';
|
|
2
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
3
|
+
const FETCH_ATTEMPTS = 2;
|
|
4
|
+
const DEFAULT_RETRY_DELAY_MS = 1000;
|
|
5
|
+
function formatTransportError(value, seen) {
|
|
6
|
+
if (typeof value === 'object' && value !== null) {
|
|
7
|
+
if (seen.has(value))
|
|
8
|
+
return '[circular]';
|
|
9
|
+
seen.add(value);
|
|
10
|
+
}
|
|
11
|
+
if (value instanceof Error) {
|
|
12
|
+
const cause = value.cause !== undefined ? `; cause: ${formatTransportError(value.cause, seen)}` : '';
|
|
13
|
+
return `${value.name}: ${value.message}${cause}`;
|
|
14
|
+
}
|
|
15
|
+
return String(value);
|
|
16
|
+
}
|
|
17
|
+
export function redactTransportLog(value) {
|
|
18
|
+
return formatTransportError(value, new WeakSet()).replaceAll(/https?:\/\/[^\s]+/gi, '[redacted-url]');
|
|
19
|
+
}
|
|
20
|
+
export function coveragePresignFailureLog(error) {
|
|
21
|
+
return `[coverage-transport] presign failed: ${redactTransportLog(error)}; artifact fallback required`;
|
|
22
|
+
}
|
|
23
|
+
export function logTransport(options, line) {
|
|
24
|
+
;
|
|
25
|
+
(options.log ?? ((message) => process.stderr.write(`${message}\n`)))(redactTransportLog(line));
|
|
26
|
+
}
|
|
27
|
+
async function delay(milliseconds) {
|
|
28
|
+
if (milliseconds <= 0)
|
|
29
|
+
return;
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
31
|
+
}
|
|
32
|
+
async function cancelBody(response) {
|
|
33
|
+
await response.body?.cancel();
|
|
34
|
+
}
|
|
35
|
+
async function readLimitedBody(response, maxBytes) {
|
|
36
|
+
if (response.body === null)
|
|
37
|
+
return Buffer.alloc(0);
|
|
38
|
+
const reader = response.body.getReader();
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done)
|
|
44
|
+
return Buffer.concat(chunks);
|
|
45
|
+
total += value.byteLength;
|
|
46
|
+
if (total > maxBytes) {
|
|
47
|
+
await reader.cancel();
|
|
48
|
+
throw new Error('[coverage-transport] GET body exceeds size limit');
|
|
49
|
+
}
|
|
50
|
+
chunks.push(value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function request(method, url, body, options) {
|
|
54
|
+
for (let attempt = 1; attempt <= FETCH_ATTEMPTS; attempt += 1) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? FETCH_TIMEOUT_MS);
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
...(body ? { body: new Uint8Array(body) } : {}),
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
// A missing presigned object is terminal. Let fetchGet yield null or fetchPut yield false
|
|
64
|
+
// without retrying the same URL or emitting a misleading transport-error diagnostic.
|
|
65
|
+
if (response.ok || response.status === 404) {
|
|
66
|
+
const payload = method === 'GET' && response.ok
|
|
67
|
+
? await readLimitedBody(response, options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES)
|
|
68
|
+
: undefined;
|
|
69
|
+
if (payload === undefined)
|
|
70
|
+
await cancelBody(response);
|
|
71
|
+
return { ok: response.ok, status: response.status, body: payload };
|
|
72
|
+
}
|
|
73
|
+
await cancelBody(response);
|
|
74
|
+
logTransport(options, `[coverage-transport] ${method} failed: HTTP ${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
logTransport(options, `[coverage-transport] ${method} error: ${redactTransportLog(error)}`);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
if (attempt < FETCH_ATTEMPTS) {
|
|
83
|
+
await delay(options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export async function fetchPut(url, body, options = {}) {
|
|
89
|
+
const response = await request('PUT', url, body, options);
|
|
90
|
+
return response?.ok === true;
|
|
91
|
+
}
|
|
92
|
+
export async function fetchGet(url, options = {}) {
|
|
93
|
+
const response = await request('GET', url, undefined, options);
|
|
94
|
+
if (response?.status === 404)
|
|
95
|
+
return null;
|
|
96
|
+
if (!response?.ok)
|
|
97
|
+
throw new Error('[coverage-transport] GET exhausted');
|
|
98
|
+
return response.body;
|
|
99
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DEFAULT_COVERAGE_MANIFEST_FILENAME, cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, } from './lib.mts';
|
|
2
|
+
export { parseTransportControl, readTransportControl, writeTransportControl, type ExpectedTransportIdentity, type FallbackOnlyTransportControl, type PresignedBlobUrls, type PresignedCoverageUrls, type PresignedTransportControl, type RequestOptions, type TransportControl, } from './control.mts';
|
|
3
|
+
export { coveragePresignFailureLog, fetchGet, fetchPut, logTransport, redactTransportLog, } from './http.mts';
|
|
4
|
+
export { assertCoverageTransportBlobOutcome, assertCoverageTransportOutcome, isBlobPrimaryState, isStepOutcome, writeUploadOutcomeOutput, type AppendOutput, type BlobPrimaryState, type StepOutcome, } from './outcome.mts';
|
|
5
|
+
export { downloadVitestBlobBundles, packVitestBlobBundle } from './vitest-blob-transport.mts';
|
|
6
|
+
export { mintPresignedControl, transportObjectKeys, type MintPresignedControlOptions, type ObjectSigner, type PresignIdentity, } from './presign.mts';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DEFAULT_COVERAGE_MANIFEST_FILENAME, cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, } from './lib.mjs';
|
|
2
|
+
export { parseTransportControl, readTransportControl, writeTransportControl, } from './control.mjs';
|
|
3
|
+
export { coveragePresignFailureLog, fetchGet, fetchPut, logTransport, redactTransportLog, } from './http.mjs';
|
|
4
|
+
export { assertCoverageTransportBlobOutcome, assertCoverageTransportOutcome, isBlobPrimaryState, isStepOutcome, writeUploadOutcomeOutput, } from './outcome.mjs';
|
|
5
|
+
export { downloadVitestBlobBundles, packVitestBlobBundle } from './vitest-blob-transport.mjs';
|
|
6
|
+
export { mintPresignedControl, transportObjectKeys, } from './presign.mjs';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ExpectedTransportIdentity, type RequestOptions } from './control.mts';
|
|
2
|
+
export { DEFAULT_COVERAGE_MANIFEST_FILENAME } from './constants.mts';
|
|
3
|
+
interface UploadOptions extends RequestOptions {
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
readonly expectedIdentity: ExpectedTransportIdentity;
|
|
6
|
+
readonly coverageManifestFilename?: string;
|
|
7
|
+
}
|
|
8
|
+
interface DownloadOptions extends RequestOptions {
|
|
9
|
+
readonly expectedIdentity: ExpectedTransportIdentity;
|
|
10
|
+
readonly coverageManifestFilename?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function cmdUpload(controlPath: string, suite: string, options: UploadOptions): Promise<{
|
|
13
|
+
coverage: boolean;
|
|
14
|
+
blob: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function cmdDownloadCoverage(controlPath: string, destinationRoot: string, options: DownloadOptions): Promise<void>;
|
|
17
|
+
export declare function cmdDownloadVitestBlobs(controlPath: string, destinationRoot: string, options: DownloadOptions): Promise<void>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DEFAULT_COVERAGE_MANIFEST_FILENAME, DEFAULT_MAX_BODY_BYTES, assertCoverageManifestFilename, } from './constants.mjs';
|
|
5
|
+
import { readTransportControl, } from './control.mjs';
|
|
6
|
+
import { fetchGet, fetchPut, logTransport } from './http.mjs';
|
|
7
|
+
import { downloadVitestBlobBundles, packVitestBlobBundle } from './vitest-blob-transport.mjs';
|
|
8
|
+
export { DEFAULT_COVERAGE_MANIFEST_FILENAME } from './constants.mjs';
|
|
9
|
+
export async function cmdUpload(controlPath, suite, options) {
|
|
10
|
+
const control = readTransportControl(controlPath, options.expectedIdentity);
|
|
11
|
+
if (control.mode === 'fallback-only') {
|
|
12
|
+
logTransport(options, `[coverage-transport] S3 unavailable for ${suite}; artifact fallback required`);
|
|
13
|
+
return { coverage: false, blob: false };
|
|
14
|
+
}
|
|
15
|
+
const cwd = options.cwd ?? process.cwd();
|
|
16
|
+
const manifestFilename = options.coverageManifestFilename ?? DEFAULT_COVERAGE_MANIFEST_FILENAME;
|
|
17
|
+
assertCoverageManifestFilename(manifestFilename);
|
|
18
|
+
const coverageUrls = control.coverage[suite];
|
|
19
|
+
const lcovPath = join(cwd, 'coverage', 'lcov.info');
|
|
20
|
+
const manifestPath = join(cwd, 'coverage', manifestFilename);
|
|
21
|
+
const maxBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
22
|
+
const lcov = coverageUrls && existsSync(lcovPath) && existsSync(manifestPath)
|
|
23
|
+
? await readFile(lcovPath)
|
|
24
|
+
: null;
|
|
25
|
+
const manifest = lcov === null ? null : await readFile(manifestPath);
|
|
26
|
+
const lcovStored = coverageUrls &&
|
|
27
|
+
lcov !== null &&
|
|
28
|
+
manifest !== null &&
|
|
29
|
+
lcov.byteLength <= maxBytes &&
|
|
30
|
+
manifest.byteLength <= maxBytes
|
|
31
|
+
? await fetchPut(coverageUrls.lcovPut, lcov, options)
|
|
32
|
+
: false;
|
|
33
|
+
const coverage = lcovStored && coverageUrls && manifest !== null && manifest.byteLength <= maxBytes
|
|
34
|
+
? await fetchPut(coverageUrls.manifestPut, manifest, options)
|
|
35
|
+
: false;
|
|
36
|
+
if (coverageUrls && existsSync(lcovPath) && existsSync(manifestPath)) {
|
|
37
|
+
logTransport(options, coverage
|
|
38
|
+
? `[coverage-transport] Uploaded coverage pair for ${suite}`
|
|
39
|
+
: `[coverage-transport] Coverage pair upload failed for ${suite}`);
|
|
40
|
+
}
|
|
41
|
+
const blobUrls = control.blobs[suite];
|
|
42
|
+
const blobData = blobUrls
|
|
43
|
+
? packVitestBlobBundle(cwd, suite, options.expectedIdentity, options)
|
|
44
|
+
: null;
|
|
45
|
+
const blob = Boolean(blobUrls && blobData && (await fetchPut(blobUrls.put, blobData, options)));
|
|
46
|
+
if (blob)
|
|
47
|
+
logTransport(options, `[coverage-transport] Uploaded vitest blob for ${suite}`);
|
|
48
|
+
else if (blobUrls && blobData) {
|
|
49
|
+
logTransport(options, `[coverage-transport] Vitest blob upload failed for ${suite}`);
|
|
50
|
+
}
|
|
51
|
+
return { coverage, blob };
|
|
52
|
+
}
|
|
53
|
+
export async function cmdDownloadCoverage(controlPath, destinationRoot, options) {
|
|
54
|
+
const control = readTransportControl(controlPath, options.expectedIdentity);
|
|
55
|
+
if (control.mode === 'fallback-only') {
|
|
56
|
+
logTransport(options, '[coverage-transport] S3 unavailable; artifact fallback required');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const manifestFilename = options.coverageManifestFilename ?? DEFAULT_COVERAGE_MANIFEST_FILENAME;
|
|
60
|
+
assertCoverageManifestFilename(manifestFilename);
|
|
61
|
+
await Promise.all(Object.entries(control.coverage).map(async ([suite, urls]) => {
|
|
62
|
+
const [lcov, manifest] = await Promise.all([
|
|
63
|
+
fetchGet(urls.lcovGet, options),
|
|
64
|
+
fetchGet(urls.manifestGet, options),
|
|
65
|
+
]);
|
|
66
|
+
if (!lcov || !manifest) {
|
|
67
|
+
logTransport(options, `[coverage-transport] Skipped incomplete coverage pair for ${suite}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const destination = join(destinationRoot, `coverage-${suite}`);
|
|
71
|
+
mkdirSync(destination, { recursive: true });
|
|
72
|
+
writeFileSync(join(destination, 'lcov.info'), lcov);
|
|
73
|
+
writeFileSync(join(destination, manifestFilename), manifest, { mode: 0o600 });
|
|
74
|
+
logTransport(options, `[coverage-transport] Downloaded coverage pair for ${suite}`);
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
export async function cmdDownloadVitestBlobs(controlPath, destinationRoot, options) {
|
|
78
|
+
const control = readTransportControl(controlPath, options.expectedIdentity);
|
|
79
|
+
if (control.mode === 'fallback-only') {
|
|
80
|
+
logTransport(options, '[coverage-transport] S3 unavailable; artifact fallback required');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await downloadVitestBlobBundles(control.blobs, destinationRoot, options);
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport exhaustion guards for a coverage pair and a Vitest blob, plus the
|
|
3
|
+
* `$GITHUB_OUTPUT` writer producers use when the upload exit code is not the blob signal.
|
|
4
|
+
*/
|
|
5
|
+
export type StepOutcome = 'success' | 'failure' | 'cancelled' | 'skipped';
|
|
6
|
+
/**
|
|
7
|
+
* CLI argv values arrive as plain strings (or `undefined` when a positional arg was omitted); this
|
|
8
|
+
* narrows to `StepOutcome` so a caller can validate a step-outcome argument without an unchecked
|
|
9
|
+
* `as` cast.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isStepOutcome(value: string | undefined): value is StepOutcome;
|
|
12
|
+
export declare function assertCoverageTransportOutcome(suite: string, primary: StepOutcome, artifactAttempt1: StepOutcome, artifactAttempt2: StepOutcome, emit?: (line: string) => void): boolean;
|
|
13
|
+
export type BlobPrimaryState = 'true' | 'false' | 'skipped';
|
|
14
|
+
export declare function isBlobPrimaryState(value: string | undefined): value is BlobPrimaryState;
|
|
15
|
+
/**
|
|
16
|
+
* Sibling to `assertCoverageTransportOutcome` for the Vitest blob. `true`/`false` are the S3
|
|
17
|
+
* upload step's `blob` output; `skipped` means that step never ran. GitHub fallback is always
|
|
18
|
+
* attempted when enabled — workflows must not gate it on `blob != 'true'`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertCoverageTransportBlobOutcome(suite: string, primaryPersisted: BlobPrimaryState, artifactAttempt1: StepOutcome, artifactAttempt2: StepOutcome, emit?: (line: string) => void): boolean;
|
|
21
|
+
export type AppendOutput = (path: string, data: string) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Writes `blob=true|false` to `$GITHUB_OUTPUT` for outcome reporting. GitHub-fallback blob upload
|
|
24
|
+
* must always be attempted when enabled; do not gate it on this signal. The upload subcommand's
|
|
25
|
+
* exit code tracks the coverage pair, not the blob. A no-op outside CI (`githubOutputPath` unset).
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeUploadOutcomeOutput(outcome: {
|
|
28
|
+
readonly blob: boolean;
|
|
29
|
+
}, githubOutputPath: string | undefined, appendOutput?: AppendOutput): void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
const STEP_OUTCOMES = new Set([
|
|
3
|
+
'success',
|
|
4
|
+
'failure',
|
|
5
|
+
'cancelled',
|
|
6
|
+
'skipped',
|
|
7
|
+
]);
|
|
8
|
+
/**
|
|
9
|
+
* CLI argv values arrive as plain strings (or `undefined` when a positional arg was omitted); this
|
|
10
|
+
* narrows to `StepOutcome` so a caller can validate a step-outcome argument without an unchecked
|
|
11
|
+
* `as` cast.
|
|
12
|
+
*/
|
|
13
|
+
export function isStepOutcome(value) {
|
|
14
|
+
return value !== undefined && STEP_OUTCOMES.has(value);
|
|
15
|
+
}
|
|
16
|
+
export function assertCoverageTransportOutcome(suite, primary, artifactAttempt1, artifactAttempt2, emit = (line) => process.stderr.write(`${line}\n`)) {
|
|
17
|
+
const primarySucceeded = primary === 'success';
|
|
18
|
+
const artifactSucceeded = artifactAttempt1 === 'success' || artifactAttempt2 === 'success';
|
|
19
|
+
if (primarySucceeded && artifactSucceeded)
|
|
20
|
+
return true;
|
|
21
|
+
if (primarySucceeded) {
|
|
22
|
+
emit(`::warning::Coverage persisted only to S3 for suite=${suite}; GitHub artifact fallback is degraded.`);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (artifactSucceeded) {
|
|
26
|
+
emit(`::warning::Coverage persisted only to GitHub artifacts for suite=${suite}; S3 primary is degraded.`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
emit(`::error::COVERAGE_TRANSPORT_EXHAUSTED suite=${suite} Neither S3 nor GitHub artifacts persisted the coverage pair.`);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const BLOB_PRIMARY_STATES = new Set([
|
|
33
|
+
'true',
|
|
34
|
+
'false',
|
|
35
|
+
'skipped',
|
|
36
|
+
]);
|
|
37
|
+
export function isBlobPrimaryState(value) {
|
|
38
|
+
return value !== undefined && BLOB_PRIMARY_STATES.has(value);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sibling to `assertCoverageTransportOutcome` for the Vitest blob. `true`/`false` are the S3
|
|
42
|
+
* upload step's `blob` output; `skipped` means that step never ran. GitHub fallback is always
|
|
43
|
+
* attempted when enabled — workflows must not gate it on `blob != 'true'`.
|
|
44
|
+
*/
|
|
45
|
+
export function assertCoverageTransportBlobOutcome(suite, primaryPersisted, artifactAttempt1, artifactAttempt2, emit = (line) => process.stderr.write(`${line}\n`)) {
|
|
46
|
+
const artifactSucceeded = artifactAttempt1 === 'success' || artifactAttempt2 === 'success';
|
|
47
|
+
if (primaryPersisted === 'true') {
|
|
48
|
+
if (!artifactSucceeded) {
|
|
49
|
+
emit(`::warning::Vitest blob persisted only to S3 for suite=${suite}; GitHub artifact fallback is degraded.`);
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (artifactSucceeded) {
|
|
54
|
+
if (primaryPersisted === 'false') {
|
|
55
|
+
emit(`::warning::Vitest blob persisted only to GitHub artifacts for suite=${suite}; S3 primary is degraded.`);
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
emit(`::error::COVERAGE_TRANSPORT_BLOB_EXHAUSTED suite=${suite} Neither S3 nor GitHub artifacts persisted the vitest blob.`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Writes `blob=true|false` to `$GITHUB_OUTPUT` for outcome reporting. GitHub-fallback blob upload
|
|
64
|
+
* must always be attempted when enabled; do not gate it on this signal. The upload subcommand's
|
|
65
|
+
* exit code tracks the coverage pair, not the blob. A no-op outside CI (`githubOutputPath` unset).
|
|
66
|
+
*/
|
|
67
|
+
export function writeUploadOutcomeOutput(outcome, githubOutputPath, appendOutput = appendFileSync) {
|
|
68
|
+
if (githubOutputPath)
|
|
69
|
+
appendOutput(githubOutputPath, `blob=${String(outcome.blob)}\n`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PresignedTransportControl } from './control.mts';
|
|
2
|
+
export interface PresignIdentity {
|
|
3
|
+
readonly repository: string;
|
|
4
|
+
readonly revision: string;
|
|
5
|
+
readonly runId: string;
|
|
6
|
+
readonly controlAttempt: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ObjectSigner {
|
|
9
|
+
signPut(key: string, ttlSeconds: number): Promise<string>;
|
|
10
|
+
signGet(key: string, ttlSeconds: number): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
export interface MintPresignedControlOptions {
|
|
13
|
+
readonly ttlSeconds?: number;
|
|
14
|
+
readonly now?: () => Date;
|
|
15
|
+
readonly manifestFilename?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function transportObjectKeys(repository: string, runId: string, controlAttempt: number, suite: string, manifestFilename?: string): {
|
|
18
|
+
readonly lcov: string;
|
|
19
|
+
readonly manifest: string;
|
|
20
|
+
readonly blob: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function mintPresignedControl(identity: PresignIdentity, coverageSuites: readonly string[], blobSuites: readonly string[], signer: ObjectSigner, options?: MintPresignedControlOptions): Promise<PresignedTransportControl>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { assertCoverageManifestFilename, DEFAULT_COVERAGE_MANIFEST_FILENAME } from './constants.mjs';
|
|
2
|
+
const DEFAULT_PRESIGN_TTL_SECONDS = 14_400;
|
|
3
|
+
export function transportObjectKeys(repository, runId, controlAttempt, suite, manifestFilename = DEFAULT_COVERAGE_MANIFEST_FILENAME) {
|
|
4
|
+
assertCoverageManifestFilename(manifestFilename);
|
|
5
|
+
if (repository.includes('..') ||
|
|
6
|
+
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
|
7
|
+
!/^[1-9][0-9]*$/.test(runId) ||
|
|
8
|
+
!Number.isSafeInteger(controlAttempt) ||
|
|
9
|
+
controlAttempt < 1 ||
|
|
10
|
+
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(suite)) {
|
|
11
|
+
throw new Error('Coverage transport key identity is invalid');
|
|
12
|
+
}
|
|
13
|
+
const prefix = `coverage-transport/${repository}/${runId}/${controlAttempt}`;
|
|
14
|
+
return {
|
|
15
|
+
lcov: `${prefix}/coverage/${suite}/lcov.info`,
|
|
16
|
+
manifest: `${prefix}/coverage/${suite}/${manifestFilename}`,
|
|
17
|
+
blob: `${prefix}/blobs/${suite}.tar.gz`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export async function mintPresignedControl(identity, coverageSuites, blobSuites, signer, options = {}) {
|
|
21
|
+
const ttlSeconds = options.ttlSeconds ?? DEFAULT_PRESIGN_TTL_SECONDS;
|
|
22
|
+
const expiresAt = new Date((options.now?.() ?? new Date()).getTime() + ttlSeconds * 1000);
|
|
23
|
+
const manifestFilename = options.manifestFilename ?? DEFAULT_COVERAGE_MANIFEST_FILENAME;
|
|
24
|
+
const control = {
|
|
25
|
+
version: 1,
|
|
26
|
+
mode: 'presigned',
|
|
27
|
+
repository: identity.repository,
|
|
28
|
+
revision: identity.revision,
|
|
29
|
+
run: { id: identity.runId, controlAttempt: identity.controlAttempt },
|
|
30
|
+
expiresAt: expiresAt.toISOString(),
|
|
31
|
+
coverage: {},
|
|
32
|
+
blobs: {},
|
|
33
|
+
};
|
|
34
|
+
for (const suite of coverageSuites) {
|
|
35
|
+
const keys = transportObjectKeys(identity.repository, identity.runId, identity.controlAttempt, suite, manifestFilename);
|
|
36
|
+
control.coverage[suite] = {
|
|
37
|
+
lcovPut: await signer.signPut(keys.lcov, ttlSeconds),
|
|
38
|
+
lcovGet: await signer.signGet(keys.lcov, ttlSeconds),
|
|
39
|
+
manifestPut: await signer.signPut(keys.manifest, ttlSeconds),
|
|
40
|
+
manifestGet: await signer.signGet(keys.manifest, ttlSeconds),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
for (const suite of blobSuites) {
|
|
44
|
+
const key = transportObjectKeys(identity.repository, identity.runId, identity.controlAttempt, suite, manifestFilename).blob;
|
|
45
|
+
control.blobs[suite] = {
|
|
46
|
+
put: await signer.signPut(key, ttlSeconds),
|
|
47
|
+
get: await signer.signGet(key, ttlSeconds),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return control;
|
|
51
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExpectedTransportIdentity, PresignedBlobUrls, RequestOptions } from './control.mts';
|
|
2
|
+
export declare function packVitestBlobBundle(cwd: string, suite: string, identity: ExpectedTransportIdentity, options: RequestOptions): Buffer | null;
|
|
3
|
+
export declare function tarVerboseMemberSize(line: string): number;
|
|
4
|
+
export declare function assertTarMemberSizes(verboseLines: readonly string[], maxMemberBytes?: number): void;
|
|
5
|
+
export declare function downloadVitestBlobBundles(blobs: Readonly<Record<string, PresignedBlobUrls>>, destinationRoot: string, options: RequestOptions): Promise<void>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fetchGet, logTransport, redactTransportLog } from './http.mjs';
|
|
6
|
+
import { inspectVitestBlobBundle, VITEST_BLOB_MANIFEST_FILENAME, VITEST_SUITE_PATTERN, vitestBlobBundlePaths, writeVitestBlobManifest, } from '../vitest-blob-manifest/index.mjs';
|
|
7
|
+
function archiveWorkspace(suite) {
|
|
8
|
+
assertVitestSuite(suite);
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), `ct-blob-${suite}-`));
|
|
10
|
+
chmodSync(root, 0o700);
|
|
11
|
+
return { root, archive: join(root, 'bundle.tar.gz') };
|
|
12
|
+
}
|
|
13
|
+
function assertVitestSuite(suite) {
|
|
14
|
+
if (!VITEST_SUITE_PATTERN.test(suite))
|
|
15
|
+
throw new Error('Invalid Vitest suite');
|
|
16
|
+
}
|
|
17
|
+
export function packVitestBlobBundle(cwd, suite, identity, options) {
|
|
18
|
+
const directory = join(cwd, '.vitest-reports');
|
|
19
|
+
const { root, archive } = archiveWorkspace(suite);
|
|
20
|
+
try {
|
|
21
|
+
writeVitestBlobManifest(directory, {
|
|
22
|
+
suite,
|
|
23
|
+
repository: identity.repository,
|
|
24
|
+
revision: identity.revision,
|
|
25
|
+
runId: identity.runId,
|
|
26
|
+
runAttempt: identity.currentAttempt,
|
|
27
|
+
});
|
|
28
|
+
const paths = vitestBlobBundlePaths(directory, suite);
|
|
29
|
+
const limit = options.maxMemberBytes ?? MAX_VITEST_BLOB_MEMBER_BYTES;
|
|
30
|
+
if (paths.some((path) => statSync(path).size > limit)) {
|
|
31
|
+
throw new Error('Vitest blob member exceeds size limit');
|
|
32
|
+
}
|
|
33
|
+
const names = paths.map((path) => basename(path));
|
|
34
|
+
execFileSync('tar', ['czf', archive, '-C', directory, ...names], {
|
|
35
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
36
|
+
});
|
|
37
|
+
return readFileSync(archive);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
logTransport(options, `[coverage-transport] vitest blob pack failed: ${redactTransportLog(error)}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
rmSync(root, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function tarVerboseMemberSize(line) {
|
|
48
|
+
const match = line.match(/\s(\d+)\s+\d{4}-\d{2}-\d{2}(?:\s|$)/) ??
|
|
49
|
+
line.match(/\s(\d+)\s+[A-Z][a-z]{2}\s+\d{1,2}\s/);
|
|
50
|
+
const size = match === null ? Number.NaN : Number(match[1]);
|
|
51
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
52
|
+
throw new Error('Vitest blob archive listing is malformed');
|
|
53
|
+
}
|
|
54
|
+
return size;
|
|
55
|
+
}
|
|
56
|
+
const MAX_VITEST_BLOB_MEMBER_BYTES = 32 * 1024 * 1024;
|
|
57
|
+
export function assertTarMemberSizes(verboseLines, maxMemberBytes = MAX_VITEST_BLOB_MEMBER_BYTES) {
|
|
58
|
+
if (verboseLines.some((line) => tarVerboseMemberSize(line) > maxMemberBytes)) {
|
|
59
|
+
throw new Error('Vitest blob archive exceeds the member size limit');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function validateArchive(archive, suite, maxMemberBytes) {
|
|
63
|
+
const entries = execFileSync('tar', ['tzf', archive], { encoding: 'utf8' })
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
const expected = [VITEST_BLOB_MANIFEST_FILENAME, `${suite}.json`].toSorted();
|
|
67
|
+
if (entries.length !== 2 || entries.toSorted().join('\0') !== expected.join('\0')) {
|
|
68
|
+
throw new Error(`Vitest blob archive for ${suite} has unexpected entries`);
|
|
69
|
+
}
|
|
70
|
+
const verbose = execFileSync('tar', ['tvzf', archive], { encoding: 'utf8' })
|
|
71
|
+
.split('\n')
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
if (verbose.length !== 2 || verbose.some((line) => !line.startsWith('-'))) {
|
|
74
|
+
throw new Error(`Vitest blob archive for ${suite} must contain regular files`);
|
|
75
|
+
}
|
|
76
|
+
assertTarMemberSizes(verbose, maxMemberBytes);
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
function extractValidatedBundle(archive, destinationRoot, suite, maxMemberBytes) {
|
|
80
|
+
const entries = validateArchive(archive, suite, maxMemberBytes);
|
|
81
|
+
mkdirSync(destinationRoot, { recursive: true });
|
|
82
|
+
const temporary = mkdtempSync(join(destinationRoot, `.vitest-blob-${suite}-`));
|
|
83
|
+
try {
|
|
84
|
+
execFileSync('tar', ['xzf', archive, '-C', temporary, ...entries], {
|
|
85
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
86
|
+
});
|
|
87
|
+
for (const entry of entries)
|
|
88
|
+
chmodSync(join(temporary, entry), 0o600);
|
|
89
|
+
inspectVitestBlobBundle(temporary);
|
|
90
|
+
const destination = join(destinationRoot, `vitest-blob-${suite}`);
|
|
91
|
+
rmSync(destination, { recursive: true, force: true });
|
|
92
|
+
renameSync(temporary, destination);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function downloadVitestBlobBundles(blobs, destinationRoot, options) {
|
|
99
|
+
mkdirSync(destinationRoot, { recursive: true });
|
|
100
|
+
await Promise.all(Object.entries(blobs).map(async ([suite, urls]) => {
|
|
101
|
+
assertVitestSuite(suite);
|
|
102
|
+
const data = await fetchGet(urls.get, options);
|
|
103
|
+
if (!data) {
|
|
104
|
+
logTransport(options, `[coverage-transport] No vitest blob available for ${suite}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const { root, archive } = archiveWorkspace(suite);
|
|
108
|
+
const invalidMarker = join(destinationRoot, `.invalid-${suite}`);
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(archive, data, { flag: 'wx', mode: 0o600 });
|
|
111
|
+
extractValidatedBundle(archive, destinationRoot, suite, options.maxMemberBytes ?? MAX_VITEST_BLOB_MEMBER_BYTES);
|
|
112
|
+
rmSync(invalidMarker, { recursive: true, force: true });
|
|
113
|
+
logTransport(options, `[coverage-transport] Downloaded vitest blob for ${suite}`);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
try {
|
|
117
|
+
rmSync(invalidMarker, { force: true });
|
|
118
|
+
writeFileSync(invalidMarker, 'invalid archive\n', { flag: 'wx', mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// The original archive failure is more actionable than a diagnostic-write failure.
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
rmSync(root, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}));
|
|
129
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -34,3 +34,5 @@ export type { AstGrepRuleInvocation, RunAstGrepRuleOptions } from './ast-grep-ru
|
|
|
34
34
|
export { bodyOnlyReviewFallback, indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mts';
|
|
35
35
|
export type { CommentableIndex, CommentableLine, LineKind, PayloadRequirement, ReviewComment, ReviewFile, ReviewSide, SanitizedReview, } from './gha-review-payload/index.mts';
|
|
36
36
|
export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mts';
|
|
37
|
+
export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mts';
|
|
38
|
+
export type { ExpectedTransportIdentity, ObjectSigner, PresignIdentity, TransportControl, } from './coverage-transport/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -20,3 +20,4 @@ export { MissingResponseBodyError, readResponseBody, readResponseBodyAsBuffer, R
|
|
|
20
20
|
export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mjs';
|
|
21
21
|
export { bodyOnlyReviewFallback, indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mjs';
|
|
22
22
|
export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mjs';
|
|
23
|
+
export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mjs';
|
|
@@ -14,5 +14,7 @@ export type DecisionResult = {
|
|
|
14
14
|
};
|
|
15
15
|
export interface EvaluateRulesOptions {
|
|
16
16
|
afterRuleEvaluated?: (context: RetryContext, rule: RetryRule) => Promise<void> | void;
|
|
17
|
+
/** Defaults to `no-match`. Use `dispatch` when unmatched work should leave the retry engine. */
|
|
18
|
+
unmatchedDecision?: 'no-match' | 'dispatch';
|
|
17
19
|
}
|
|
18
20
|
export declare function decide(context: RetryContext, rules: readonly RetryRule[], options?: EvaluateRulesOptions): Promise<DecisionResult>;
|
|
@@ -46,11 +46,17 @@ export async function decide(context, rules, options = {}) {
|
|
|
46
46
|
if (rule.decision !== undefined && rule.decision !== 'rerun') {
|
|
47
47
|
return { decision: rule.decision, matchedRule: rule.id };
|
|
48
48
|
}
|
|
49
|
+
if (rule.retryTarget === undefined) {
|
|
50
|
+
return { decision: 'rerun', matchedRule: rule.id, targetName: '' };
|
|
51
|
+
}
|
|
49
52
|
const resolved = resolveTargetName(rule.retryTarget, context);
|
|
50
53
|
if (resolved.targetName === undefined) {
|
|
51
54
|
return { decision: 'no-match', matchedRule: rule.id, reason: resolved.reason };
|
|
52
55
|
}
|
|
53
56
|
return { decision: 'rerun', matchedRule: rule.id, targetName: resolved.targetName };
|
|
54
57
|
}
|
|
58
|
+
if (options.unmatchedDecision === 'dispatch') {
|
|
59
|
+
return { decision: 'dispatch', matchedRule: '' };
|
|
60
|
+
}
|
|
55
61
|
return { decision: 'no-match', matchedRule: '', reason: 'no-rule' };
|
|
56
62
|
}
|
|
@@ -9,7 +9,7 @@ export interface RetryContext {
|
|
|
9
9
|
/** Targets that the provider resolved for this run. */
|
|
10
10
|
targetNames?: ReadonlySet<string>;
|
|
11
11
|
}
|
|
12
|
-
export type RetryDecision = 'rerun' | 'ignore' | 'fresh-plan' | 'reap-lock';
|
|
12
|
+
export type RetryDecision = 'rerun' | 'ignore' | 'fresh-plan' | 'reap-lock' | 'dispatch';
|
|
13
13
|
export type RetryTarget = {
|
|
14
14
|
targetName: string;
|
|
15
15
|
targetFamily?: never;
|
|
@@ -30,8 +30,8 @@ interface RetryRuleBase {
|
|
|
30
30
|
}
|
|
31
31
|
export type RetryRule = (RetryRuleBase & {
|
|
32
32
|
decision?: 'rerun';
|
|
33
|
-
/**
|
|
34
|
-
retryTarget
|
|
33
|
+
/** When omitted, the match is an untargeted provider-level rerun. */
|
|
34
|
+
retryTarget?: RetryTarget;
|
|
35
35
|
}) | (RetryRuleBase & {
|
|
36
36
|
decision: Exclude<RetryDecision, 'rerun'>;
|
|
37
37
|
retryTarget?: never;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -130,6 +130,11 @@
|
|
|
130
130
|
"import": "./dist/http-link-pagination/index.mjs",
|
|
131
131
|
"default": "./dist/http-link-pagination/index.mjs"
|
|
132
132
|
},
|
|
133
|
+
"./coverage-transport": {
|
|
134
|
+
"types": "./dist/coverage-transport/index.d.mts",
|
|
135
|
+
"import": "./dist/coverage-transport/index.mjs",
|
|
136
|
+
"default": "./dist/coverage-transport/index.mjs"
|
|
137
|
+
},
|
|
133
138
|
"./package.json": "./package.json"
|
|
134
139
|
},
|
|
135
140
|
"publishConfig": {
|