vouchington-tooling 0.1.7 → 0.1.9
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 +9 -0
- package/dist/gha-dependabot-automerge-action/index.test-helpers.d.mts +22 -0
- package/dist/gha-dependabot-automerge-action/index.test-helpers.mjs +113 -0
- package/dist/gha-post-review/github.d.mts +2 -0
- package/dist/gha-post-review/github.mjs +47 -5
- package/dist/gha-post-review/post.mjs +6 -1
- package/dist/pnpm-install/index.d.mts +2 -1
- package/dist/pnpm-install/index.mjs +2 -1
- package/dist/pnpm-install/install-operations.d.mts +4 -0
- package/dist/pnpm-install/install-operations.mjs +38 -0
- package/dist/pnpm-install/metadata-legacy.d.mts +4 -0
- package/dist/pnpm-install/metadata-legacy.mjs +81 -0
- package/dist/pnpm-install/metadata.d.mts +16 -3
- package/dist/pnpm-install/metadata.mjs +103 -36
- package/dist/pnpm-install/pending-builds.d.mts +17 -0
- package/dist/pnpm-install/pending-builds.mjs +87 -0
- package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.d.mts +1 -0
- package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +52 -0
- package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +6 -40
- package/dist/pnpm-install/runner.mjs +72 -56
- package/dist/pnpm-install/transition.d.mts +23 -0
- package/dist/pnpm-install/transition.mjs +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,15 @@ vouchington post-review
|
|
|
47
47
|
vouchington stage-review-payload optional|required <source> <destination>
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
For persistent `pnpm-install`, v4 metadata tracks structural inputs separately from the
|
|
51
|
+
`--install-scripts` policy. A warm scripts-enabled tree can therefore toggle
|
|
52
|
+
`true → false → true` without forced reconciliation; a tree first installed with scripts disabled
|
|
53
|
+
uses one script-suppressed verification install followed by `pnpm rebuild --pending --recursive`.
|
|
54
|
+
When only newly pending dependency package IDs remain, it instead rebuilds those exact IDs without
|
|
55
|
+
rerunning first-party workspace hooks.
|
|
56
|
+
The command emits a structured non-secret provenance diagnostic identifying changed structural
|
|
57
|
+
categories, the last script policy, script capability, and native-binary health.
|
|
58
|
+
|
|
50
59
|
`download-optional-run-artifacts` uses the current Actions run and host. Pattern mode discovers
|
|
51
60
|
non-expired artifacts across the run, keeps the first result for each name (matching `gh run
|
|
52
61
|
download`), and extracts each selected name into its own directory. Ordinary absence is reported as
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type DependencyUpdate = Record<'dependencyName' | 'directory' | 'newVersion' | 'packageEcosystem' | 'prevVersion' | 'updateType', string>;
|
|
2
|
+
export interface ScriptResult {
|
|
3
|
+
failures: string[];
|
|
4
|
+
infos: string[];
|
|
5
|
+
mutationRequests: Array<{
|
|
6
|
+
body: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
method: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}>;
|
|
11
|
+
restGetCalls: Array<Record<string, unknown>>;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function update(prevVersion: string, newVersion: string, updateType: string, dependencyName?: string): DependencyUpdate;
|
|
15
|
+
export declare function runPolicy(metadata: DependencyUpdate[] | string | undefined, pullRequestOverrides?: Record<string, unknown>, freshPullRequestOverrides?: Record<string, unknown>, mergeToken?: string | undefined, mutationResult?: {
|
|
16
|
+
jsonError?: Error;
|
|
17
|
+
payload?: unknown;
|
|
18
|
+
status?: number;
|
|
19
|
+
}, dependabot?: {
|
|
20
|
+
directory: string;
|
|
21
|
+
ecosystem: string;
|
|
22
|
+
}, expectedBase?: string, expectedHead?: string, manualRules?: string, confirmedPullRequestOverrides?: Error | Record<string, unknown>, initialRefreshError?: Error): Promise<ScriptResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as load } from 'yaml';
|
|
3
|
+
const source = readFileSync('.github/actions/dependabot-automerge/action.yml', 'utf8');
|
|
4
|
+
const action = load(source);
|
|
5
|
+
const script = action.runs.steps.find((step) => step.uses?.startsWith('actions/github-script@'))
|
|
6
|
+
?.with?.script;
|
|
7
|
+
export function update(prevVersion, newVersion, updateType, dependencyName = 'example') {
|
|
8
|
+
return {
|
|
9
|
+
dependencyName,
|
|
10
|
+
directory: '/',
|
|
11
|
+
newVersion,
|
|
12
|
+
packageEcosystem: 'npm',
|
|
13
|
+
prevVersion,
|
|
14
|
+
updateType,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export async function runPolicy(metadata, pullRequestOverrides = {}, freshPullRequestOverrides = {}, mergeToken = 'merge-token', mutationResult = {}, dependabot = { directory: '/', ecosystem: 'npm' }, expectedBase, expectedHead, manualRules = '[]', confirmedPullRequestOverrides = freshPullRequestOverrides, initialRefreshError) {
|
|
18
|
+
if (!script)
|
|
19
|
+
throw new Error('Dependabot auto-merge script is missing');
|
|
20
|
+
const failures = [];
|
|
21
|
+
const infos = [];
|
|
22
|
+
const mutationRequests = [];
|
|
23
|
+
const restGetCalls = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const eventPullRequest = {
|
|
26
|
+
auto_merge: null,
|
|
27
|
+
base: { ref: 'main', sha: 'a'.repeat(40) },
|
|
28
|
+
draft: false,
|
|
29
|
+
head: {
|
|
30
|
+
ref: 'dependabot/npm_and_yarn/example-1.5.0',
|
|
31
|
+
repo: { full_name: 'example-owner/example-repo' },
|
|
32
|
+
sha: 'b'.repeat(40),
|
|
33
|
+
},
|
|
34
|
+
node_id: 'PR_node_id',
|
|
35
|
+
number: 123,
|
|
36
|
+
merged: false,
|
|
37
|
+
state: 'open',
|
|
38
|
+
title: 'Bump dependencies',
|
|
39
|
+
user: { login: 'dependabot[bot]' },
|
|
40
|
+
...pullRequestOverrides,
|
|
41
|
+
};
|
|
42
|
+
const github = {
|
|
43
|
+
rest: {
|
|
44
|
+
pulls: {
|
|
45
|
+
async get(args) {
|
|
46
|
+
restGetCalls.push(args);
|
|
47
|
+
if (restGetCalls.length === 1 && initialRefreshError)
|
|
48
|
+
throw initialRefreshError;
|
|
49
|
+
if (restGetCalls.length > 1 && confirmedPullRequestOverrides instanceof Error)
|
|
50
|
+
throw confirmedPullRequestOverrides;
|
|
51
|
+
const overrides = restGetCalls.length === 1 || confirmedPullRequestOverrides instanceof Error
|
|
52
|
+
? freshPullRequestOverrides
|
|
53
|
+
: confirmedPullRequestOverrides;
|
|
54
|
+
return { data: { ...eventPullRequest, ...overrides } };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const context = {
|
|
60
|
+
payload: { pull_request: eventPullRequest, repository: { default_branch: 'main' } },
|
|
61
|
+
repo: { owner: 'example-owner', repo: 'example-repo' },
|
|
62
|
+
};
|
|
63
|
+
const core = {
|
|
64
|
+
setFailed(message) {
|
|
65
|
+
failures.push(message);
|
|
66
|
+
},
|
|
67
|
+
info(message) {
|
|
68
|
+
infos.push(message);
|
|
69
|
+
},
|
|
70
|
+
warning(message) {
|
|
71
|
+
warnings.push(message);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const environment = {
|
|
75
|
+
DEPENDABOT_DIRECTORY: dependabot.directory,
|
|
76
|
+
DEPENDABOT_ECOSYSTEM: dependabot.ecosystem,
|
|
77
|
+
EXPECTED_BASE_SHA: expectedBase ?? String(eventPullRequest.base.sha),
|
|
78
|
+
EXPECTED_HEAD_SHA: expectedHead ?? String(eventPullRequest.head.sha),
|
|
79
|
+
GRAPHQL_URL: 'https://github.example.test/api/graphql',
|
|
80
|
+
MANUAL_UPDATE_RULES: manualRules,
|
|
81
|
+
};
|
|
82
|
+
if (metadata !== undefined)
|
|
83
|
+
environment.UPDATED_DEPENDENCIES_JSON =
|
|
84
|
+
typeof metadata === 'string' ? metadata : JSON.stringify(metadata);
|
|
85
|
+
if (mergeToken !== undefined)
|
|
86
|
+
environment.AUTOMERGE_TOKEN = mergeToken;
|
|
87
|
+
const fetch = async (url, init) => {
|
|
88
|
+
if (typeof init.body !== 'string')
|
|
89
|
+
throw new TypeError('Dependabot auto-merge mutation body must be a string');
|
|
90
|
+
const body = init.body;
|
|
91
|
+
mutationRequests.push({
|
|
92
|
+
body,
|
|
93
|
+
headers: init.headers,
|
|
94
|
+
method: String(init.method),
|
|
95
|
+
url,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
async json() {
|
|
99
|
+
if (mutationResult.jsonError)
|
|
100
|
+
throw mutationResult.jsonError;
|
|
101
|
+
return (mutationResult.payload ??
|
|
102
|
+
(body.includes('disablePullRequestAutoMerge')
|
|
103
|
+
? { data: { disablePullRequestAutoMerge: { clientMutationId: null } } }
|
|
104
|
+
: { data: { enablePullRequestAutoMerge: { clientMutationId: null } } }));
|
|
105
|
+
},
|
|
106
|
+
ok: (mutationResult.status ?? 200) < 400,
|
|
107
|
+
status: mutationResult.status ?? 200,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor;
|
|
111
|
+
await new AsyncFunction('github', 'context', 'core', 'process', 'fetch', script)(github, context, core, { env: environment }, fetch);
|
|
112
|
+
return { failures, infos, mutationRequests, restGetCalls, warnings };
|
|
113
|
+
}
|
|
@@ -14,6 +14,8 @@ export declare function createGhPostReviewIo(options: {
|
|
|
14
14
|
payloadBytes: Buffer;
|
|
15
15
|
token: string;
|
|
16
16
|
exec: GhExec;
|
|
17
|
+
expectedHeadSha?: string;
|
|
18
|
+
expectedBaseSha?: string;
|
|
17
19
|
}): PostReviewIo;
|
|
18
20
|
export declare function postReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<{
|
|
19
21
|
posted: boolean;
|
|
@@ -33,8 +33,25 @@ export function writePostedOutput(posted, outputPath = process.env.GITHUB_OUTPUT
|
|
|
33
33
|
return;
|
|
34
34
|
appendFileSync(outputPath, `posted=${posted ? 'true' : 'false'}\n`);
|
|
35
35
|
}
|
|
36
|
+
function readPullRefs(repository, prNumber, exec) {
|
|
37
|
+
let lastError;
|
|
38
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
return exec([
|
|
41
|
+
'api',
|
|
42
|
+
`repos/${repository}/pulls/${prNumber}`,
|
|
43
|
+
'--jq',
|
|
44
|
+
'[.head.sha, .base.sha] | @tsv',
|
|
45
|
+
]).split('\t');
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
lastError = error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw lastError;
|
|
52
|
+
}
|
|
36
53
|
export function createGhPostReviewIo(options) {
|
|
37
|
-
const { repository, prNumber, payloadBytes, token, exec } = options;
|
|
54
|
+
const { repository, prNumber, payloadBytes, token, exec, expectedHeadSha, expectedBaseSha } = options;
|
|
38
55
|
return {
|
|
39
56
|
readFile() {
|
|
40
57
|
return payloadBytes;
|
|
@@ -43,11 +60,34 @@ export function createGhPostReviewIo(options) {
|
|
|
43
60
|
rmSync(path, { force: true });
|
|
44
61
|
},
|
|
45
62
|
getHeadSha() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
if (!/^[1-9][0-9]*$/u.test(prNumber)) {
|
|
64
|
+
throw new ReviewPayloadError('PR_NUMBER must be a positive integer.');
|
|
65
|
+
}
|
|
66
|
+
if (Boolean(expectedHeadSha) !== Boolean(expectedBaseSha)) {
|
|
67
|
+
throw new ReviewPayloadError('EXPECTED_HEAD_SHA and EXPECTED_BASE_SHA must be provided together.');
|
|
68
|
+
}
|
|
69
|
+
if (expectedHeadSha && !/^[0-9a-f]{40}$/u.test(expectedHeadSha)) {
|
|
70
|
+
throw new ReviewPayloadError('EXPECTED_HEAD_SHA must be a full lowercase commit SHA.');
|
|
71
|
+
}
|
|
72
|
+
if (expectedBaseSha && !/^[0-9a-f]{40}$/u.test(expectedBaseSha)) {
|
|
73
|
+
throw new ReviewPayloadError('EXPECTED_BASE_SHA must be a full lowercase commit SHA.');
|
|
74
|
+
}
|
|
75
|
+
const refs = readPullRefs(repository, prNumber, exec);
|
|
76
|
+
const [headSha = '', baseSha = ''] = refs;
|
|
77
|
+
if (!/^[0-9a-f]{40}$/u.test(headSha)) {
|
|
78
|
+
throw new ReviewPayloadError(`Could not resolve PR head SHA (got "${headSha}").`);
|
|
79
|
+
}
|
|
80
|
+
if (!/^[0-9a-f]{40}$/u.test(baseSha)) {
|
|
81
|
+
throw new ReviewPayloadError(`Could not resolve PR base SHA (got "${baseSha}").`);
|
|
82
|
+
}
|
|
83
|
+
if (expectedHeadSha && headSha !== expectedHeadSha) {
|
|
84
|
+
throw new ReviewPayloadError('PR head changed before posting the selected review.');
|
|
85
|
+
}
|
|
86
|
+
if (expectedBaseSha && baseSha !== expectedBaseSha) {
|
|
87
|
+
throw new ReviewPayloadError('PR base changed before posting the selected review.');
|
|
49
88
|
}
|
|
50
|
-
|
|
89
|
+
// Preserve the orchestrator-selected revision as the review commit_id after equality checks.
|
|
90
|
+
return expectedHeadSha || headSha;
|
|
51
91
|
},
|
|
52
92
|
listPullFiles() {
|
|
53
93
|
return parseReviewFilesJson(exec(['api', '--paginate', `repos/${repository}/pulls/${prNumber}/files?per_page=100`]));
|
|
@@ -69,6 +109,8 @@ export async function postReviewFromEnv(env = process.env, exec = createGhExec()
|
|
|
69
109
|
payloadBytes,
|
|
70
110
|
token,
|
|
71
111
|
exec,
|
|
112
|
+
expectedHeadSha: env.EXPECTED_HEAD_SHA ?? '',
|
|
113
|
+
expectedBaseSha: env.EXPECTED_BASE_SHA ?? '',
|
|
72
114
|
}));
|
|
73
115
|
const token = resolveReviewPostToken(env);
|
|
74
116
|
if (token.source === 'github-token')
|
|
@@ -2,13 +2,18 @@ import { ReviewPayloadError, indexReviewFiles, parseReviewPayload, remapReviewCo
|
|
|
2
2
|
export { MAX_REVIEW_COMMENTS as MAX_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES as MAX_PAYLOAD_BYTES, ReviewPayloadError as PostReviewError, } from '../gha-review-payload/index.mjs';
|
|
3
3
|
export function runPostReview(payloadPath, io) {
|
|
4
4
|
try {
|
|
5
|
-
|
|
5
|
+
const selectedHeadSha = io.getHeadSha();
|
|
6
|
+
let review = parseReviewPayload(io.readFile(payloadPath), selectedHeadSha);
|
|
6
7
|
try {
|
|
7
8
|
review = remapReviewComments(review, indexReviewFiles(io.listPullFiles()));
|
|
8
9
|
}
|
|
9
10
|
catch {
|
|
10
11
|
// Keep the parsed review when the PR file list is unavailable.
|
|
11
12
|
}
|
|
13
|
+
// The adapter also revalidates a pinned base; equality catches legacy live-head drift.
|
|
14
|
+
if (io.getHeadSha() !== selectedHeadSha) {
|
|
15
|
+
throw new ReviewPayloadError('PR head changed while preparing the selected review.');
|
|
16
|
+
}
|
|
12
17
|
const first = io.postReview(review);
|
|
13
18
|
if (first.ok)
|
|
14
19
|
return { posted: true };
|
|
@@ -6,4 +6,5 @@ export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolatio
|
|
|
6
6
|
export { flattenReleaseAgeSelectors, packageNameFromPnpmLockKey, pnpmLockPackageKeyMatchesSelector, validateReleaseAgeExemptionGroups, validateReleaseAgePolicy, } from './release-age-policy.mts';
|
|
7
7
|
export type { ReleaseAgeExemptionGroup, ReleaseAgePermanentExemption, ReleaseAgePolicyConfig, ReleaseAgePolicySnapshot, } from './release-age-policy.mts';
|
|
8
8
|
export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mts';
|
|
9
|
-
export { persistentDependencyTreeIsCold
|
|
9
|
+
export { persistentDependencyTreeIsCold } from './metadata.mts';
|
|
10
|
+
export { persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata-legacy.mts';
|
|
@@ -4,4 +4,5 @@ export { baseInstallArgs, findWorkspaceLinkMismatches, listWorkspaces, logWorksp
|
|
|
4
4
|
export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolations, } from './release-age.mjs';
|
|
5
5
|
export { flattenReleaseAgeSelectors, packageNameFromPnpmLockKey, pnpmLockPackageKeyMatchesSelector, validateReleaseAgeExemptionGroups, validateReleaseAgePolicy, } from './release-age-policy.mjs';
|
|
6
6
|
export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mjs';
|
|
7
|
-
export { persistentDependencyTreeIsCold
|
|
7
|
+
export { persistentDependencyTreeIsCold } from './metadata.mjs';
|
|
8
|
+
export { persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata-legacy.mjs';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type CommandResult, type InstallOptions } from './support.mts';
|
|
2
|
+
export declare function withScriptPolicy(args: string[], installScripts: boolean): string[];
|
|
3
|
+
export declare function install(args: string[], options: InstallOptions, label: string): Promise<void>;
|
|
4
|
+
export declare function reconcileOrFail(options: InstallOptions, runCapture: (args: string[]) => Promise<CommandResult>): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { scheduler } from 'node:timers/promises';
|
|
2
|
+
import { runPnpm } from './exec.mjs';
|
|
3
|
+
import { INSTALL_TERMINATION_FAILED } from './process.mjs';
|
|
4
|
+
import { formatReleaseAgeFailure, isReleaseAgeViolation } from './release-age.mjs';
|
|
5
|
+
import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
|
|
6
|
+
function fail(message) {
|
|
7
|
+
throw new Error(message);
|
|
8
|
+
}
|
|
9
|
+
export function withScriptPolicy(args, installScripts) {
|
|
10
|
+
return installScripts ? args : [...args, '--ignore-scripts'];
|
|
11
|
+
}
|
|
12
|
+
export async function install(args, options, label) {
|
|
13
|
+
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
14
|
+
const result = await runPnpm(args, options);
|
|
15
|
+
if (result.code === 0)
|
|
16
|
+
return;
|
|
17
|
+
if (result.code === INSTALL_TERMINATION_FAILED)
|
|
18
|
+
fail(`${label} could not terminate safely`);
|
|
19
|
+
const output = `${result.output}\n${result.errorOutput ?? ''}`;
|
|
20
|
+
if (isReleaseAgeViolation(output))
|
|
21
|
+
fail(formatReleaseAgeFailure(label, output));
|
|
22
|
+
if (attempt < options.maxAttempts) {
|
|
23
|
+
console.warn(`${label} failed (attempt ${attempt}/${options.maxAttempts}); retrying`);
|
|
24
|
+
await scheduler.wait(5000);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
fail(`${label} failed after ${options.maxAttempts} attempt${options.maxAttempts === 1 ? '' : 's'}`);
|
|
28
|
+
}
|
|
29
|
+
export async function reconcileOrFail(options, runCapture) {
|
|
30
|
+
const forced = ['install', '--frozen-lockfile', '--force', ...baseInstallArgs.slice(2)];
|
|
31
|
+
await install([...forced, '--ignore-scripts', '--ignore-pnpmfile'], options, 'script-free reconciliation');
|
|
32
|
+
await install(withScriptPolicy(forced, options.installScripts), options, 'strict persistent reconciliation');
|
|
33
|
+
const remaining = await findWorkspaceLinkMismatches(runCapture);
|
|
34
|
+
if (remaining.length > 0) {
|
|
35
|
+
logWorkspaceLinkMismatches(remaining);
|
|
36
|
+
fail('persistent reconciliation completed with invalid workspace links');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type CaptureCommand } from './support.mts';
|
|
2
|
+
export declare function persistentMetadataFingerprint(runCapture: CaptureCommand, installScripts: boolean): Promise<string>;
|
|
3
|
+
export declare function persistentMetadataMatches(fingerprint: string): Promise<boolean>;
|
|
4
|
+
export declare function writePersistentMetadataStamp(fingerprint: string): Promise<void>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { listWorkspaces, reportGlibcVersionRuntime } from './support.mjs';
|
|
5
|
+
function persistentMetadataStampPath() {
|
|
6
|
+
return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.v3.json');
|
|
7
|
+
}
|
|
8
|
+
function legacySharedStampPath() {
|
|
9
|
+
return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.json');
|
|
10
|
+
}
|
|
11
|
+
function addFingerprintInput(hash, label, value) {
|
|
12
|
+
hash.update(`${label.length}:${label}${value.length}:${value}`);
|
|
13
|
+
}
|
|
14
|
+
async function optionalFile(pathname) {
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(pathname, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return '';
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function runtimePlatformIdentity() {
|
|
25
|
+
const report = process.report?.getReport();
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
arch: process.arch,
|
|
28
|
+
glibc: reportGlibcVersionRuntime(report),
|
|
29
|
+
modules: process.versions.modules,
|
|
30
|
+
node: process.version,
|
|
31
|
+
npmConfigArch: process.env.npm_config_arch ?? '',
|
|
32
|
+
npmConfigLibc: process.env.npm_config_libc ?? '',
|
|
33
|
+
npmConfigPlatform: process.env.npm_config_platform ?? '',
|
|
34
|
+
platform: process.platform,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function persistentMetadataFingerprint(runCapture, installScripts) {
|
|
38
|
+
const workspaces = (await listWorkspaces(runCapture)).toSorted((left, right) => left.path.localeCompare(right.path));
|
|
39
|
+
const pnpmVersion = await runCapture(['--version']);
|
|
40
|
+
if (pnpmVersion.code !== 0)
|
|
41
|
+
throw new Error(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
|
|
42
|
+
const hash = createHash('sha256');
|
|
43
|
+
addFingerprintInput(hash, 'runtime', runtimePlatformIdentity());
|
|
44
|
+
addFingerprintInput(hash, 'pnpm', pnpmVersion.output.trim());
|
|
45
|
+
addFingerprintInput(hash, 'installScripts', String(installScripts));
|
|
46
|
+
for (const filename of [
|
|
47
|
+
'pnpm-lock.yaml',
|
|
48
|
+
'pnpm-workspace.yaml',
|
|
49
|
+
'.npmrc',
|
|
50
|
+
'.pnpmfile.cjs',
|
|
51
|
+
'.pnpmfile.mjs',
|
|
52
|
+
])
|
|
53
|
+
addFingerprintInput(hash, filename, await optionalFile(path.join(process.cwd(), filename)));
|
|
54
|
+
for (const workspace of workspaces)
|
|
55
|
+
addFingerprintInput(hash, path.relative(process.cwd(), path.join(workspace.path, 'package.json')), await readFile(path.join(workspace.path, 'package.json'), 'utf8'));
|
|
56
|
+
return hash.digest('hex');
|
|
57
|
+
}
|
|
58
|
+
export async function persistentMetadataMatches(fingerprint) {
|
|
59
|
+
for (const filename of [persistentMetadataStampPath(), legacySharedStampPath()]) {
|
|
60
|
+
try {
|
|
61
|
+
const stamp = JSON.parse(await readFile(filename, 'utf8'));
|
|
62
|
+
if (stamp.version === 3 && stamp.fingerprint === fingerprint)
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
export async function writePersistentMetadataStamp(fingerprint) {
|
|
70
|
+
const stampPath = persistentMetadataStampPath();
|
|
71
|
+
const directory = path.dirname(stampPath);
|
|
72
|
+
const temporary = `${stampPath}.${process.pid}.tmp`;
|
|
73
|
+
await mkdir(directory, { recursive: true });
|
|
74
|
+
try {
|
|
75
|
+
await writeFile(temporary, `${JSON.stringify({ fingerprint, version: 3 })}\n`);
|
|
76
|
+
await rename(temporary, stampPath);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await rm(temporary, { force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { type CaptureCommand } from './support.mts';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { type ProvenanceStatus } from './transition.mts';
|
|
3
|
+
declare const componentNames: readonly ['lockfile', 'npm-config', 'pnpm', 'pnpmfiles', 'runtime', 'workspace-config', 'workspace-manifests'];
|
|
4
|
+
type ComponentName = (typeof componentNames)[number];
|
|
5
|
+
type StructuralProvenance = Record<ComponentName, string>;
|
|
6
|
+
export declare function persistentMetadataFingerprintV4(runCapture: CaptureCommand): Promise<{
|
|
7
|
+
lockfile: string;
|
|
8
|
+
'npm-config': string;
|
|
9
|
+
pnpm: string;
|
|
10
|
+
pnpmfiles: string;
|
|
11
|
+
runtime: string;
|
|
12
|
+
'workspace-config': string;
|
|
13
|
+
'workspace-manifests': string;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function persistentMetadataStatusV4(provenance: StructuralProvenance): Promise<ProvenanceStatus>;
|
|
16
|
+
export declare function writePersistentMetadataStampV4(provenance: StructuralProvenance, installScripts: boolean, resetScriptsEnabledCapability: boolean, pendingDependencyBuilds?: string[]): Promise<void>;
|
|
4
17
|
export declare function persistentDependencyTreeIsCold(): Promise<boolean>;
|
|
5
|
-
export
|
|
18
|
+
export {};
|
|
@@ -2,15 +2,28 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listWorkspaces, reportGlibcVersionRuntime } from './support.mjs';
|
|
5
|
+
import {} from './transition.mjs';
|
|
6
|
+
const componentNames = [
|
|
7
|
+
'lockfile',
|
|
8
|
+
'npm-config',
|
|
9
|
+
'pnpm',
|
|
10
|
+
'pnpmfiles',
|
|
11
|
+
'runtime',
|
|
12
|
+
'workspace-config',
|
|
13
|
+
'workspace-manifests',
|
|
14
|
+
];
|
|
5
15
|
function persistentMetadataStampPath() {
|
|
6
16
|
return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.json');
|
|
7
17
|
}
|
|
8
|
-
function fail(message) {
|
|
9
|
-
throw new Error(message);
|
|
10
|
-
}
|
|
11
18
|
function addFingerprintInput(hash, label, value) {
|
|
12
19
|
hash.update(`${label.length}:${label}${value.length}:${value}`);
|
|
13
20
|
}
|
|
21
|
+
function componentHash(inputs) {
|
|
22
|
+
const hash = createHash('sha256');
|
|
23
|
+
for (const [label, value] of inputs)
|
|
24
|
+
addFingerprintInput(hash, label, value);
|
|
25
|
+
return hash.digest('hex');
|
|
26
|
+
}
|
|
14
27
|
async function optionalFile(pathname) {
|
|
15
28
|
try {
|
|
16
29
|
return await readFile(pathname, 'utf8');
|
|
@@ -34,58 +47,112 @@ function runtimePlatformIdentity() {
|
|
|
34
47
|
platform: process.platform,
|
|
35
48
|
});
|
|
36
49
|
}
|
|
37
|
-
export async function
|
|
50
|
+
export async function persistentMetadataFingerprintV4(runCapture) {
|
|
38
51
|
const workspaces = (await listWorkspaces(runCapture)).toSorted((left, right) => left.path.localeCompare(right.path));
|
|
39
52
|
const pnpmVersion = await runCapture(['--version']);
|
|
40
53
|
if (pnpmVersion.code !== 0)
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'pnpm-
|
|
49
|
-
'.npmrc',
|
|
50
|
-
'.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
throw new Error(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
|
|
55
|
+
const files = new Map(await Promise.all(['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs'].map(async (filename) => [filename, await optionalFile(path.join(process.cwd(), filename))])));
|
|
56
|
+
const manifests = await Promise.all(workspaces.map(async (workspace) => {
|
|
57
|
+
const filename = path.relative(process.cwd(), path.join(workspace.path, 'package.json'));
|
|
58
|
+
return [filename, await readFile(path.join(workspace.path, 'package.json'), 'utf8')];
|
|
59
|
+
}));
|
|
60
|
+
return {
|
|
61
|
+
lockfile: componentHash([['pnpm-lock.yaml', files.get('pnpm-lock.yaml')]]),
|
|
62
|
+
'npm-config': componentHash([['.npmrc', files.get('.npmrc')]]),
|
|
63
|
+
pnpm: componentHash([['version', pnpmVersion.output.trim()]]),
|
|
64
|
+
pnpmfiles: componentHash([
|
|
65
|
+
['.pnpmfile.cjs', files.get('.pnpmfile.cjs')],
|
|
66
|
+
['.pnpmfile.mjs', files.get('.pnpmfile.mjs')],
|
|
67
|
+
]),
|
|
68
|
+
runtime: componentHash([['identity', runtimePlatformIdentity()]]),
|
|
69
|
+
'workspace-config': componentHash([['pnpm-workspace.yaml', files.get('pnpm-workspace.yaml')]]),
|
|
70
|
+
'workspace-manifests': componentHash(manifests),
|
|
71
|
+
};
|
|
57
72
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const stamp = JSON.parse(await readFile(persistentMetadataStampPath(), 'utf8'));
|
|
61
|
-
return stamp.version === 3 && stamp.fingerprint === fingerprint;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
73
|
+
function validStamp(value) {
|
|
74
|
+
if (typeof value !== 'object' || value === null)
|
|
64
75
|
return false;
|
|
65
|
-
|
|
76
|
+
const stamp = value;
|
|
77
|
+
return (stamp.version === 4 &&
|
|
78
|
+
typeof stamp.lastInvocationInstallScripts === 'boolean' &&
|
|
79
|
+
typeof stamp.scriptsEnabledInstallSucceeded === 'boolean' &&
|
|
80
|
+
typeof stamp.provenance === 'object' &&
|
|
81
|
+
stamp.provenance !== null &&
|
|
82
|
+
componentNames.every((name) => typeof stamp.provenance?.[name] === 'string') &&
|
|
83
|
+
(stamp.pendingDependencyBuilds === undefined ||
|
|
84
|
+
(Array.isArray(stamp.pendingDependencyBuilds) &&
|
|
85
|
+
stamp.pendingDependencyBuilds.every((id) => typeof id === 'string'))));
|
|
66
86
|
}
|
|
67
|
-
|
|
87
|
+
async function readPersistentMetadataState() {
|
|
68
88
|
try {
|
|
69
|
-
await
|
|
70
|
-
return
|
|
89
|
+
const parsed = JSON.parse(await readFile(persistentMetadataStampPath(), 'utf8'));
|
|
90
|
+
return validStamp(parsed)
|
|
91
|
+
? { kind: 'stamp', stamp: parsed }
|
|
92
|
+
: { kind: 'unsafe' };
|
|
71
93
|
}
|
|
72
94
|
catch (error) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
throw error;
|
|
95
|
+
return error.code === 'ENOENT'
|
|
96
|
+
? { kind: 'missing' }
|
|
97
|
+
: { kind: 'unsafe' };
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
async function readPersistentMetadataStamp() {
|
|
101
|
+
const state = await readPersistentMetadataState();
|
|
102
|
+
return state.kind === 'stamp' ? state.stamp : undefined;
|
|
103
|
+
}
|
|
104
|
+
export async function persistentMetadataStatusV4(provenance) {
|
|
105
|
+
const state = await readPersistentMetadataState();
|
|
106
|
+
if (state.kind === 'missing')
|
|
107
|
+
return { kind: 'absent' };
|
|
108
|
+
if (state.kind === 'unsafe')
|
|
109
|
+
return { kind: 'unsafe' };
|
|
110
|
+
const { stamp } = state;
|
|
111
|
+
const changed = componentNames.filter((name) => stamp.provenance[name] !== provenance[name]);
|
|
112
|
+
return changed.length > 0
|
|
113
|
+
? { kind: 'changed', components: changed }
|
|
114
|
+
: {
|
|
115
|
+
kind: 'matching',
|
|
116
|
+
lastInvocationInstallScripts: stamp.lastInvocationInstallScripts,
|
|
117
|
+
pendingDependencyBuilds: stamp.pendingDependencyBuilds ?? [],
|
|
118
|
+
scriptsEnabledInstallSucceeded: stamp.scriptsEnabledInstallSucceeded,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function writePersistentMetadataStampV4(provenance, installScripts, resetScriptsEnabledCapability, pendingDependencyBuilds) {
|
|
122
|
+
const existing = await readPersistentMetadataStamp();
|
|
123
|
+
const existingMatches = existing && componentNames.every((name) => existing.provenance[name] === provenance[name]);
|
|
124
|
+
const stamp = {
|
|
125
|
+
lastInvocationInstallScripts: installScripts,
|
|
126
|
+
pendingDependencyBuilds: (pendingDependencyBuilds ??
|
|
127
|
+
(existingMatches ? existing.pendingDependencyBuilds : undefined) ??
|
|
128
|
+
[]).toSorted(),
|
|
129
|
+
provenance,
|
|
130
|
+
scriptsEnabledInstallSucceeded: installScripts ||
|
|
131
|
+
(!resetScriptsEnabledCapability &&
|
|
132
|
+
Boolean(existingMatches && existing.scriptsEnabledInstallSucceeded)),
|
|
133
|
+
version: 4,
|
|
134
|
+
};
|
|
80
135
|
const stampPath = persistentMetadataStampPath();
|
|
81
136
|
const directory = path.dirname(stampPath);
|
|
82
137
|
const temporary = `${stampPath}.${process.pid}.tmp`;
|
|
83
138
|
await mkdir(directory, { recursive: true });
|
|
84
139
|
try {
|
|
85
|
-
await writeFile(temporary, `${JSON.stringify(
|
|
140
|
+
await writeFile(temporary, `${JSON.stringify(stamp)}\n`);
|
|
86
141
|
await rename(temporary, stampPath);
|
|
87
142
|
}
|
|
88
143
|
finally {
|
|
89
144
|
await rm(temporary, { force: true });
|
|
90
145
|
}
|
|
91
146
|
}
|
|
147
|
+
export async function persistentDependencyTreeIsCold() {
|
|
148
|
+
try {
|
|
149
|
+
await stat(path.dirname(persistentMetadataStampPath()));
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code === 'ENOENT')
|
|
154
|
+
return true;
|
|
155
|
+
/* v8 ignore next -- non-ENOENT stat failures are host-specific */
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PendingBuilds = {
|
|
2
|
+
kind: 'known';
|
|
3
|
+
ids: Set<string>;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'unknown';
|
|
6
|
+
};
|
|
7
|
+
export type PendingBuildDelta = {
|
|
8
|
+
kind: 'unknown';
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'known';
|
|
11
|
+
dependencyIds: string[];
|
|
12
|
+
workspaceIds: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function pendingBuilds(): Promise<PendingBuilds>;
|
|
15
|
+
export declare function validDependencyBuildIds(ids: [string, ...string[]]): Promise<[string, ...string[]] | undefined>;
|
|
16
|
+
export declare function clearPendingDependencyBuilds(ids: string[]): Promise<boolean>;
|
|
17
|
+
export declare function pendingBuildDelta(before: PendingBuilds, after: PendingBuilds): Promise<PendingBuildDelta>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
export async function pendingBuilds() {
|
|
5
|
+
try {
|
|
6
|
+
const value = parse(await readFile(path.join(process.cwd(), 'node_modules', '.modules.yaml'), 'utf8'));
|
|
7
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
8
|
+
return { kind: 'unknown' };
|
|
9
|
+
const pending = value.pendingBuilds ?? [];
|
|
10
|
+
if (!Array.isArray(pending) || !pending.every((id) => typeof id === 'string'))
|
|
11
|
+
return { kind: 'unknown' };
|
|
12
|
+
return { kind: 'known', ids: new Set(pending) };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { kind: 'unknown' };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function lockfileDependencyIds() {
|
|
19
|
+
try {
|
|
20
|
+
const lockfile = parse(await readFile(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'));
|
|
21
|
+
if (typeof lockfile !== 'object' || lockfile === null || !('packages' in lockfile))
|
|
22
|
+
return undefined;
|
|
23
|
+
const packages = lockfile.packages;
|
|
24
|
+
if (typeof packages !== 'object' || packages === null || Array.isArray(packages))
|
|
25
|
+
return undefined;
|
|
26
|
+
return new Set(Object.keys(packages));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function validDependencyBuildIds(ids) {
|
|
33
|
+
const packages = await lockfileDependencyIds();
|
|
34
|
+
if (!packages || !ids.every((id) => packages.has(id)))
|
|
35
|
+
return undefined;
|
|
36
|
+
const [first, ...remaining] = ids.toSorted();
|
|
37
|
+
return [first, ...remaining];
|
|
38
|
+
}
|
|
39
|
+
export async function clearPendingDependencyBuilds(ids) {
|
|
40
|
+
const filename = path.join(process.cwd(), 'node_modules', '.modules.yaml');
|
|
41
|
+
try {
|
|
42
|
+
const value = parse(await readFile(filename, 'utf8'));
|
|
43
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
44
|
+
return false;
|
|
45
|
+
const record = value;
|
|
46
|
+
if (!Array.isArray(record.pendingBuilds) ||
|
|
47
|
+
!record.pendingBuilds.every((id) => typeof id === 'string'))
|
|
48
|
+
return false;
|
|
49
|
+
const ignored = record.ignoredBuilds ?? [];
|
|
50
|
+
if (!Array.isArray(ignored) ||
|
|
51
|
+
!ignored.every((id) => typeof id === 'string') ||
|
|
52
|
+
ids.some((id) => ignored.includes(id)))
|
|
53
|
+
return false;
|
|
54
|
+
record.pendingBuilds = record.pendingBuilds.filter((id) => !ids.includes(id));
|
|
55
|
+
const temporary = `${filename}.${process.pid}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(temporary, stringify(record));
|
|
58
|
+
await rename(temporary, filename);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(temporary, { force: true });
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function pendingBuildDelta(before, after) {
|
|
70
|
+
if (before.kind !== 'known' || after.kind !== 'known')
|
|
71
|
+
return { kind: 'unknown' };
|
|
72
|
+
const packages = await lockfileDependencyIds();
|
|
73
|
+
if (!packages)
|
|
74
|
+
return { kind: 'unknown' };
|
|
75
|
+
const dependencyIds = [];
|
|
76
|
+
const workspaceIds = [];
|
|
77
|
+
for (const id of after.ids) {
|
|
78
|
+
if (before.ids.has(id))
|
|
79
|
+
continue;
|
|
80
|
+
(packages.has(id) ? dependencyIds : workspaceIds).push(id);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
kind: 'known',
|
|
84
|
+
dependencyIds: dependencyIds.toSorted(),
|
|
85
|
+
workspaceIds: workspaceIds.toSorted(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fakePnpmScript(): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function fakePnpmScript() {
|
|
2
|
+
return `#!/usr/bin/env bash
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
if [ "\${1:-}" = m ]; then
|
|
5
|
+
if [ -n "\${PNPM_LIST_WARNING:-}" ]; then printf '%s\\n' "$PNPM_LIST_WARNING" >&2; fi
|
|
6
|
+
printf '%s\\n' "$PNPM_WORKSPACES_JSON"
|
|
7
|
+
exit 0
|
|
8
|
+
fi
|
|
9
|
+
if [ "\${1:-}" = --version ]; then
|
|
10
|
+
printf '%s\\n' "\${PNPM_VERSION:-11.0.0}"
|
|
11
|
+
exit 0
|
|
12
|
+
fi
|
|
13
|
+
printf '%s\\n' "$*" >> "$PNPM_LOG"
|
|
14
|
+
calls=0
|
|
15
|
+
if [ -f "$PNPM_CALLS" ]; then calls="$(cat "$PNPM_CALLS")"; fi
|
|
16
|
+
calls=$((calls + 1))
|
|
17
|
+
printf '%s' "$calls" > "$PNPM_CALLS"
|
|
18
|
+
print_release_age_violation() {
|
|
19
|
+
printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
|
|
20
|
+
printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
|
|
21
|
+
printf '%s\\n' ' undici@8.10.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
|
|
22
|
+
}
|
|
23
|
+
if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
|
|
24
|
+
if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi
|
|
25
|
+
exit "\${PNPM_FAIL_CODE:-1}"
|
|
26
|
+
fi
|
|
27
|
+
if [ "\${PNPM_FAIL_RELEASE_AGE_CALL:-0}" = "$calls" ]; then
|
|
28
|
+
print_release_age_violation
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "\${PNPM_SLEEP_SECONDS:-}" ]; then sleep "$PNPM_SLEEP_SECONDS"; fi
|
|
32
|
+
if [ -n "\${PNPM_PENDING_BUILDS:-}" ]; then
|
|
33
|
+
mkdir -p "$PNPM_NODE_MODULES"
|
|
34
|
+
printf 'pendingBuilds: [%s]\\n' "$PNPM_PENDING_BUILDS" > "$PNPM_NODE_MODULES/.modules.yaml"
|
|
35
|
+
fi
|
|
36
|
+
case " $* " in
|
|
37
|
+
*' rebuild '*)
|
|
38
|
+
if [ "\${PNPM_REBUILD_INVALID_LEDGER:-0}" = 1 ]; then
|
|
39
|
+
printf 'pendingBuilds: invalid\n' > "$PNPM_NODE_MODULES/.modules.yaml"
|
|
40
|
+
fi
|
|
41
|
+
if [ "\${PNPM_REBUILD_BREAK_LINK:-0}" = 1 ]; then rm -f "$PNPM_DEPENDENCY_LINK"; fi
|
|
42
|
+
;;
|
|
43
|
+
*' --force '*)
|
|
44
|
+
if [ "\${PNPM_REPAIR_LINK:-0}" = 1 ]; then
|
|
45
|
+
mkdir -p "$(dirname "$PNPM_DEPENDENCY_LINK")"
|
|
46
|
+
rm -f "$PNPM_DEPENDENCY_LINK"
|
|
47
|
+
ln -s "$PNPM_DEPENDENCY" "$PNPM_DEPENDENCY_LINK"
|
|
48
|
+
fi
|
|
49
|
+
;;
|
|
50
|
+
esac
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { runPnpmInstallCli } from '../cli/commands/pnpm-install.mjs';
|
|
7
|
+
import { fakePnpmScript } from './pnpm-install-fake-pnpm.test-helpers.mjs';
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
async function writeJson(path, value) {
|
|
9
10
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -20,6 +21,7 @@ export async function makeFixture() {
|
|
|
20
21
|
await Promise.all([
|
|
21
22
|
writeJson(join(root, 'package.json'), { name: 'fixture-root', private: true }),
|
|
22
23
|
writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\nminimumReleaseAge: 2880\n'),
|
|
24
|
+
writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\npackages: {}\n'),
|
|
23
25
|
writeJson(join(consumer, 'package.json'), {
|
|
24
26
|
name: '@fixture/consumer',
|
|
25
27
|
dependencies: { '@fixture/dependency': 'workspace:^' },
|
|
@@ -29,46 +31,7 @@ export async function makeFixture() {
|
|
|
29
31
|
mkdir(pnpmBin),
|
|
30
32
|
]);
|
|
31
33
|
await symlink(dependency, dependencyLink, 'dir');
|
|
32
|
-
await writeFile(join(pnpmBin, 'pnpm'),
|
|
33
|
-
set -euo pipefail
|
|
34
|
-
if [ "\${1:-}" = m ]; then
|
|
35
|
-
if [ -n "\${PNPM_LIST_WARNING:-}" ]; then printf '%s\\n' "$PNPM_LIST_WARNING" >&2; fi
|
|
36
|
-
printf '%s\\n' "$PNPM_WORKSPACES_JSON"
|
|
37
|
-
exit 0
|
|
38
|
-
fi
|
|
39
|
-
if [ "\${1:-}" = --version ]; then
|
|
40
|
-
printf '%s\\n' "\${PNPM_VERSION:-11.0.0}"
|
|
41
|
-
exit 0
|
|
42
|
-
fi
|
|
43
|
-
printf '%s\\n' "$*" >> "$PNPM_LOG"
|
|
44
|
-
calls=0
|
|
45
|
-
if [ -f "$PNPM_CALLS" ]; then calls="$(cat "$PNPM_CALLS")"; fi
|
|
46
|
-
calls=$((calls + 1))
|
|
47
|
-
printf '%s' "$calls" > "$PNPM_CALLS"
|
|
48
|
-
print_release_age_violation() {
|
|
49
|
-
printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
|
|
50
|
-
printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
|
|
51
|
-
printf '%s\\n' ' undici@8.10.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
|
|
52
|
-
}
|
|
53
|
-
if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
|
|
54
|
-
if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi
|
|
55
|
-
exit "\${PNPM_FAIL_CODE:-1}"
|
|
56
|
-
fi
|
|
57
|
-
if [ "\${PNPM_FAIL_RELEASE_AGE_CALL:-0}" = "$calls" ]; then
|
|
58
|
-
print_release_age_violation
|
|
59
|
-
exit 1
|
|
60
|
-
fi
|
|
61
|
-
if [ -n "\${PNPM_SLEEP_SECONDS:-}" ]; then sleep "$PNPM_SLEEP_SECONDS"; fi
|
|
62
|
-
case " $* " in
|
|
63
|
-
*' --force '*)
|
|
64
|
-
if [ "\${PNPM_REPAIR_LINK:-0}" = 1 ]; then
|
|
65
|
-
mkdir -p "$(dirname "$PNPM_DEPENDENCY_LINK")"
|
|
66
|
-
rm -f "$PNPM_DEPENDENCY_LINK"
|
|
67
|
-
ln -s "$PNPM_DEPENDENCY" "$PNPM_DEPENDENCY_LINK"
|
|
68
|
-
fi
|
|
69
|
-
;;
|
|
70
|
-
esac
|
|
71
|
-
`);
|
|
34
|
+
await writeFile(join(pnpmBin, 'pnpm'), fakePnpmScript());
|
|
72
35
|
await execFileAsync('chmod', ['+x', join(pnpmBin, 'pnpm')]);
|
|
73
36
|
const workspaces = [
|
|
74
37
|
{ name: 'fixture-root', path: root },
|
|
@@ -83,7 +46,10 @@ esac
|
|
|
83
46
|
PNPM_DEPENDENCY: dependency,
|
|
84
47
|
PNPM_DEPENDENCY_LINK: dependencyLink,
|
|
85
48
|
PNPM_LOG: pnpmLog,
|
|
49
|
+
PNPM_NODE_MODULES: join(root, 'node_modules'),
|
|
50
|
+
PNPM_PENDING_BUILDS: '',
|
|
86
51
|
PNPM_REPAIR_LINK: '0',
|
|
52
|
+
PNPM_REBUILD_BREAK_LINK: '0',
|
|
87
53
|
PNPM_WORKSPACES_JSON: JSON.stringify(workspaces),
|
|
88
54
|
};
|
|
89
55
|
return {
|
|
@@ -1,83 +1,99 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mjs';
|
|
3
|
-
import { nativeBinariesMatchRuntime } from './native-health.mjs';
|
|
1
|
+
import { persistentDependencyTreeIsCold, persistentMetadataFingerprintV4, persistentMetadataStatusV4, writePersistentMetadataStampV4, } from './metadata.mjs';
|
|
4
2
|
import { runPnpm } from './exec.mjs';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { nativeBinariesMatchRuntime } from './native-health.mjs';
|
|
4
|
+
import { clearPendingDependencyBuilds, pendingBuildDelta, pendingBuilds, validDependencyBuildIds, } from './pending-builds.mjs';
|
|
5
|
+
import { install, reconcileOrFail, withScriptPolicy } from './install-operations.mjs';
|
|
7
6
|
import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
|
|
7
|
+
import { persistentInstallTransition, persistentProvenanceDiagnostic } from './transition.mjs';
|
|
8
8
|
// oxfmt-ignore
|
|
9
9
|
const fail = (message) => { throw new Error(message); };
|
|
10
|
-
async function install(args, options, label) {
|
|
11
|
-
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
12
|
-
const attemptResult = await runPnpm(args, options);
|
|
13
|
-
if (attemptResult.code === 0)
|
|
14
|
-
return;
|
|
15
|
-
if (attemptResult.code === INSTALL_TERMINATION_FAILED)
|
|
16
|
-
fail(`${label} could not terminate safely`);
|
|
17
|
-
const combinedOutput = `${attemptResult.output}\n${attemptResult.errorOutput ?? ''}`;
|
|
18
|
-
if (isReleaseAgeViolation(combinedOutput))
|
|
19
|
-
fail(formatReleaseAgeFailure(label, combinedOutput));
|
|
20
|
-
if (attempt < options.maxAttempts) {
|
|
21
|
-
console.warn(`${label} failed (attempt ${attempt}/${options.maxAttempts}); retrying`);
|
|
22
|
-
await scheduler.wait(5000);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
fail(`${label} failed after ${options.maxAttempts} attempt${options.maxAttempts === 1 ? '' : 's'}`);
|
|
26
|
-
}
|
|
27
|
-
function withScriptPolicy(args, installScripts) {
|
|
28
|
-
return installScripts ? args : [...args, '--ignore-scripts'];
|
|
29
|
-
}
|
|
30
|
-
async function reconcileAndFindMismatches(options, runCapture) {
|
|
31
|
-
const forced = ['install', '--frozen-lockfile', '--force', ...baseInstallArgs.slice(2)];
|
|
32
|
-
// oxfmt-ignore
|
|
33
|
-
await install([...forced, '--ignore-scripts', '--ignore-pnpmfile'], options, 'script-free reconciliation');
|
|
34
|
-
// oxfmt-ignore
|
|
35
|
-
await install(withScriptPolicy(forced, options.installScripts), options, 'strict persistent reconciliation');
|
|
36
|
-
return findWorkspaceLinkMismatches(runCapture);
|
|
37
|
-
}
|
|
38
|
-
async function reconcileOrFail(options, runCapture) {
|
|
39
|
-
const remaining = await reconcileAndFindMismatches(options, runCapture);
|
|
40
|
-
if (remaining.length > 0) {
|
|
41
|
-
logWorkspaceLinkMismatches(remaining);
|
|
42
|
-
fail('persistent reconciliation completed with invalid workspace links');
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
10
|
async function persistent(options) {
|
|
46
11
|
if (options.ephemeralWorkspaces.trim())
|
|
47
12
|
fail('ephemeral-workspaces is only valid for ephemeral runners');
|
|
48
13
|
const runCapture = (args) => runPnpm(args, options, true);
|
|
49
|
-
const fingerprint = await
|
|
50
|
-
const
|
|
14
|
+
const fingerprint = await persistentMetadataFingerprintV4(runCapture);
|
|
15
|
+
const provenance = await persistentMetadataStatusV4(fingerprint);
|
|
51
16
|
const nativesMatch = await nativeBinariesMatchRuntime();
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
17
|
+
const provisionalTransition = persistentInstallTransition(provenance, options.installScripts);
|
|
18
|
+
let transition = nativesMatch
|
|
19
|
+
? provisionalTransition
|
|
20
|
+
: { action: 'reconcile', reason: 'native-health-mismatch' };
|
|
21
|
+
if (transition.action === 'upgrade-dependencies') {
|
|
22
|
+
const ids = await validDependencyBuildIds(transition.pendingDependencyBuilds);
|
|
23
|
+
if (!ids)
|
|
24
|
+
transition = { action: 'upgrade-scripts', reason: 'invalid-pending-dependency-builds' };
|
|
25
|
+
else
|
|
26
|
+
transition = { ...transition, pendingDependencyBuilds: ids };
|
|
27
|
+
}
|
|
28
|
+
const provenanceOk = provenance.kind === 'matching' && transition.action !== 'reconcile' && nativesMatch;
|
|
55
29
|
const cold = !provenanceOk && (await persistentDependencyTreeIsCold());
|
|
56
30
|
if (!provenanceOk && !cold) {
|
|
57
|
-
|
|
31
|
+
const finalTransition = provenance.kind === 'absent'
|
|
32
|
+
? { action: 'reconcile', reason: 'missing-stamp-populated-tree' }
|
|
33
|
+
: transition;
|
|
34
|
+
console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, finalTransition));
|
|
35
|
+
console.warn(provenance.kind === 'matching' && !nativesMatch
|
|
58
36
|
? 'persistent optional native binaries do not match this runtime; reconciling'
|
|
59
37
|
: 'persistent dependency metadata provenance is missing or changed; reconciling');
|
|
60
38
|
await reconcileOrFail(options, runCapture);
|
|
61
|
-
await
|
|
39
|
+
await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
|
|
62
40
|
return 'persistent metadata reconciled';
|
|
63
41
|
}
|
|
64
|
-
if (
|
|
42
|
+
if (provenance.kind === 'absent')
|
|
65
43
|
console.warn('persistent dependency tree is absent; installing cold');
|
|
66
|
-
|
|
44
|
+
const pendingBefore = transition.action === 'ordinary' && !options.installScripts ? await pendingBuilds() : undefined;
|
|
45
|
+
await install(withScriptPolicy([...baseInstallArgs], transition.action.startsWith('upgrade-') ? false : options.installScripts), options, 'ordinary persistent install');
|
|
67
46
|
const stale = await findWorkspaceLinkMismatches(runCapture);
|
|
68
47
|
if (stale.length === 0) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
48
|
+
if (transition.action.startsWith('upgrade-')) {
|
|
49
|
+
const pendingDependencyBuilds = transition.action === 'upgrade-dependencies'
|
|
50
|
+
? transition.pendingDependencyBuilds
|
|
51
|
+
: undefined;
|
|
52
|
+
const rebuild = pendingDependencyBuilds === undefined
|
|
53
|
+
? ['rebuild', '--pending', '--recursive']
|
|
54
|
+
: ['rebuild', '--recursive', '--', ...pendingDependencyBuilds];
|
|
55
|
+
await install(rebuild, options, 'pending scripts rebuild');
|
|
56
|
+
const rebuiltStale = await findWorkspaceLinkMismatches(runCapture);
|
|
57
|
+
if (rebuiltStale.length > 0) {
|
|
58
|
+
logWorkspaceLinkMismatches(rebuiltStale);
|
|
59
|
+
await reconcileOrFail(options, runCapture);
|
|
60
|
+
console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, {
|
|
61
|
+
action: 'reconcile',
|
|
62
|
+
reason: 'workspace-links-stale-after-rebuild',
|
|
63
|
+
}));
|
|
64
|
+
await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
|
|
65
|
+
return 'persistent reconciled';
|
|
66
|
+
}
|
|
67
|
+
if (pendingDependencyBuilds !== undefined &&
|
|
68
|
+
!(await clearPendingDependencyBuilds(pendingDependencyBuilds)))
|
|
69
|
+
fail('dependency rebuild completed but pending build ledger could not be updated safely');
|
|
70
|
+
}
|
|
71
|
+
console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, transition));
|
|
72
|
+
const pendingAfter = pendingBefore ? await pendingBuilds() : undefined;
|
|
73
|
+
const delta = pendingBefore && pendingAfter
|
|
74
|
+
? await pendingBuildDelta(pendingBefore, pendingAfter)
|
|
75
|
+
: undefined;
|
|
76
|
+
const unsafePending = delta?.kind === 'unknown' || Boolean(delta?.workspaceIds.length);
|
|
77
|
+
const pendingDependencyBuilds = transition.action.startsWith('upgrade-') || unsafePending
|
|
78
|
+
? []
|
|
79
|
+
: [
|
|
80
|
+
...new Set([
|
|
81
|
+
...(provenance.kind === 'matching' ? provenance.pendingDependencyBuilds : []),
|
|
82
|
+
...(delta?.kind === 'known' ? delta.dependencyIds : []),
|
|
83
|
+
]),
|
|
84
|
+
].toSorted();
|
|
85
|
+
await writePersistentMetadataStampV4(fingerprint, options.installScripts, unsafePending, pendingDependencyBuilds);
|
|
86
|
+
return provenance.kind === 'absent' ? 'persistent cold' : 'persistent ordinary';
|
|
72
87
|
}
|
|
73
88
|
logWorkspaceLinkMismatches(stale);
|
|
89
|
+
console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, {
|
|
90
|
+
action: 'reconcile',
|
|
91
|
+
reason: 'workspace-links-stale',
|
|
92
|
+
}));
|
|
74
93
|
await reconcileOrFail(options, runCapture);
|
|
75
|
-
await
|
|
94
|
+
await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
|
|
76
95
|
return 'persistent reconciled';
|
|
77
96
|
}
|
|
78
|
-
// A path selector's `...` (dependency closure) suffix is silently ignored by pnpm unless the
|
|
79
|
-
// path is brace-wrapped, e.g. `{./web}...` — `./web...` installs only `web` itself. Reject the
|
|
80
|
-
// unbraced form outright rather than let it resolve to a smaller-than-intended scope.
|
|
81
97
|
function isUnbracedPathClosureSelector(selector) {
|
|
82
98
|
return /^\.{0,2}\//.test(selector) && selector.endsWith('...') && !selector.startsWith('{');
|
|
83
99
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ProvenanceStatus = {
|
|
2
|
+
kind: 'absent';
|
|
3
|
+
} | {
|
|
4
|
+
kind: 'changed';
|
|
5
|
+
components: string[];
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'matching';
|
|
8
|
+
lastInvocationInstallScripts: boolean;
|
|
9
|
+
pendingDependencyBuilds: string[];
|
|
10
|
+
scriptsEnabledInstallSucceeded: boolean;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'unsafe';
|
|
13
|
+
};
|
|
14
|
+
export type PersistentInstallTransition = {
|
|
15
|
+
action: 'ordinary' | 'reconcile' | 'upgrade-scripts';
|
|
16
|
+
reason: string;
|
|
17
|
+
} | {
|
|
18
|
+
action: 'upgrade-dependencies';
|
|
19
|
+
pendingDependencyBuilds: [string, ...string[]];
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function persistentInstallTransition(provenance: ProvenanceStatus, installScripts: boolean): PersistentInstallTransition;
|
|
23
|
+
export declare function persistentProvenanceDiagnostic(provenance: ProvenanceStatus, installScripts: boolean, nativeBinariesMatchRuntime: boolean, transition: PersistentInstallTransition): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The matching rows are deliberately explicit: a script-disabled invocation can never erase
|
|
2
|
+
// evidence that this structural tree has already completed a scripts-enabled install.
|
|
3
|
+
export function persistentInstallTransition(provenance, installScripts) {
|
|
4
|
+
if (provenance.kind === 'matching') {
|
|
5
|
+
if (installScripts && !provenance.scriptsEnabledInstallSucceeded)
|
|
6
|
+
return { action: 'upgrade-scripts', reason: 'pending-scripts-rebuild' };
|
|
7
|
+
const [firstPendingDependencyBuild, ...remainingPendingDependencyBuilds] = provenance.pendingDependencyBuilds;
|
|
8
|
+
if (installScripts && firstPendingDependencyBuild !== undefined)
|
|
9
|
+
return {
|
|
10
|
+
action: 'upgrade-dependencies',
|
|
11
|
+
pendingDependencyBuilds: [firstPendingDependencyBuild, ...remainingPendingDependencyBuilds],
|
|
12
|
+
reason: 'pending-dependency-rebuild',
|
|
13
|
+
};
|
|
14
|
+
return { action: 'ordinary', reason: 'matching-structural-provenance' };
|
|
15
|
+
}
|
|
16
|
+
if (provenance.kind === 'absent')
|
|
17
|
+
return { action: 'ordinary', reason: 'missing-stamp' };
|
|
18
|
+
return {
|
|
19
|
+
action: 'reconcile',
|
|
20
|
+
reason: provenance.kind === 'changed' ? 'structural-provenance-changed' : 'unsafe-stamp',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function persistentProvenanceDiagnostic(provenance, installScripts, nativeBinariesMatchRuntime, transition) {
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
action: transition.action,
|
|
26
|
+
changedComponents: provenance.kind === 'changed' ? provenance.components : [],
|
|
27
|
+
event: 'pnpm-install-persistent-provenance',
|
|
28
|
+
installScripts,
|
|
29
|
+
scriptsEnabledInstallSucceeded: provenance.kind === 'matching' ? provenance.scriptsEnabledInstallSucceeded : false,
|
|
30
|
+
lastInvocationInstallScripts: provenance.kind === 'matching' ? provenance.lastInvocationInstallScripts : null,
|
|
31
|
+
pendingDependencyBuildCount: provenance.kind === 'matching' ? provenance.pendingDependencyBuilds.length : 0,
|
|
32
|
+
nativeBinariesMatchRuntime,
|
|
33
|
+
reason: transition.reason,
|
|
34
|
+
state: provenance.kind,
|
|
35
|
+
});
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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": {
|