@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,97 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseAllDocuments } from 'yaml';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { headingText, locateRelease, requireClosedBlocks } from './markdown.js';
|
|
9
|
+
import { blankLine } from './release.js';
|
|
10
|
+
import { git, readRegularFile, readRegularFileBytes } from './repository.js';
|
|
11
|
+
function releaseInsertion(changelog, version) {
|
|
12
|
+
const { body, frontmatter, nodes, releases } = locateRelease(changelog, version);
|
|
13
|
+
requireClosedBlocks(body, 'CHANGELOG.md');
|
|
14
|
+
const headings = nodes.filter((node) => node.type === 'heading');
|
|
15
|
+
if (!headings.some((node) => node.depth === 1)) {
|
|
16
|
+
throw new Error('CHANGELOG.md requires a title.');
|
|
17
|
+
}
|
|
18
|
+
if (headings.some((node) => headingText(node).trim().toLowerCase() === 'unreleased' ||
|
|
19
|
+
headingText(node).trim().split(/\s/u)[0] === `v${version}`)) {
|
|
20
|
+
throw new Error('CHANGELOG.md already contains this version or an Unreleased section.');
|
|
21
|
+
}
|
|
22
|
+
const offset = releases[0]?.position?.start.offset;
|
|
23
|
+
const before = offset === undefined ? changelog : changelog.slice(0, frontmatter.length + offset);
|
|
24
|
+
const after = offset === undefined ? '' : changelog.slice(frontmatter.length + offset);
|
|
25
|
+
return { after, before: before + blankLine(before) };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The installed pnpm manifest's path. pnpm is an optional peer dependency, because only lockfile
|
|
29
|
+
* preparation runs it, so a missing one fails with a sentence instead of a resolve error.
|
|
30
|
+
*/
|
|
31
|
+
function pnpmManifestPath(resolve = (specifier) => import.meta.resolve(specifier)) {
|
|
32
|
+
try {
|
|
33
|
+
return fileURLToPath(resolve('pnpm/package.json'));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new Error('Preparing pnpm-lock.yaml requires pnpm, an optional peer dependency of @loomcli/loom. Install pnpm 12.3.2 beside @loomcli/loom and run the command again.', { cause: error });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function updateLockfile(root) {
|
|
40
|
+
const lock = join(root, 'pnpm-lock.yaml');
|
|
41
|
+
if (existsSync(lock)) {
|
|
42
|
+
for (const document of parseAllDocuments(readRegularFile(root, 'pnpm-lock.yaml'))) {
|
|
43
|
+
if (document.errors.length > 0) {
|
|
44
|
+
throw new Error('pnpm-lock.yaml is invalid YAML.');
|
|
45
|
+
}
|
|
46
|
+
z.object({ lockfileVersion: z.union([z.string(), z.number()]) }).parse(document.toJS());
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const manifestPath = pnpmManifestPath();
|
|
50
|
+
const manifestSource = readRegularFile(dirname(manifestPath), 'package.json');
|
|
51
|
+
const manifest = z
|
|
52
|
+
.object({ bin: z.object({ pnpm: z.string() }) })
|
|
53
|
+
.parse(JSON.parse(manifestSource));
|
|
54
|
+
const result = spawnSync(join(dirname(manifestPath), manifest.bin.pnpm), [
|
|
55
|
+
'install',
|
|
56
|
+
'--lockfile-only',
|
|
57
|
+
'--lockfile-dir',
|
|
58
|
+
'.',
|
|
59
|
+
'--offline',
|
|
60
|
+
'--ignore-scripts',
|
|
61
|
+
'--ignore-pnpmfile',
|
|
62
|
+
'--config.frozen-lockfile=false',
|
|
63
|
+
], {
|
|
64
|
+
cwd: root,
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
timeout: 60_000,
|
|
67
|
+
});
|
|
68
|
+
if (result.error) {
|
|
69
|
+
throw result.error;
|
|
70
|
+
}
|
|
71
|
+
if (result.status !== 0) {
|
|
72
|
+
throw new Error(`Lockfile preparation failed:\n${result.stdout}${result.stderr}`);
|
|
73
|
+
}
|
|
74
|
+
return readRegularFile(root, 'pnpm-lock.yaml');
|
|
75
|
+
}
|
|
76
|
+
function versionedManifest(source, version) {
|
|
77
|
+
const document = z.record(z.string(), z.unknown()).parse(JSON.parse(source));
|
|
78
|
+
return `${JSON.stringify({ ...document, version }, null, 2)}\n`;
|
|
79
|
+
}
|
|
80
|
+
export function prepareLockfile(root, release, ref) {
|
|
81
|
+
const stage = mkdtempSync(join(tmpdir(), 'loom-release-'));
|
|
82
|
+
try {
|
|
83
|
+
const paths = git(root, ref === undefined ? ['ls-files', '-z'] : ['ls-tree', '-r', '--name-only', '-z', ref]);
|
|
84
|
+
for (const path of paths.split('\0').filter(Boolean)) {
|
|
85
|
+
mkdirSync(dirname(join(stage, path)), { recursive: true });
|
|
86
|
+
writeFileSync(join(stage, path), readRegularFileBytes(root, path, ref));
|
|
87
|
+
}
|
|
88
|
+
for (const library of release.libraries) {
|
|
89
|
+
writeFileSync(join(stage, library.path), versionedManifest(library.source, release.version));
|
|
90
|
+
}
|
|
91
|
+
return updateLockfile(stage);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(stage, { force: true, recursive: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export { pnpmManifestPath, releaseInsertion, versionedManifest };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { HttpPolicy } from './http.js';
|
|
3
|
+
export declare const planSchema: z.ZodObject<{
|
|
4
|
+
cutCommit: z.ZodString;
|
|
5
|
+
head: z.ZodString;
|
|
6
|
+
libraries: z.ZodTuple<[z.ZodObject<{
|
|
7
|
+
directory: z.ZodString;
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
published: z.ZodBoolean;
|
|
10
|
+
}, z.core.$strip>], z.ZodObject<{
|
|
11
|
+
directory: z.ZodString;
|
|
12
|
+
name: z.ZodString;
|
|
13
|
+
published: z.ZodBoolean;
|
|
14
|
+
}, z.core.$strip>>;
|
|
15
|
+
notes: z.ZodString;
|
|
16
|
+
provenanceCommit: z.ZodNullable<z.ZodString>;
|
|
17
|
+
publish: z.ZodBoolean;
|
|
18
|
+
record: z.ZodBoolean;
|
|
19
|
+
release: z.ZodObject<{
|
|
20
|
+
present: z.ZodBoolean;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
tag: z.ZodObject<{
|
|
23
|
+
commit: z.ZodNullable<z.ZodString>;
|
|
24
|
+
present: z.ZodBoolean;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
version: z.ZodString;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type ReleasePlan = z.output<typeof planSchema>;
|
|
29
|
+
export interface PlanRequest {
|
|
30
|
+
githubApi: string;
|
|
31
|
+
head: string;
|
|
32
|
+
policy: HttpPolicy;
|
|
33
|
+
registry: string;
|
|
34
|
+
repository: string;
|
|
35
|
+
root: string;
|
|
36
|
+
token: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
export declare function planRelease(request: PlanRequest): Promise<ReleasePlan>;
|
|
39
|
+
export declare function renderPlan(plan: ReleasePlan): string;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isUploadedAsset, readRelease, readTagCommit } from './github.js';
|
|
3
|
+
import { locateRelease } from './markdown.js';
|
|
4
|
+
import { materialBaseline } from './material.js';
|
|
5
|
+
import { readProvenance, readPublished, tarballName } from './registry.js';
|
|
6
|
+
import { currentVersion, git, readLibraries, readRegularFile, requireFullHistory, } from './repository.js';
|
|
7
|
+
const librarySchema = z.object({
|
|
8
|
+
directory: z.string().min(1),
|
|
9
|
+
name: z.string().min(1),
|
|
10
|
+
published: z.boolean(),
|
|
11
|
+
});
|
|
12
|
+
const releaseFactsSchema = z.object({ present: z.boolean() });
|
|
13
|
+
const tagFactsSchema = z.object({ commit: z.string().nullable(), present: z.boolean() });
|
|
14
|
+
// The manifests carry this version until the initial cut, and it is never published, tagged, or released.
|
|
15
|
+
const unreleasedVersion = '0.0.0';
|
|
16
|
+
// A heading, a rule, and raw HTML carry no notes, so a section built only from them says nothing.
|
|
17
|
+
const decorations = new Set(['heading', 'html', 'thematicBreak']);
|
|
18
|
+
// The notes are the body under the version's heading, up to the next release heading.
|
|
19
|
+
function changelogSection(changelog, version) {
|
|
20
|
+
const { body, index, nodes, releases } = locateRelease(changelog, version);
|
|
21
|
+
const start = releases[index]?.position?.end.offset;
|
|
22
|
+
if (start === undefined) {
|
|
23
|
+
throw new Error(`CHANGELOG.md has no v${version} section.`);
|
|
24
|
+
}
|
|
25
|
+
const end = releases[index + 1]?.position?.start.offset ?? body.length;
|
|
26
|
+
const carries = nodes.some((node) => {
|
|
27
|
+
const offset = node.position?.start.offset;
|
|
28
|
+
return offset !== undefined && offset >= start && offset < end && !decorations.has(node.type);
|
|
29
|
+
});
|
|
30
|
+
if (!carries) {
|
|
31
|
+
throw new Error(`CHANGELOG.md: the v${version} section has no notes.`);
|
|
32
|
+
}
|
|
33
|
+
return body.slice(start, end).trim();
|
|
34
|
+
}
|
|
35
|
+
// A Release is complete once it carries every expected asset as a finished upload.
|
|
36
|
+
// The exception: a Release that carries none of the expected assets but carries others was recorded by another process, and it is left alone.
|
|
37
|
+
function releaseComplete(assets, expected) {
|
|
38
|
+
const carriesExpected = expected.some((name) => assets.some((asset) => asset.name === name));
|
|
39
|
+
if (!carriesExpected && assets.length > 0) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return expected.every((name) => assets.some((asset) => asset.name === name && isUploadedAsset(asset)));
|
|
43
|
+
}
|
|
44
|
+
function abandon(version, reason) {
|
|
45
|
+
return new Error(`Version ${version} is absent from the registry and ${reason} Publication attests the head, so ${version} must be abandoned as unpublished and superseded by the next cut.`);
|
|
46
|
+
}
|
|
47
|
+
// Every participating library, named without a registry read, because the unreleased version is never published.
|
|
48
|
+
function unreleasedLibraries(participants) {
|
|
49
|
+
const [first, ...others] = participants;
|
|
50
|
+
const libraries = [
|
|
51
|
+
{ directory: first.directory, name: first.manifest.name, published: false },
|
|
52
|
+
];
|
|
53
|
+
for (const other of others) {
|
|
54
|
+
libraries.push({ directory: other.directory, name: other.manifest.name, published: false });
|
|
55
|
+
}
|
|
56
|
+
return libraries;
|
|
57
|
+
}
|
|
58
|
+
// The registry facts of one participating library at the released version.
|
|
59
|
+
async function describeLibrary(registry, participant, version) {
|
|
60
|
+
const name = participant.manifest.name;
|
|
61
|
+
const published = await readPublished(registry, name, version);
|
|
62
|
+
return { directory: participant.directory, name, published: published !== undefined };
|
|
63
|
+
}
|
|
64
|
+
// Every published library attests the same build commit, and that commit is where the tag belongs.
|
|
65
|
+
async function publishedCommit(registry, libraries, version) {
|
|
66
|
+
let commit = undefined;
|
|
67
|
+
for (const library of libraries) {
|
|
68
|
+
const provenance = await readProvenance(registry, library.name, version);
|
|
69
|
+
if (provenance === undefined) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (commit !== undefined && provenance.commit !== commit) {
|
|
73
|
+
throw new Error(`${library.name}@${version} was published from ${provenance.commit}, but an earlier library names ${commit}.`);
|
|
74
|
+
}
|
|
75
|
+
commit = provenance.commit;
|
|
76
|
+
}
|
|
77
|
+
return commit;
|
|
78
|
+
}
|
|
79
|
+
// A tag anywhere other than the commit the version was published from fails the run, and recording never moves one.
|
|
80
|
+
function requireTagAtPublication(tag, tagCommit, provenanceCommit, version) {
|
|
81
|
+
if (tagCommit === undefined) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (provenanceCommit === null) {
|
|
85
|
+
throw new Error(`Tag ${tag} names ${tagCommit}, but the registry reports no provenance for ${version}, so the commit it was published from is unknown.`);
|
|
86
|
+
}
|
|
87
|
+
if (tagCommit !== provenanceCommit) {
|
|
88
|
+
throw new Error(`Tag ${tag} names ${tagCommit}, but ${version} was published from ${provenanceCommit}; recording never moves a tag.`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// The paths whose bytes can reach a published library build.
|
|
92
|
+
// Publication attests the head commit, so a change to any of them after the cut commit would attest bytes the cut did not produce.
|
|
93
|
+
// Documentation and root Markdown are absent from this set and never refuse a publication.
|
|
94
|
+
const sharedBuildInputs = [
|
|
95
|
+
'package.json',
|
|
96
|
+
'pnpm-lock.yaml',
|
|
97
|
+
'pnpm-workspace.yaml',
|
|
98
|
+
'scripts/clean.mjs',
|
|
99
|
+
'tsconfig.json',
|
|
100
|
+
];
|
|
101
|
+
export const planSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
cutCommit: z.string().min(1),
|
|
104
|
+
head: z.string().min(1),
|
|
105
|
+
// A release participates with at least one library, and the record command reads the first one.
|
|
106
|
+
libraries: z.tuple([librarySchema], librarySchema),
|
|
107
|
+
notes: z.string(),
|
|
108
|
+
provenanceCommit: z.string().min(1).nullable(),
|
|
109
|
+
publish: z.boolean(),
|
|
110
|
+
record: z.boolean(),
|
|
111
|
+
release: releaseFactsSchema,
|
|
112
|
+
tag: tagFactsSchema,
|
|
113
|
+
version: z.string().min(1),
|
|
114
|
+
})
|
|
115
|
+
// The notes are the body of the Release, so only the unreleased version plans without them.
|
|
116
|
+
.refine((plan) => plan.notes !== '' || plan.version === unreleasedVersion, {
|
|
117
|
+
error: 'A plan for a released version carries notes.',
|
|
118
|
+
path: ['notes'],
|
|
119
|
+
});
|
|
120
|
+
export async function planRelease(request) {
|
|
121
|
+
requireFullHistory(request.root);
|
|
122
|
+
const head = git(request.root, [
|
|
123
|
+
'rev-parse',
|
|
124
|
+
'--verify',
|
|
125
|
+
'--end-of-options',
|
|
126
|
+
`${request.head}^{commit}`,
|
|
127
|
+
]).trim();
|
|
128
|
+
const participants = readLibraries(request.root, head);
|
|
129
|
+
const version = currentVersion(participants).text;
|
|
130
|
+
const cutCommit = materialBaseline(request.root, participants, version, head);
|
|
131
|
+
// The unreleased version has no registry record, no changelog section, and nothing to reconcile.
|
|
132
|
+
if (version === unreleasedVersion) {
|
|
133
|
+
return {
|
|
134
|
+
cutCommit,
|
|
135
|
+
head,
|
|
136
|
+
libraries: unreleasedLibraries(participants),
|
|
137
|
+
notes: '',
|
|
138
|
+
provenanceCommit: null,
|
|
139
|
+
publish: false,
|
|
140
|
+
record: false,
|
|
141
|
+
release: { present: false },
|
|
142
|
+
tag: { commit: null, present: false },
|
|
143
|
+
version,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const changelog = readRegularFile(request.root, 'CHANGELOG.md', head);
|
|
147
|
+
const notes = changelogSection(changelog, version);
|
|
148
|
+
const [participant, ...others] = participants;
|
|
149
|
+
const registry = { policy: request.policy, registry: request.registry };
|
|
150
|
+
const libraries = [
|
|
151
|
+
await describeLibrary(registry, participant, version),
|
|
152
|
+
];
|
|
153
|
+
for (const other of others) {
|
|
154
|
+
libraries.push(await describeLibrary(registry, other, version));
|
|
155
|
+
}
|
|
156
|
+
const tag = `v${version}`;
|
|
157
|
+
const target = {
|
|
158
|
+
api: request.githubApi,
|
|
159
|
+
policy: request.policy,
|
|
160
|
+
repository: request.repository,
|
|
161
|
+
token: request.token,
|
|
162
|
+
};
|
|
163
|
+
const tagCommit = await readTagCommit(target, tag);
|
|
164
|
+
const release = await readRelease(target, tag);
|
|
165
|
+
const publish = libraries.some((library) => !library.published);
|
|
166
|
+
let provenanceCommit = null;
|
|
167
|
+
if (publish) {
|
|
168
|
+
const changed = git(request.root, [
|
|
169
|
+
'diff',
|
|
170
|
+
'--name-only',
|
|
171
|
+
'-z',
|
|
172
|
+
cutCommit,
|
|
173
|
+
head,
|
|
174
|
+
'--',
|
|
175
|
+
...libraries.map((library) => library.directory),
|
|
176
|
+
...sharedBuildInputs,
|
|
177
|
+
])
|
|
178
|
+
.split('\0')
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
if (changed.length > 0) {
|
|
181
|
+
const paths = changed.join(', ');
|
|
182
|
+
throw abandon(version, `${paths} changed between the cut commit ${cutCommit} and the head ${head}.`);
|
|
183
|
+
}
|
|
184
|
+
if (tagCommit !== undefined && tagCommit !== head) {
|
|
185
|
+
throw abandon(version, `tag ${tag} already names ${tagCommit} instead of the head ${head}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
provenanceCommit = (await publishedCommit(registry, libraries, version)) ?? null;
|
|
190
|
+
requireTagAtPublication(tag, tagCommit, provenanceCommit, version);
|
|
191
|
+
}
|
|
192
|
+
const expected = libraries.map((library) => tarballName(library.name, version));
|
|
193
|
+
return {
|
|
194
|
+
cutCommit,
|
|
195
|
+
head,
|
|
196
|
+
libraries,
|
|
197
|
+
notes,
|
|
198
|
+
provenanceCommit,
|
|
199
|
+
publish,
|
|
200
|
+
record: tagCommit === undefined ||
|
|
201
|
+
release === undefined ||
|
|
202
|
+
!releaseComplete(release.assets, expected),
|
|
203
|
+
release: { present: release !== undefined },
|
|
204
|
+
tag: { commit: tagCommit ?? null, present: tagCommit !== undefined },
|
|
205
|
+
version,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function renderPlan(plan) {
|
|
209
|
+
const tag = `v${plan.version}`;
|
|
210
|
+
if (plan.version === unreleasedVersion) {
|
|
211
|
+
return `${plan.version} is the unreleased version at head ${plan.head}, so there is nothing to reconcile.\nPublish: no. Record: no.\n`;
|
|
212
|
+
}
|
|
213
|
+
const lines = [
|
|
214
|
+
`Plan ${tag} at head ${plan.head}, cut commit ${plan.cutCommit}.`,
|
|
215
|
+
...plan.libraries.map((library) => `${library.name}: ${library.published ? 'published' : 'absent from the registry'}.`),
|
|
216
|
+
`Tag ${tag}: ${plan.tag.commit === null ? 'absent' : `present at ${plan.tag.commit}`}.`,
|
|
217
|
+
`Release ${tag}: ${plan.release.present ? 'present' : 'absent'}.`,
|
|
218
|
+
`Publish: ${plan.publish ? 'yes' : 'no'}. Record: ${plan.record ? 'yes' : 'no'}.`,
|
|
219
|
+
];
|
|
220
|
+
return `${lines.join('\n')}\n`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RetryPolicy } from './http.js';
|
|
2
|
+
import type { ReleasePlan } from './release-plan.js';
|
|
3
|
+
export interface RecordRequest {
|
|
4
|
+
githubApi: string;
|
|
5
|
+
plan: ReleasePlan;
|
|
6
|
+
registry: string;
|
|
7
|
+
repository: string;
|
|
8
|
+
retry: RetryPolicy;
|
|
9
|
+
root: string;
|
|
10
|
+
token: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function recordRelease(request: RecordRequest): Promise<string>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRelease, createTag, isUploadedAsset, readAssets, readRelease, readTagCommit, removeAsset, uploadAsset, } from './github.js';
|
|
2
|
+
import { readBytes, until } from './http.js';
|
|
3
|
+
import { readProvenance, readPublished, tarballName, verifyIntegrity } from './registry.js';
|
|
4
|
+
import { git } from './repository.js';
|
|
5
|
+
// The bytes, the build commit, and the asset name of one published library.
|
|
6
|
+
async function readUpload(request, library, lines) {
|
|
7
|
+
const { plan, retry } = request;
|
|
8
|
+
const registry = { policy: retry, registry: request.registry };
|
|
9
|
+
const name = library.name;
|
|
10
|
+
const subject = `${name}@${plan.version}`;
|
|
11
|
+
const published = await until(retry, `${subject} on the registry`, async () => readPublished(registry, name, plan.version));
|
|
12
|
+
lines.push(`Read ${subject} from the registry.`);
|
|
13
|
+
const provenance = await until(retry, `the provenance of ${subject}`, async () => readProvenance(registry, name, plan.version));
|
|
14
|
+
const source = `git+https://github.com/${request.repository}@refs/heads/main`;
|
|
15
|
+
if (provenance.uri !== source) {
|
|
16
|
+
throw new Error(`${subject}: its provenance names ${provenance.uri}, which is not a build of ${source}.`);
|
|
17
|
+
}
|
|
18
|
+
const bytes = await until(retry, `the ${subject} tarball`, async () => readBytes(retry, published.tarball));
|
|
19
|
+
verifyIntegrity(subject, bytes, published.integrity);
|
|
20
|
+
lines.push(`Verified the ${subject} tarball against ${published.integrity}.`);
|
|
21
|
+
return { bytes, commit: provenance.commit, name: tarballName(name, plan.version), subject };
|
|
22
|
+
}
|
|
23
|
+
// Provenance attests a commit of this repository, and the checkout proves it is one this branch carries.
|
|
24
|
+
function requireCommitInCheckout(root, commit, subject) {
|
|
25
|
+
const head = git(root, ['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}']).trim();
|
|
26
|
+
if (head === commit) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
git(root, ['merge-base', '--is-ancestor', commit, head]);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error(`${subject} was published from ${commit}, which is neither the head ${head} of the checkout nor an ancestor of it.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Reconciles the tag, the Release, and its assets with the versions the registry already carries.
|
|
37
|
+
// Every step reads before it writes, and the bytes are verified before the first write.
|
|
38
|
+
export async function recordRelease(request) {
|
|
39
|
+
const { plan, retry } = request;
|
|
40
|
+
const version = plan.version;
|
|
41
|
+
const tag = `v${version}`;
|
|
42
|
+
const target = {
|
|
43
|
+
api: request.githubApi,
|
|
44
|
+
policy: retry,
|
|
45
|
+
repository: request.repository,
|
|
46
|
+
token: request.token,
|
|
47
|
+
};
|
|
48
|
+
const lines = [];
|
|
49
|
+
const first = await readUpload(request, plan.libraries[0], lines);
|
|
50
|
+
const uploads = [first];
|
|
51
|
+
for (const library of plan.libraries.slice(1)) {
|
|
52
|
+
const upload = await readUpload(request, library, lines);
|
|
53
|
+
if (upload.commit !== first.commit) {
|
|
54
|
+
throw new Error(`${upload.subject} was published from ${upload.commit}, but ${first.subject} was published from ${first.commit}.`);
|
|
55
|
+
}
|
|
56
|
+
uploads.push(upload);
|
|
57
|
+
}
|
|
58
|
+
const commit = first.commit;
|
|
59
|
+
requireCommitInCheckout(request.root, commit, tag);
|
|
60
|
+
const tagged = await readTagCommit(target, tag);
|
|
61
|
+
if (tagged === undefined) {
|
|
62
|
+
await createTag(target, tag, commit);
|
|
63
|
+
await until(retry, `the tag ${tag}`, async () => readTagCommit(target, tag));
|
|
64
|
+
lines.push(`Created the annotated tag ${tag} at ${commit}.`);
|
|
65
|
+
}
|
|
66
|
+
else if (tagged === commit) {
|
|
67
|
+
lines.push(`Reused the tag ${tag} at ${commit}.`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw new Error(`Tag ${tag} names ${tagged}, but ${version} was published from ${commit}; recording never moves a tag.`);
|
|
71
|
+
}
|
|
72
|
+
const found = await readRelease(target, tag);
|
|
73
|
+
if (found === undefined) {
|
|
74
|
+
await createRelease(target, tag, plan.notes);
|
|
75
|
+
}
|
|
76
|
+
const release = found ?? (await until(retry, `the ${tag} Release`, async () => readRelease(target, tag)));
|
|
77
|
+
lines.push(found === undefined ? `Created the ${tag} Release.` : `Reused the ${tag} Release.`);
|
|
78
|
+
for (const upload of uploads) {
|
|
79
|
+
const assets = await readAssets(target, release.id);
|
|
80
|
+
const present = assets.find((asset) => asset.name === upload.name);
|
|
81
|
+
if (present !== undefined && isUploadedAsset(present)) {
|
|
82
|
+
lines.push(`Kept the ${upload.name} asset.`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (present !== undefined) {
|
|
86
|
+
await removeAsset(target, present.id);
|
|
87
|
+
lines.push(`Deleted the unfinished ${upload.name} asset.`);
|
|
88
|
+
}
|
|
89
|
+
await uploadAsset(target, release.uploadUrl, upload.name, upload.bytes);
|
|
90
|
+
await until(retry, `the ${upload.name} asset`, async () => {
|
|
91
|
+
const uploaded = await readAssets(target, release.id);
|
|
92
|
+
return uploaded.find((asset) => asset.name === upload.name && isUploadedAsset(asset));
|
|
93
|
+
});
|
|
94
|
+
lines.push(`Uploaded the ${upload.name} asset.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lines.push(`Recorded ${tag}.`);
|
|
98
|
+
return `${lines.join('\n')}\n`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare function releaseDate(input: string | undefined): string;
|
|
2
|
+
export declare function prepareRelease(root: string, options: {
|
|
3
|
+
date: string;
|
|
4
|
+
initial: boolean;
|
|
5
|
+
since: string | undefined;
|
|
6
|
+
narrative: string | undefined;
|
|
7
|
+
}, ref?: string): {
|
|
8
|
+
fragments: {
|
|
9
|
+
added: number;
|
|
10
|
+
body: string;
|
|
11
|
+
breaking: boolean;
|
|
12
|
+
name: string;
|
|
13
|
+
}[];
|
|
14
|
+
head: string;
|
|
15
|
+
libraries: [{
|
|
16
|
+
directory: string;
|
|
17
|
+
manifest: {
|
|
18
|
+
[x: string]: unknown;
|
|
19
|
+
dependencies?: Record<string, string> | undefined;
|
|
20
|
+
devDependencies?: Record<string, string> | undefined;
|
|
21
|
+
name: string;
|
|
22
|
+
optionalDependencies?: Record<string, string> | undefined;
|
|
23
|
+
peerDependencies?: Record<string, string> | undefined;
|
|
24
|
+
private?: boolean | undefined;
|
|
25
|
+
version: string;
|
|
26
|
+
};
|
|
27
|
+
path: string;
|
|
28
|
+
source: string;
|
|
29
|
+
}, ...{
|
|
30
|
+
directory: string;
|
|
31
|
+
manifest: {
|
|
32
|
+
[x: string]: unknown;
|
|
33
|
+
dependencies?: Record<string, string> | undefined;
|
|
34
|
+
devDependencies?: Record<string, string> | undefined;
|
|
35
|
+
name: string;
|
|
36
|
+
optionalDependencies?: Record<string, string> | undefined;
|
|
37
|
+
peerDependencies?: Record<string, string> | undefined;
|
|
38
|
+
private?: boolean | undefined;
|
|
39
|
+
version: string;
|
|
40
|
+
};
|
|
41
|
+
path: string;
|
|
42
|
+
source: string;
|
|
43
|
+
}[]];
|
|
44
|
+
section: string;
|
|
45
|
+
version: string;
|
|
46
|
+
};
|
|
47
|
+
export declare function requireReleaseNotes(release: ReturnType<typeof prepareRelease>): void;
|
|
48
|
+
export declare function blankLine(text: string): "" | "\n" | "\n\n";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
import { readFragments } from './fragments.js';
|
|
3
|
+
import { requireClosedBlocks } from './markdown.js';
|
|
4
|
+
import { materialBaseline, unchangedLibraries } from './material.js';
|
|
5
|
+
import { currentVersion, git, readLibraries, requireFullHistory } from './repository.js';
|
|
6
|
+
function compareNames(left, right) {
|
|
7
|
+
if (left < right) {
|
|
8
|
+
return -1;
|
|
9
|
+
}
|
|
10
|
+
if (left > right) {
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
export function releaseDate(input) {
|
|
16
|
+
const date = input ?? new Date().toISOString().slice(0, 10);
|
|
17
|
+
if (!/^\d{4}-\d{2}-\d{2}$/u.test(date) ||
|
|
18
|
+
Number.isNaN(Date.parse(date)) ||
|
|
19
|
+
new Date(date).toISOString().slice(0, 10) !== date) {
|
|
20
|
+
throw new Error('Expected a calendar date: YYYY-MM-DD.');
|
|
21
|
+
}
|
|
22
|
+
return date;
|
|
23
|
+
}
|
|
24
|
+
export function prepareRelease(root, options, ref) {
|
|
25
|
+
requireFullHistory(root);
|
|
26
|
+
const head = ref ?? git(root, ['rev-parse', 'HEAD']).trim();
|
|
27
|
+
const fragments = readFragments(root, ref)
|
|
28
|
+
.map((fragment) => {
|
|
29
|
+
const added = git(root, [
|
|
30
|
+
'log',
|
|
31
|
+
'--first-parent',
|
|
32
|
+
'--diff-filter=A',
|
|
33
|
+
'-1',
|
|
34
|
+
'--format=%ct',
|
|
35
|
+
head,
|
|
36
|
+
'--',
|
|
37
|
+
`.changes/${fragment.name}`,
|
|
38
|
+
]).trim();
|
|
39
|
+
if (!/^\d+$/u.test(added)) {
|
|
40
|
+
throw new Error(`.changes/${fragment.name}: commit the fragment before preparing a release.`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
added: Number(added),
|
|
44
|
+
body: fragment.body,
|
|
45
|
+
breaking: fragment.breaking,
|
|
46
|
+
name: fragment.name,
|
|
47
|
+
};
|
|
48
|
+
})
|
|
49
|
+
.toSorted((left, right) => left.added - right.added || compareNames(left.name, right.name));
|
|
50
|
+
const libraries = readLibraries(root, ref);
|
|
51
|
+
const current = currentVersion(libraries);
|
|
52
|
+
if (options.initial && current.text !== '0.0.0') {
|
|
53
|
+
throw new Error('--initial requires library version 0.0.0.');
|
|
54
|
+
}
|
|
55
|
+
if (fragments.length === 0 && !options.initial) {
|
|
56
|
+
throw new Error('No fragments to release.');
|
|
57
|
+
}
|
|
58
|
+
const breaking = fragments.filter((fragment) => fragment.breaking);
|
|
59
|
+
const ordinary = fragments.filter((fragment) => !fragment.breaking);
|
|
60
|
+
let version = `0.${current.minor}.${current.patch + 1}`;
|
|
61
|
+
if (breaking.length > 0) {
|
|
62
|
+
version = `0.${current.minor + 1}.0`;
|
|
63
|
+
}
|
|
64
|
+
if (current.text === '0.0.0') {
|
|
65
|
+
version = '0.1.0';
|
|
66
|
+
}
|
|
67
|
+
let section = `## v${version} - ${options.date}\n\n`;
|
|
68
|
+
if (options.narrative !== undefined) {
|
|
69
|
+
requireClosedBlocks(options.narrative, 'Narrative');
|
|
70
|
+
if (!options.narrative.trim() ||
|
|
71
|
+
fromMarkdown(options.narrative).children.some((node) => node.type === 'heading' && node.depth <= 2)) {
|
|
72
|
+
throw new Error('Narrative must contain prose without level-one or level-two headings.');
|
|
73
|
+
}
|
|
74
|
+
section += options.narrative + blankLine(options.narrative);
|
|
75
|
+
}
|
|
76
|
+
for (const [heading, entries] of [
|
|
77
|
+
['Breaking Changes', breaking],
|
|
78
|
+
['Changes', ordinary],
|
|
79
|
+
]) {
|
|
80
|
+
if (entries.length > 0) {
|
|
81
|
+
section += `### ${heading}\n\n`;
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
section += entry.body + blankLine(entry.body);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const unchanged = current.text === '0.0.0'
|
|
88
|
+
? []
|
|
89
|
+
: unchangedLibraries(root, libraries, options.since ?? materialBaseline(root, libraries, current.text, head), ref);
|
|
90
|
+
if (unchanged.length > 0) {
|
|
91
|
+
section += `No material changes: ${unchanged.join(', ')}.\n\n`;
|
|
92
|
+
}
|
|
93
|
+
return { fragments, head, libraries, section, version };
|
|
94
|
+
}
|
|
95
|
+
// A release must carry notes, because the release plan refuses a changelog section that says nothing.
|
|
96
|
+
// Only an initial cut can reach that state: every other cut consumes at least one fragment.
|
|
97
|
+
export function requireReleaseNotes(release) {
|
|
98
|
+
const body = release.section.slice(release.section.indexOf('\n') + 1);
|
|
99
|
+
if (body.trim() === '') {
|
|
100
|
+
throw new Error(`Release v${release.version} carries no entries, so it requires a narrative for its notes.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function blankLine(text) {
|
|
104
|
+
if (text.endsWith('\n\n')) {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
if (text.endsWith('\n')) {
|
|
108
|
+
return '\n';
|
|
109
|
+
}
|
|
110
|
+
return '\n\n';
|
|
111
|
+
}
|