@loomcli/loom 0.7.0 → 0.8.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.
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { checkDecisions } from './release-decisions.js';
|
|
2
3
|
import { prepareLockfile, releaseInsertion } from './release-files.js';
|
|
3
4
|
import { prepareRelease, releaseDate, requireReleaseNotes } from './release.js';
|
|
4
5
|
import { currentVersion, git, readLibraries, readRegularFile } from './repository.js';
|
|
@@ -43,11 +44,13 @@ export function checkRelease(root, base, head, version, changed) {
|
|
|
43
44
|
throw new Error(`${library.path}: release cuts change only the version field.`);
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
const decisions = checkDecisions(root, base, head, { date: options.date, version }, changed);
|
|
46
48
|
const allowed = new Set([
|
|
47
49
|
'CHANGELOG.md',
|
|
48
50
|
'pnpm-lock.yaml',
|
|
49
51
|
...libraries.map((library) => library.path),
|
|
50
52
|
...release.fragments.map((fragment) => `.changes/${fragment.name}`),
|
|
53
|
+
...decisions,
|
|
51
54
|
]);
|
|
52
55
|
for (const path of changed) {
|
|
53
56
|
if (!allowed.has(path)) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admits the decision files a release cut may change: each record it marks accepted, and the index
|
|
3
|
+
* row of each one. Any other change to a record or the index throws naming the file. Returns the
|
|
4
|
+
* admitted paths.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkDecisions(root: string, base: string, head: string, release: {
|
|
7
|
+
date: string;
|
|
8
|
+
version: string;
|
|
9
|
+
}, changed: string[]): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { git, readRegularFile } from './repository.js';
|
|
2
|
+
const decisionsDirectory = 'docs/decisions/';
|
|
3
|
+
const decisionIndex = `${decisionsDirectory}README.md`;
|
|
4
|
+
const recordName = /^\d{4}-[^/]+\.md$/u;
|
|
5
|
+
/**
|
|
6
|
+
* The record a release cut writes when it marks a proposed decision accepted: the status, the
|
|
7
|
+
* modified date, and a Status section that names the release, with every other line kept.
|
|
8
|
+
* Undefined when the source is not a proposed record with one modified date and one Status section.
|
|
9
|
+
*/
|
|
10
|
+
function acceptedRecord(source, version, date) {
|
|
11
|
+
const lines = source.split('\n');
|
|
12
|
+
const close = lines.indexOf('---', 1);
|
|
13
|
+
if (lines[0] !== '---' || close === -1) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const field = (key) => lines.slice(1, close).flatMap((line, index) => (line.startsWith(`${key}:`) ? [index + 1] : []));
|
|
17
|
+
const [status, ...extraStatus] = field('status');
|
|
18
|
+
const [modified, ...extraModified] = field('modified');
|
|
19
|
+
const headings = lines.flatMap((line, index) => index > close && line === '## Status' ? [index] : []);
|
|
20
|
+
const [heading, ...extraHeadings] = headings;
|
|
21
|
+
if (status === undefined ||
|
|
22
|
+
lines[status] !== 'status: proposed' ||
|
|
23
|
+
modified === undefined ||
|
|
24
|
+
heading === undefined ||
|
|
25
|
+
extraStatus.length + extraModified.length + extraHeadings.length > 0) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const next = lines.findIndex((line, index) => index > heading && /^#{1,2} /u.test(line));
|
|
29
|
+
const end = next === -1 ? lines.length : next;
|
|
30
|
+
const fields = lines.map((line, index) => {
|
|
31
|
+
if (index === status) {
|
|
32
|
+
return 'status: accepted';
|
|
33
|
+
}
|
|
34
|
+
return index === modified ? `modified: ${date}` : line;
|
|
35
|
+
});
|
|
36
|
+
return [
|
|
37
|
+
...fields.slice(0, heading + 1),
|
|
38
|
+
'',
|
|
39
|
+
`Accepted in ${version}.`,
|
|
40
|
+
'',
|
|
41
|
+
...fields.slice(end),
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The index with each named record's status cell changed from proposed to accepted. Undefined when
|
|
46
|
+
* a named record has no single row whose first cell links it and whose last cell reads proposed.
|
|
47
|
+
*/
|
|
48
|
+
function acceptedIndex(source, names) {
|
|
49
|
+
const lines = source.split('\n');
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const rows = lines.flatMap((line, index) => /^\[[^\]]+\]\((?<target>[^)]+)\)$/u.exec(line.split('|')[1]?.trim() ?? '')?.groups?.target ===
|
|
52
|
+
name
|
|
53
|
+
? [index]
|
|
54
|
+
: []);
|
|
55
|
+
const [row, ...extraRows] = rows;
|
|
56
|
+
const text = row === undefined ? undefined : lines[row];
|
|
57
|
+
const flipped = text?.replace(/\|(?<before>\s*)proposed(?<after>\s*)\|$/u, '|$1accepted$2|');
|
|
58
|
+
if (row === undefined || extraRows.length > 0 || flipped === undefined || flipped === text) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
lines[row] = flipped;
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
function present(root, ref, path) {
|
|
66
|
+
return git(root, ['ls-tree', ref, '--', path]) !== '';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Admits the decision files a release cut may change: each record it marks accepted, and the index
|
|
70
|
+
* row of each one. Any other change to a record or the index throws naming the file. Returns the
|
|
71
|
+
* admitted paths.
|
|
72
|
+
*/
|
|
73
|
+
export function checkDecisions(root, base, head, release, changed) {
|
|
74
|
+
const records = changed.filter((path) => path.startsWith(decisionsDirectory) && recordName.test(path.slice(decisionsDirectory.length)));
|
|
75
|
+
for (const path of records) {
|
|
76
|
+
const expected = present(root, base, path) && present(root, head, path)
|
|
77
|
+
? acceptedRecord(readRegularFile(root, path, base), release.version, release.date)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (expected === undefined || readRegularFile(root, path, head) !== expected) {
|
|
80
|
+
throw new Error(`${path}: a release cut changes a decision record only to mark a proposed decision accepted, with status accepted, modified ${release.date}, and a Status section that reads "Accepted in ${release.version}.". Make any other change in an ordinary PR.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (records.length === 0 && !changed.includes(decisionIndex)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const expected = present(root, base, decisionIndex)
|
|
87
|
+
? acceptedIndex(readRegularFile(root, decisionIndex, base), records.map((path) => path.slice(decisionsDirectory.length)))
|
|
88
|
+
: undefined;
|
|
89
|
+
if (expected === undefined ||
|
|
90
|
+
!present(root, head, decisionIndex) ||
|
|
91
|
+
readRegularFile(root, decisionIndex, head) !== expected) {
|
|
92
|
+
throw new Error(`${decisionIndex}: a release cut changes the decision index only to mark each decision it accepts from proposed to accepted. Make any other change in an ordinary PR.`);
|
|
93
|
+
}
|
|
94
|
+
return [...records, decisionIndex];
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomcli/loom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The Loom CLI toolchain. Provides the packet() Bun.build plugin that marks a bundled Loom CLI application as a distributed build.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@loomcli/core": "0.
|
|
22
|
+
"@loomcli/core": "0.8.0",
|
|
23
23
|
"mdast-util-from-markdown": "^2.0.3",
|
|
24
24
|
"yaml": "^2.9.1",
|
|
25
25
|
"zod": "^4.6.5"
|