@loomcli/loom 0.0.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/dist/application.d.ts +2 -0
- package/dist/application.js +5 -0
- package/dist/build.d.ts +32 -0
- package/dist/build.js +66 -0
- package/dist/commands/changelog/command.d.ts +2 -0
- package/dist/commands/changelog/command.js +58 -0
- package/dist/commands/changelog/write.d.ts +2 -0
- package/dist/commands/changelog/write.js +98 -0
- package/dist/commands/pr/command.d.ts +2 -0
- package/dist/commands/pr/command.js +24 -0
- package/dist/commands/release/command.d.ts +2 -0
- package/dist/commands/release/command.js +78 -0
- package/dist/helpers/fragments.d.ts +5 -0
- package/dist/helpers/fragments.js +60 -0
- package/dist/helpers/github.d.ts +42 -0
- package/dist/helpers/github.js +97 -0
- package/dist/helpers/http.d.ts +17 -0
- package/dist/helpers/http.js +134 -0
- package/dist/helpers/markdown.d.ts +16 -0
- package/dist/helpers/markdown.js +40 -0
- package/dist/helpers/material.d.ts +4 -0
- package/dist/helpers/material.js +103 -0
- package/dist/helpers/pr-check.d.ts +6 -0
- package/dist/helpers/pr-check.js +65 -0
- package/dist/helpers/registry.d.ts +16 -0
- package/dist/helpers/registry.js +78 -0
- package/dist/helpers/release-check.d.ts +1 -0
- package/dist/helpers/release-check.js +76 -0
- package/dist/helpers/release-files.d.ts +13 -0
- package/dist/helpers/release-files.js +97 -0
- package/dist/helpers/release-plan.d.ts +39 -0
- package/dist/helpers/release-plan.js +221 -0
- package/dist/helpers/release-record.d.ts +12 -0
- package/dist/helpers/release-record.js +99 -0
- package/dist/helpers/release.d.ts +48 -0
- package/dist/helpers/release.js +111 -0
- package/dist/helpers/repository.d.ts +88 -0
- package/dist/helpers/repository.js +124 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +2 -0
- package/package.json +35 -3
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface HttpPolicy {
|
|
2
|
+
requestTimeoutMs: number;
|
|
3
|
+
}
|
|
4
|
+
export interface RetryPolicy extends HttpPolicy {
|
|
5
|
+
attempts: number;
|
|
6
|
+
delayMs: number;
|
|
7
|
+
}
|
|
8
|
+
export interface JsonAnswer {
|
|
9
|
+
data: unknown;
|
|
10
|
+
status: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function readJson(policy: HttpPolicy, url: string, token?: string): Promise<JsonAnswer>;
|
|
13
|
+
export declare function readBytes(policy: HttpPolicy, url: string): Promise<Buffer<ArrayBuffer>>;
|
|
14
|
+
export declare function writeJson(policy: HttpPolicy, url: string, token: string, body: unknown): Promise<unknown>;
|
|
15
|
+
export declare function writeBytes(policy: HttpPolicy, url: string, token: string, bytes: Buffer, contentType: string): Promise<void>;
|
|
16
|
+
export declare function removeResource(policy: HttpPolicy, url: string, token: string): Promise<void>;
|
|
17
|
+
export declare function until<Value>(policy: RetryPolicy, subject: string, read: () => Promise<Value | undefined>): Promise<Awaited<Value> & ({} | null)>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const maximumDelayMs = 60_000;
|
|
2
|
+
// A tarball is larger than a JSON answer, so its deadline is four times the configured one.
|
|
3
|
+
const bytesDeadlineFactor = 4;
|
|
4
|
+
function headers(token) {
|
|
5
|
+
return {
|
|
6
|
+
accept: 'application/json',
|
|
7
|
+
...(token === undefined ? {} : { authorization: `Bearer ${token}` }),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function parseJson(url, text) {
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.parse(text);
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`${url} answered with invalid JSON.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function refuse(method, url, response) {
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
return new Error(`${method} ${url} answered ${String(response.status)}: ${text.trim()}`);
|
|
22
|
+
}
|
|
23
|
+
function sleep(delayMs) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
setTimeout(resolve, delayMs);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// A deadline reached during the request or during the body read arrives as the timeout error itself or as its cause.
|
|
29
|
+
function timedOut(error) {
|
|
30
|
+
return error instanceof Error && (error.name === 'TimeoutError' || timedOut(error.cause));
|
|
31
|
+
}
|
|
32
|
+
// Every request carries a deadline, because a stalled connection would otherwise hold the run forever.
|
|
33
|
+
// The abort error names neither the endpoint nor the deadline, so a stall is reported with both.
|
|
34
|
+
async function within(method, url, deadlineMs, exchange) {
|
|
35
|
+
try {
|
|
36
|
+
return await exchange(AbortSignal.timeout(deadlineMs));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (timedOut(error)) {
|
|
40
|
+
throw new Error(`${method} ${url} did not answer within ${String(deadlineMs)} ms.`, {
|
|
41
|
+
cause: error,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// An absent resource is a fact the caller reads, so a 404 answers with an undefined body.
|
|
48
|
+
export async function readJson(policy, url, token) {
|
|
49
|
+
return within('GET', url, policy.requestTimeoutMs, async (signal) => {
|
|
50
|
+
const response = await fetch(url, { headers: headers(token), signal });
|
|
51
|
+
if (response.status === 404) {
|
|
52
|
+
return { data: undefined, status: response.status };
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw await refuse('GET', url, response);
|
|
56
|
+
}
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
return { data: parseJson(url, text), status: response.status };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function readBytes(policy, url) {
|
|
62
|
+
const deadlineMs = policy.requestTimeoutMs * bytesDeadlineFactor;
|
|
63
|
+
return within('GET', url, deadlineMs, async (signal) => {
|
|
64
|
+
const response = await fetch(url, { signal });
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw await refuse('GET', url, response);
|
|
67
|
+
}
|
|
68
|
+
const body = await response.arrayBuffer();
|
|
69
|
+
return Buffer.from(body);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function writeJson(policy, url, token, body) {
|
|
73
|
+
return within('POST', url, policy.requestTimeoutMs, async (signal) => {
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
headers: { ...headers(token), 'content-type': 'application/json' },
|
|
77
|
+
method: 'POST',
|
|
78
|
+
signal,
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
throw await refuse('POST', url, response);
|
|
82
|
+
}
|
|
83
|
+
const text = await response.text();
|
|
84
|
+
return parseJson(url, text);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function writeBytes(policy, url, token, bytes, contentType) {
|
|
88
|
+
const deadlineMs = policy.requestTimeoutMs * bytesDeadlineFactor;
|
|
89
|
+
await within('POST', url, deadlineMs, async (signal) => {
|
|
90
|
+
const response = await fetch(url, {
|
|
91
|
+
body: new Uint8Array(bytes),
|
|
92
|
+
headers: { ...headers(token), 'content-type': contentType },
|
|
93
|
+
method: 'POST',
|
|
94
|
+
signal,
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
throw await refuse('POST', url, response);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export async function removeResource(policy, url, token) {
|
|
102
|
+
await within('DELETE', url, policy.requestTimeoutMs, async (signal) => {
|
|
103
|
+
const response = await fetch(url, { headers: headers(token), method: 'DELETE', signal });
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw await refuse('DELETE', url, response);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// The registry and the GitHub API both propagate a write with a delay, so a read after a write waits for it.
|
|
110
|
+
// A failed read is one more way a write has not propagated, so it counts as an attempt and its error survives to the end.
|
|
111
|
+
export async function until(policy, subject, read) {
|
|
112
|
+
let delayMs = policy.delayMs;
|
|
113
|
+
let failure = undefined;
|
|
114
|
+
for (let attempt = 1; attempt <= policy.attempts; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
const value = await read();
|
|
117
|
+
if (value !== undefined) {
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
failure = undefined;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
failure = { error };
|
|
124
|
+
}
|
|
125
|
+
if (attempt < policy.attempts) {
|
|
126
|
+
await sleep(delayMs);
|
|
127
|
+
delayMs = Math.min(delayMs * 2, maximumDelayMs);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (failure !== undefined) {
|
|
131
|
+
throw failure.error;
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`${subject} did not appear after ${String(policy.attempts)} attempts.`);
|
|
134
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
type Node = ReturnType<typeof fromMarkdown>['children'][number];
|
|
3
|
+
type Heading = Extract<Node, {
|
|
4
|
+
type: 'heading';
|
|
5
|
+
}>;
|
|
6
|
+
export interface ReleaseLocation {
|
|
7
|
+
body: string;
|
|
8
|
+
frontmatter: string;
|
|
9
|
+
index: number;
|
|
10
|
+
nodes: Node[];
|
|
11
|
+
releases: Heading[];
|
|
12
|
+
}
|
|
13
|
+
export declare function headingText(heading: Heading): string;
|
|
14
|
+
export declare function locateRelease(changelog: string, version: string): ReleaseLocation;
|
|
15
|
+
export declare function requireClosedBlocks(source: string, label: string): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
function text(nodes) {
|
|
3
|
+
return nodes
|
|
4
|
+
.map((node) => {
|
|
5
|
+
if (node.type === 'html') {
|
|
6
|
+
return '';
|
|
7
|
+
}
|
|
8
|
+
if ('value' in node) {
|
|
9
|
+
return node.value;
|
|
10
|
+
}
|
|
11
|
+
if ('children' in node) {
|
|
12
|
+
return text(node.children);
|
|
13
|
+
}
|
|
14
|
+
return '';
|
|
15
|
+
})
|
|
16
|
+
.join('');
|
|
17
|
+
}
|
|
18
|
+
export function headingText(heading) {
|
|
19
|
+
return text(heading.children);
|
|
20
|
+
}
|
|
21
|
+
// A changelog opens every release with a depth-2 heading whose first word is the version.
|
|
22
|
+
// The frontmatter is not Markdown, so it is split off before the body is parsed, and every offset counts from the body.
|
|
23
|
+
// The index is -1 while the changelog carries no section for the version.
|
|
24
|
+
export function locateRelease(changelog, version) {
|
|
25
|
+
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---\r?\n/u.exec(changelog)?.[0] ?? '';
|
|
26
|
+
const body = changelog.slice(frontmatter.length);
|
|
27
|
+
const nodes = fromMarkdown(body).children;
|
|
28
|
+
const releases = nodes.flatMap((node) => node.type === 'heading' && node.depth === 2 ? [node] : []);
|
|
29
|
+
const index = releases.findIndex((heading) => headingText(heading).trim().split(/\s/u)[0] === `v${version}`);
|
|
30
|
+
return { body, frontmatter, index, nodes, releases };
|
|
31
|
+
}
|
|
32
|
+
// A fragment must finish its blocks so later release headings remain structural headings.
|
|
33
|
+
export function requireClosedBlocks(source, label) {
|
|
34
|
+
const boundary = fromMarkdown(`${source}\n\n## Release boundary\n`).children.at(-1);
|
|
35
|
+
if (boundary?.type !== 'heading' ||
|
|
36
|
+
boundary.depth !== 2 ||
|
|
37
|
+
headingText(boundary) !== 'Release boundary') {
|
|
38
|
+
throw new Error(`${label}: unclosed Markdown block would swallow later release entries.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { readLibraries } from './repository.js';
|
|
2
|
+
export declare function materialBaseline(root: string, libraries: ReturnType<typeof readLibraries>, version: string, head: string): string;
|
|
3
|
+
export declare function isBuildAffectingPath(path: string): boolean;
|
|
4
|
+
export declare function unchangedLibraries(root: string, libraries: ReturnType<typeof readLibraries>, since: string, head?: string): string[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { currentVersion, git, readParticipants, requireCoherentLibraries, requireFullHistory, } from './repository.js';
|
|
3
|
+
function dependencyName(directory, name, range, libraries) {
|
|
4
|
+
if (!range.startsWith('workspace:')) {
|
|
5
|
+
return name;
|
|
6
|
+
}
|
|
7
|
+
const target = range.slice('workspace:'.length);
|
|
8
|
+
if (target.startsWith('.')) {
|
|
9
|
+
const path = posix.normalize(posix.join(directory, target));
|
|
10
|
+
return libraries.find((library) => library.directory === path)?.manifest.name;
|
|
11
|
+
}
|
|
12
|
+
const separator = target.lastIndexOf('@');
|
|
13
|
+
return separator > 0 ? target.slice(0, separator) : name;
|
|
14
|
+
}
|
|
15
|
+
// Only a commit that carries no participating manifest has no synchronized version.
|
|
16
|
+
// Every other failure of the walk is a defect or a damaged repository, and it propagates.
|
|
17
|
+
function synchronizedVersion(root, ref) {
|
|
18
|
+
if (ref === undefined) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const [first, ...rest] = readParticipants(root, ref);
|
|
22
|
+
if (first === undefined) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return currentVersion(requireCoherentLibraries([first, ...rest])).text;
|
|
26
|
+
}
|
|
27
|
+
// An abandoned version never receives a tag, so the baseline is the commit that set the current version.
|
|
28
|
+
// The walk visits the first-parent commits that touched a participating manifest, newest first.
|
|
29
|
+
export function materialBaseline(root, libraries, version, head) {
|
|
30
|
+
requireFullHistory(root);
|
|
31
|
+
const candidates = git(root, [
|
|
32
|
+
'log',
|
|
33
|
+
'--first-parent',
|
|
34
|
+
'--format=%H %P',
|
|
35
|
+
head,
|
|
36
|
+
'--',
|
|
37
|
+
...libraries.map((library) => library.path),
|
|
38
|
+
])
|
|
39
|
+
.split('\n')
|
|
40
|
+
.flatMap((line) => {
|
|
41
|
+
const [commit, parent] = line.split(' ').filter(Boolean);
|
|
42
|
+
return commit === undefined ? [] : [{ commit, parent }];
|
|
43
|
+
});
|
|
44
|
+
for (const { commit, parent } of candidates) {
|
|
45
|
+
if (synchronizedVersion(root, commit) === version &&
|
|
46
|
+
synchronizedVersion(root, parent) !== version) {
|
|
47
|
+
return commit;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`No first-parent commit sets version ${version}.`);
|
|
51
|
+
}
|
|
52
|
+
// Root Markdown and docs do not enter the current library build.
|
|
53
|
+
export function isBuildAffectingPath(path) {
|
|
54
|
+
return !path.startsWith('docs/') && !(!path.includes('/') && /\.md$/iu.test(path));
|
|
55
|
+
}
|
|
56
|
+
export function unchangedLibraries(root, libraries, since, head) {
|
|
57
|
+
const base = git(root, ['rev-parse', '--verify', '--end-of-options', `${since}^{commit}`]).trim();
|
|
58
|
+
git(root, ['merge-base', '--is-ancestor', base, head ?? 'HEAD']);
|
|
59
|
+
const paths = git(root, [
|
|
60
|
+
'diff',
|
|
61
|
+
'--no-renames',
|
|
62
|
+
'--name-only',
|
|
63
|
+
'-z',
|
|
64
|
+
base,
|
|
65
|
+
...(head === undefined ? [] : [head]),
|
|
66
|
+
'--',
|
|
67
|
+
])
|
|
68
|
+
.split('\0')
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
const untracked = (head === undefined ? git(root, ['ls-files', '--others', '--exclude-standard', '-z']) : '')
|
|
71
|
+
.split('\0')
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
const changed = new Set();
|
|
74
|
+
for (const path of [...paths, ...untracked].filter((candidate) => !candidate.startsWith('.changes/') && isBuildAffectingPath(candidate))) {
|
|
75
|
+
const owner = libraries.find((library) => path.startsWith(`${library.directory}/`));
|
|
76
|
+
if (!owner) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
changed.add(owner.manifest.name);
|
|
80
|
+
}
|
|
81
|
+
let count = 0;
|
|
82
|
+
do {
|
|
83
|
+
count = changed.size;
|
|
84
|
+
for (const library of libraries) {
|
|
85
|
+
const dependencies = [
|
|
86
|
+
library.manifest.dependencies,
|
|
87
|
+
library.manifest.devDependencies,
|
|
88
|
+
library.manifest.optionalDependencies,
|
|
89
|
+
library.manifest.peerDependencies,
|
|
90
|
+
].flatMap((group) => Object.entries(group ?? {}));
|
|
91
|
+
for (const [name, range] of dependencies) {
|
|
92
|
+
const target = dependencyName(library.directory, name, range, libraries);
|
|
93
|
+
if (target && changed.has(target)) {
|
|
94
|
+
changed.add(library.manifest.name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} while (count !== changed.size);
|
|
99
|
+
return libraries
|
|
100
|
+
.map((library) => library.manifest.name)
|
|
101
|
+
.filter((name) => !changed.has(name))
|
|
102
|
+
.toSorted();
|
|
103
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readFragments } from './fragments.js';
|
|
3
|
+
import { isBuildAffectingPath } from './material.js';
|
|
4
|
+
import { checkRelease } from './release-check.js';
|
|
5
|
+
import { currentVersion, git, readLibraries, readRegularFile } from './repository.js';
|
|
6
|
+
export function checkPullRequest(root, options) {
|
|
7
|
+
if (git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true') {
|
|
8
|
+
throw new Error('PR checks require full Git history.');
|
|
9
|
+
}
|
|
10
|
+
const base = git(root, [
|
|
11
|
+
'rev-parse',
|
|
12
|
+
'--verify',
|
|
13
|
+
'--end-of-options',
|
|
14
|
+
`${options.base}^{commit}`,
|
|
15
|
+
]).trim();
|
|
16
|
+
const head = git(root, [
|
|
17
|
+
'rev-parse',
|
|
18
|
+
'--verify',
|
|
19
|
+
'--end-of-options',
|
|
20
|
+
`${options.head}^{commit}`,
|
|
21
|
+
]).trim();
|
|
22
|
+
const ancestor = git(root, ['merge-base', base, head]).trim();
|
|
23
|
+
const fragments = readFragments(root, head);
|
|
24
|
+
const changed = git(root, ['diff', '--no-renames', '--name-only', '-z', ancestor, head, '--'])
|
|
25
|
+
.split('\0')
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
if (options.title.startsWith('chore(release)')) {
|
|
28
|
+
const version = /^chore\(release\): Release v(?<version>0\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)) - (?:\S[^\r\n]*)$/u.exec(options.title)?.groups?.version;
|
|
29
|
+
if (version === undefined) {
|
|
30
|
+
throw new Error('Expected release title: chore(release): Release v<version> - <description>');
|
|
31
|
+
}
|
|
32
|
+
if (ancestor !== base) {
|
|
33
|
+
throw new Error('Release branch must include the current base commit. Update the release cut.');
|
|
34
|
+
}
|
|
35
|
+
if (fragments.length > 0) {
|
|
36
|
+
throw new Error('No fragments may remain after a release cut.');
|
|
37
|
+
}
|
|
38
|
+
checkRelease(root, base, head, version, changed);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const libraries = readLibraries(root, head);
|
|
42
|
+
const previous = readLibraries(root, ancestor);
|
|
43
|
+
const current = currentVersion(previous);
|
|
44
|
+
currentVersion(libraries);
|
|
45
|
+
for (const library of libraries) {
|
|
46
|
+
if (library.manifest.version !== current.text) {
|
|
47
|
+
throw new Error(`${library.path}: ordinary PR version must stay ${current.text}.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const library of previous) {
|
|
51
|
+
if (git(root, ['ls-tree', head, '--', library.path])) {
|
|
52
|
+
const manifest = z
|
|
53
|
+
.object({ version: z.string() })
|
|
54
|
+
.parse(JSON.parse(readRegularFile(root, library.path, head)));
|
|
55
|
+
if (manifest.version !== library.manifest.version) {
|
|
56
|
+
throw new Error(`${library.path}: ordinary PR version must stay ${current.text}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (changed.some(isBuildAffectingPath) &&
|
|
61
|
+
!fragments.some((fragment) => changed.includes(`.changes/${fragment.name}`)) &&
|
|
62
|
+
!options.labels.includes('skip-changelog')) {
|
|
63
|
+
throw new Error('Build-affecting PRs require an added or modified fragment or skip-changelog.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HttpPolicy } from './http.js';
|
|
2
|
+
export interface RegistryTarget {
|
|
3
|
+
policy: HttpPolicy;
|
|
4
|
+
registry: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readPublished(target: RegistryTarget, name: string, version: string): Promise<{
|
|
7
|
+
[x: string]: unknown;
|
|
8
|
+
integrity: string;
|
|
9
|
+
tarball: string;
|
|
10
|
+
} | undefined>;
|
|
11
|
+
export declare function readProvenance(target: RegistryTarget, name: string, version: string): Promise<{
|
|
12
|
+
commit: string;
|
|
13
|
+
uri: string;
|
|
14
|
+
} | undefined>;
|
|
15
|
+
export declare function tarballName(name: string, version: string): string;
|
|
16
|
+
export declare function verifyIntegrity(subject: string, bytes: Buffer, integrity: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { readJson } from './http.js';
|
|
4
|
+
const packumentSchema = z.looseObject({ versions: z.record(z.string(), z.unknown()) });
|
|
5
|
+
const distSchema = z.looseObject({ integrity: z.string().min(1), tarball: z.string().min(1) });
|
|
6
|
+
const releasedSchema = z.looseObject({ dist: distSchema });
|
|
7
|
+
const envelopeSchema = z.looseObject({ payload: z.string() });
|
|
8
|
+
const bundleSchema = z.looseObject({ dsseEnvelope: envelopeSchema });
|
|
9
|
+
const attestationSchema = z.looseObject({ bundle: bundleSchema, predicateType: z.string() });
|
|
10
|
+
const attestationsSchema = z.looseObject({ attestations: z.array(attestationSchema) });
|
|
11
|
+
const digestSchema = z.looseObject({ gitCommit: z.string().min(1) });
|
|
12
|
+
const dependencySchema = z.looseObject({ digest: digestSchema, uri: z.string() });
|
|
13
|
+
// The provenance statement names the source of the build in its first resolved dependency.
|
|
14
|
+
const buildDefinitionSchema = z.looseObject({ resolvedDependencies: z.array(dependencySchema) });
|
|
15
|
+
const predicateSchema = z.looseObject({ buildDefinition: buildDefinitionSchema });
|
|
16
|
+
const subjectSchema = z.looseObject({ name: z.string() });
|
|
17
|
+
const provenanceSchema = z.looseObject({
|
|
18
|
+
predicate: predicateSchema,
|
|
19
|
+
subject: z.array(subjectSchema),
|
|
20
|
+
});
|
|
21
|
+
const provenancePredicateType = 'https://slsa.dev/provenance/v1';
|
|
22
|
+
// Npm attests a version under its package URL, whose scope separator is percent-encoded.
|
|
23
|
+
function packageUrl(name, version) {
|
|
24
|
+
return `pkg:npm/${name.replace('@', '%40')}@${version}`;
|
|
25
|
+
}
|
|
26
|
+
function endpoint(target, path) {
|
|
27
|
+
return `${target.registry.replace(/\/+$/u, '')}/${path}`;
|
|
28
|
+
}
|
|
29
|
+
// The distribution facts of one published version, or undefined while the registry lacks it.
|
|
30
|
+
export async function readPublished(target, name, version) {
|
|
31
|
+
const { data } = await readJson(target.policy, endpoint(target, name));
|
|
32
|
+
if (data === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const released = packumentSchema.parse(data).versions[version];
|
|
36
|
+
return released === undefined ? undefined : releasedSchema.parse(released).dist;
|
|
37
|
+
}
|
|
38
|
+
// The commit and the source repository npm attests for one published version.
|
|
39
|
+
export async function readProvenance(target, name, version) {
|
|
40
|
+
const path = `-/npm/v1/attestations/${name}@${version}`;
|
|
41
|
+
const { data } = await readJson(target.policy, endpoint(target, path));
|
|
42
|
+
if (data === undefined) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const attestation = attestationsSchema
|
|
46
|
+
.parse(data)
|
|
47
|
+
.attestations.find((candidate) => candidate.predicateType === provenancePredicateType);
|
|
48
|
+
if (attestation === undefined) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const statement = Buffer.from(attestation.bundle.dsseEnvelope.payload, 'base64').toString('utf8');
|
|
52
|
+
const payload = JSON.parse(statement);
|
|
53
|
+
const provenance = provenanceSchema.parse(payload);
|
|
54
|
+
const purl = packageUrl(name, version);
|
|
55
|
+
if (!provenance.subject.some((subject) => subject.name === purl)) {
|
|
56
|
+
const attested = provenance.subject.map((subject) => subject.name).join(', ');
|
|
57
|
+
throw new Error(`${name}@${version}: its provenance attests ${attested || 'nothing'}, not ${purl}.`);
|
|
58
|
+
}
|
|
59
|
+
const [source] = provenance.predicate.buildDefinition.resolvedDependencies;
|
|
60
|
+
if (source === undefined) {
|
|
61
|
+
throw new Error(`${name}@${version}: its provenance resolves no source repository.`);
|
|
62
|
+
}
|
|
63
|
+
return { commit: source.digest.gitCommit, uri: source.uri };
|
|
64
|
+
}
|
|
65
|
+
// A published tarball keeps the name npm pack gives it, and its Release asset uses that same name.
|
|
66
|
+
export function tarballName(name, version) {
|
|
67
|
+
return `${name.replace(/^@/u, '').replace('/', '-')}-${version}.tgz`;
|
|
68
|
+
}
|
|
69
|
+
export function verifyIntegrity(subject, bytes, integrity) {
|
|
70
|
+
const expected = /^sha512-(?<digest>[\w+/=]+)$/u.exec(integrity)?.groups?.digest;
|
|
71
|
+
if (expected === undefined) {
|
|
72
|
+
throw new Error(`${subject}: the registry reports unsupported integrity ${integrity}.`);
|
|
73
|
+
}
|
|
74
|
+
const actual = createHash('sha512').update(bytes).digest('base64');
|
|
75
|
+
if (actual !== expected) {
|
|
76
|
+
throw new Error(`${subject}: the downloaded tarball hashes to sha512-${actual}, not ${integrity}.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function checkRelease(root: string, base: string, head: string, version: string, changed: string[]): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { prepareLockfile, releaseInsertion } from './release-files.js';
|
|
3
|
+
import { prepareRelease, releaseDate, requireReleaseNotes } from './release.js';
|
|
4
|
+
import { currentVersion, git, readLibraries, readRegularFile } from './repository.js';
|
|
5
|
+
export function checkRelease(root, base, head, version, changed) {
|
|
6
|
+
const previous = readLibraries(root, base);
|
|
7
|
+
const libraries = readLibraries(root, head);
|
|
8
|
+
const changelog = readRegularFile(root, 'CHANGELOG.md', head);
|
|
9
|
+
const { after, before } = releaseInsertion(readRegularFile(root, 'CHANGELOG.md', base), version);
|
|
10
|
+
if (!changelog.startsWith(before) ||
|
|
11
|
+
!changelog.endsWith(after) ||
|
|
12
|
+
changelog.length <= before.length + after.length) {
|
|
13
|
+
throw new Error('CHANGELOG.md must preserve its introduction and earlier releases.');
|
|
14
|
+
}
|
|
15
|
+
const section = changelog.slice(before.length, changelog.length - after.length);
|
|
16
|
+
const dateInput = /^## v0\.\d+\.\d+ - (?<date>\d{4}-\d{2}-\d{2})\n\n/u.exec(section)?.groups
|
|
17
|
+
?.date;
|
|
18
|
+
if (dateInput === undefined) {
|
|
19
|
+
throw new Error('CHANGELOG.md requires a compiled release heading and date.');
|
|
20
|
+
}
|
|
21
|
+
const options = {
|
|
22
|
+
date: releaseDate(dateInput),
|
|
23
|
+
initial: currentVersion(previous).text === '0.0.0',
|
|
24
|
+
narrative: undefined,
|
|
25
|
+
since: undefined,
|
|
26
|
+
};
|
|
27
|
+
const release = prepareRelease(root, options, base);
|
|
28
|
+
if (version !== release.version) {
|
|
29
|
+
throw new Error(`Release version must be ${release.version}; replacement overrides are not supported.`);
|
|
30
|
+
}
|
|
31
|
+
if (currentVersion(libraries).text !== version) {
|
|
32
|
+
throw new Error(`Every participating library must carry title version ${version}.`);
|
|
33
|
+
}
|
|
34
|
+
if (git(root, ['tag', '--list', `v${version}`]).trim()) {
|
|
35
|
+
throw new Error(`Tag v${version} already exists.`);
|
|
36
|
+
}
|
|
37
|
+
if (!isDeepStrictEqual(libraries.map((library) => library.path).toSorted(), previous.map((library) => library.path).toSorted())) {
|
|
38
|
+
throw new Error('Release cuts cannot change participating libraries.');
|
|
39
|
+
}
|
|
40
|
+
for (const library of libraries) {
|
|
41
|
+
const original = previous.find((candidate) => candidate.path === library.path);
|
|
42
|
+
if (!isDeepStrictEqual(library.manifest, { ...original?.manifest, version })) {
|
|
43
|
+
throw new Error(`${library.path}: release cuts change only the version field.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const allowed = new Set([
|
|
47
|
+
'CHANGELOG.md',
|
|
48
|
+
'pnpm-lock.yaml',
|
|
49
|
+
...libraries.map((library) => library.path),
|
|
50
|
+
...release.fragments.map((fragment) => `.changes/${fragment.name}`),
|
|
51
|
+
]);
|
|
52
|
+
for (const path of changed) {
|
|
53
|
+
if (!allowed.has(path)) {
|
|
54
|
+
throw new Error(`${path}: outside the release cut.`);
|
|
55
|
+
}
|
|
56
|
+
const oldMode = git(root, ['ls-tree', base, '--', path]).slice(0, 6);
|
|
57
|
+
const newMode = git(root, ['ls-tree', head, '--', path]).slice(0, 6);
|
|
58
|
+
if (oldMode && newMode && oldMode !== newMode) {
|
|
59
|
+
throw new Error(`${path}: release cuts cannot change a file mode.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (readRegularFile(root, 'pnpm-lock.yaml', head) !== prepareLockfile(root, release, base)) {
|
|
63
|
+
throw new Error('pnpm-lock.yaml must match the version writer; unrelated lockfile changes need an ordinary PR.');
|
|
64
|
+
}
|
|
65
|
+
const heading = `## v${release.version} - ${options.date}\n\n`;
|
|
66
|
+
const entries = release.section.slice(heading.length);
|
|
67
|
+
if (!section.startsWith(heading) || !section.endsWith(entries)) {
|
|
68
|
+
throw new Error('CHANGELOG.md must contain the compiler output for the consumed fragments.');
|
|
69
|
+
}
|
|
70
|
+
const narrative = section.slice(heading.length, section.length - entries.length);
|
|
71
|
+
const compiled = prepareRelease(root, { ...options, narrative: narrative || undefined }, base);
|
|
72
|
+
if (section !== compiled.section) {
|
|
73
|
+
throw new Error('CHANGELOG.md must contain exactly one compiled release section.');
|
|
74
|
+
}
|
|
75
|
+
requireReleaseNotes(compiled);
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { prepareRelease } from './release.js';
|
|
2
|
+
declare function releaseInsertion(changelog: string, version: string): {
|
|
3
|
+
after: string;
|
|
4
|
+
before: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The installed pnpm manifest's path. pnpm is an optional peer dependency, because only lockfile
|
|
8
|
+
* preparation runs it, so a missing one fails with a sentence instead of a resolve error.
|
|
9
|
+
*/
|
|
10
|
+
declare function pnpmManifestPath(resolve?: (specifier: string) => string): string;
|
|
11
|
+
declare function versionedManifest(source: string, version: string): string;
|
|
12
|
+
export declare function prepareLockfile(root: string, release: Pick<ReturnType<typeof prepareRelease>, 'libraries' | 'version'>, ref?: string): string;
|
|
13
|
+
export { pnpmManifestPath, releaseInsertion, versionedManifest };
|