@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drew Butler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @loomcli/loom
|
|
2
|
+
|
|
3
|
+
The Loom CLI toolchain. Its public surface is `@loomcli/loom/build`, which exports `packet()`.
|
|
4
|
+
|
|
5
|
+
`packet()` is a `Bun.build` plugin. A Loom application keeps a `loom.packet.json` that reads `{ "build": "development" }` at its package root and passes it to its `Application` as `packet`. While Bun bundles the application, `packet()` writes `distributed` into the packet the bundle carries, so an operator sees one generic message for a defect, and the source file keeps reading `development`. The plugin also carries the data files core's Unicode tables read at run time, so a bundled application needs it to start.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add -D @loomcli/loom
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Build
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// scripts/build.ts, run as `bun scripts/build.ts`; the bundle reads { "build": "distributed" }.
|
|
17
|
+
import { packet } from '@loomcli/loom/build';
|
|
18
|
+
|
|
19
|
+
await Bun.build({ entrypoints: ['src/main.ts'], outdir: 'dist', plugins: [packet()], target: 'node' });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Build a compiled binary through the same plugin with the `compile` option of `Bun.build`. The `bun build` command line takes no plugin.
|
|
23
|
+
|
|
24
|
+
See [Development builds](https://github.com/dbtlr/loomcli/blob/main/docs/core.md#development-builds) for the packet contract.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Application } from '@loomcli/core';
|
|
2
|
+
import { changelog } from './commands/changelog/command.js';
|
|
3
|
+
import { pr } from './commands/pr/command.js';
|
|
4
|
+
import { release } from './commands/release/command.js';
|
|
5
|
+
export const loom = new Application('loom').command(changelog).command(pr).command(release);
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** What Bun passes the writer for one module it loads: the module's file path. */
|
|
2
|
+
interface PacketLoad {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
}
|
|
5
|
+
/** The part of Bun's plugin builder the writer uses: answering the load of matching modules. */
|
|
6
|
+
interface PacketBuilder {
|
|
7
|
+
onLoad(constraints: {
|
|
8
|
+
filter: RegExp;
|
|
9
|
+
}, callback: (args: PacketLoad) => Promise<{
|
|
10
|
+
contents: string;
|
|
11
|
+
loader: 'js' | 'json';
|
|
12
|
+
}>): unknown;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The plugin `packet()` returns. It is a Bun plugin by shape, so `Bun.build` accepts it wherever
|
|
16
|
+
* Bun's own plugin type is expected, and an application that type-checks its build script needs
|
|
17
|
+
* no other declarations from this package.
|
|
18
|
+
*/
|
|
19
|
+
interface PacketPlugin {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
setup(build: PacketBuilder): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The `Bun.build` plugin that makes a bundle a distributed build. While Bun bundles, it answers the
|
|
25
|
+
* import of every `loom.packet.json` with the file's members and `build` set to `distributed`, and
|
|
26
|
+
* it never writes to the source tree, so the source run keeps reading `development`. A compiled
|
|
27
|
+
* binary built through `Bun.build` with this plugin is distributed too. It also carries the data
|
|
28
|
+
* files core's Unicode tables read beside their own modules into the bundle, without which neither
|
|
29
|
+
* a bundle nor a compiled binary can start.
|
|
30
|
+
*/
|
|
31
|
+
export declare function packet(): PacketPlugin;
|
|
32
|
+
export type { PacketBuilder, PacketLoad, PacketPlugin };
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
/** Every module whose file name is the packet's, wherever the entry imports it from. */
|
|
3
|
+
const packetFile = /(?:^|[\\/])loom\.packet\.json$/u;
|
|
4
|
+
/** The generated modules of core's Unicode tables, which read their data files at run time. */
|
|
5
|
+
const unicodeTables = /[\\/]@rockorager[\\/]uucode[\\/]dist[\\/]src[\\/]generated[\\/][\w-]+\.js$/u;
|
|
6
|
+
/** One table module's read of the data file beside it. */
|
|
7
|
+
const tableRead = /JSON\.parse\(readFileSync\(new URL\("\.\/(?<file>[\w-]+\.json)", import\.meta\.url\), "utf8"\)\)/gu;
|
|
8
|
+
/**
|
|
9
|
+
* A table module with each data file it reads beside itself imported as a JSON module instead, so
|
|
10
|
+
* the bundle carries the data. A bundle, and a compiled binary most of all, holds no file beside
|
|
11
|
+
* the module that the original read could find. A table module with no read of that shape fails
|
|
12
|
+
* the build, because a bundle built from it would fail at run time instead.
|
|
13
|
+
*/
|
|
14
|
+
async function inlinedTables(path) {
|
|
15
|
+
const source = await readFile(path, 'utf8');
|
|
16
|
+
const imports = [];
|
|
17
|
+
// The one capture is the data file's name, which the callback receives after the whole match.
|
|
18
|
+
const contents = source.replaceAll(tableRead, (_read, file) => {
|
|
19
|
+
const name = `table${String(imports.length)}`;
|
|
20
|
+
imports.push(`import ${name} from './${file}' with { type: 'json' };`);
|
|
21
|
+
return name;
|
|
22
|
+
});
|
|
23
|
+
if (imports.length === 0) {
|
|
24
|
+
throw new Error(`packet() found no data file read in the Unicode table module ${path}. Update @loomcli/loom to a version that supports the installed @rockorager/uucode.`);
|
|
25
|
+
}
|
|
26
|
+
return [...imports, contents].join('\n');
|
|
27
|
+
}
|
|
28
|
+
/** Whether a parsed JSON value is a plain object, whose members the writer keeps. */
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The packet a build writes: the source file's own members with `build` set to `distributed`. A
|
|
34
|
+
* byte order mark is dropped, as the runtimes drop it when they import the file. A file that
|
|
35
|
+
* holds no JSON object is left as it is, so the Application rejects it at construction with its
|
|
36
|
+
* own diagnostic.
|
|
37
|
+
*/
|
|
38
|
+
async function distributedPacket(path) {
|
|
39
|
+
const text = await readFile(path, 'utf8');
|
|
40
|
+
const source = text.replace(/^\uFEFF/u, '');
|
|
41
|
+
const members = JSON.parse(source);
|
|
42
|
+
return isRecord(members) ? JSON.stringify({ ...members, build: 'distributed' }) : source;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The `Bun.build` plugin that makes a bundle a distributed build. While Bun bundles, it answers the
|
|
46
|
+
* import of every `loom.packet.json` with the file's members and `build` set to `distributed`, and
|
|
47
|
+
* it never writes to the source tree, so the source run keeps reading `development`. A compiled
|
|
48
|
+
* binary built through `Bun.build` with this plugin is distributed too. It also carries the data
|
|
49
|
+
* files core's Unicode tables read beside their own modules into the bundle, without which neither
|
|
50
|
+
* a bundle nor a compiled binary can start.
|
|
51
|
+
*/
|
|
52
|
+
export function packet() {
|
|
53
|
+
return {
|
|
54
|
+
name: '@loomcli/loom/build/packet',
|
|
55
|
+
setup(build) {
|
|
56
|
+
build.onLoad({ filter: packetFile }, async ({ path }) => ({
|
|
57
|
+
contents: await distributedPacket(path),
|
|
58
|
+
loader: 'json',
|
|
59
|
+
}));
|
|
60
|
+
build.onLoad({ filter: unicodeTables }, async ({ path }) => ({
|
|
61
|
+
contents: await inlinedTables(path),
|
|
62
|
+
loader: 'js',
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { Command } from '@loomcli/core';
|
|
4
|
+
import { readFragments } from '../../helpers/fragments.js';
|
|
5
|
+
import { prepareRelease, releaseDate, requireReleaseNotes } from '../../helpers/release.js';
|
|
6
|
+
import { writeRelease } from './write.js';
|
|
7
|
+
function prepare(root, options) {
|
|
8
|
+
const release = prepareRelease(root, {
|
|
9
|
+
date: releaseDate(options.date),
|
|
10
|
+
initial: options.initial,
|
|
11
|
+
narrative: options.narrative === undefined
|
|
12
|
+
? undefined
|
|
13
|
+
: readFileSync(resolve(root, options.narrative), 'utf8'),
|
|
14
|
+
since: options.since,
|
|
15
|
+
});
|
|
16
|
+
requireReleaseNotes(release);
|
|
17
|
+
return release;
|
|
18
|
+
}
|
|
19
|
+
// Convert compiler failures to Loom's fatal channel; keep generated Markdown byte-for-byte.
|
|
20
|
+
async function output(out, passthrough, produce) {
|
|
21
|
+
try {
|
|
22
|
+
if (passthrough.length > 0) {
|
|
23
|
+
out.fatal('Arguments after -- are not supported.');
|
|
24
|
+
}
|
|
25
|
+
const text = produce();
|
|
26
|
+
await out.render(text, { render: (value) => value });
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
out.fatal(error instanceof Error ? error.message : String(error));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const changelog = new Command('changelog', { hidden: true })
|
|
33
|
+
.command(new Command('check').action(async ({ host, out, passthrough }) => {
|
|
34
|
+
await output(out, passthrough, () => {
|
|
35
|
+
const fragments = readFragments(host.cwd);
|
|
36
|
+
return `Checked ${fragments.length} fragment${fragments.length === 1 ? '' : 's'}.\n`;
|
|
37
|
+
});
|
|
38
|
+
}))
|
|
39
|
+
.command(new Command('preview')
|
|
40
|
+
.option('date', { type: 'string' })
|
|
41
|
+
.option('initial', { type: 'boolean' })
|
|
42
|
+
.option('since', { type: 'string' })
|
|
43
|
+
.option('narrative', { type: 'string' })
|
|
44
|
+
.action(async ({ host, options, out, passthrough }) => {
|
|
45
|
+
await output(out, passthrough, () => prepare(host.cwd, options).section);
|
|
46
|
+
}))
|
|
47
|
+
.command(new Command('write')
|
|
48
|
+
.option('date', { type: 'string' })
|
|
49
|
+
.option('initial', { type: 'boolean' })
|
|
50
|
+
.option('since', { type: 'string' })
|
|
51
|
+
.option('narrative', { type: 'string' })
|
|
52
|
+
.action(async ({ host, options, out, passthrough }) => {
|
|
53
|
+
await output(out, passthrough, () => {
|
|
54
|
+
const release = prepare(host.cwd, options);
|
|
55
|
+
writeRelease(host.cwd, release);
|
|
56
|
+
return release.section;
|
|
57
|
+
});
|
|
58
|
+
}));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { prepareLockfile, releaseInsertion, versionedManifest, } from '../../helpers/release-files.js';
|
|
4
|
+
import { git, readRegularFile } from '../../helpers/repository.js';
|
|
5
|
+
function insertSection(changelog, section, version) {
|
|
6
|
+
const { after, before } = releaseInsertion(changelog, version);
|
|
7
|
+
return before + section + after;
|
|
8
|
+
}
|
|
9
|
+
// Stage package-manager work before touching the checkout; handled write failures restore originals.
|
|
10
|
+
function installRelease(root, release) {
|
|
11
|
+
if (git(root, ['rev-parse', '--show-prefix']).trim() !== '') {
|
|
12
|
+
throw new Error('Run write from the repository root.');
|
|
13
|
+
}
|
|
14
|
+
if (git(root, ['status', '--porcelain', '--untracked-files=all']).trim()) {
|
|
15
|
+
throw new Error('write requires a clean checkout. Commit the release inputs first.');
|
|
16
|
+
}
|
|
17
|
+
if (git(root, ['tag', '--list', `v${release.version}`]).trim()) {
|
|
18
|
+
throw new Error(`Tag v${release.version} already exists.`);
|
|
19
|
+
}
|
|
20
|
+
const original = new Map();
|
|
21
|
+
const updates = new Map();
|
|
22
|
+
original.set('CHANGELOG.md', readRegularFile(root, 'CHANGELOG.md'));
|
|
23
|
+
updates.set('CHANGELOG.md', insertSection(readRegularFile(root, 'CHANGELOG.md'), release.section, release.version));
|
|
24
|
+
for (const library of release.libraries) {
|
|
25
|
+
original.set(library.path, library.source);
|
|
26
|
+
updates.set(library.path, versionedManifest(library.source, release.version));
|
|
27
|
+
}
|
|
28
|
+
for (const fragment of release.fragments) {
|
|
29
|
+
original.set(`.changes/${fragment.name}`, fragment.body);
|
|
30
|
+
updates.set(`.changes/${fragment.name}`, undefined);
|
|
31
|
+
}
|
|
32
|
+
original.set('pnpm-lock.yaml', existsSync(join(root, 'pnpm-lock.yaml')) ? readRegularFile(root, 'pnpm-lock.yaml') : undefined);
|
|
33
|
+
const installed = [];
|
|
34
|
+
updates.set('pnpm-lock.yaml', prepareLockfile(root, release));
|
|
35
|
+
try {
|
|
36
|
+
if (git(root, ['rev-parse', 'HEAD']).trim() !== release.head ||
|
|
37
|
+
git(root, ['status', '--porcelain', '--untracked-files=all']).trim()) {
|
|
38
|
+
throw new Error('Checkout changed during release preparation.');
|
|
39
|
+
}
|
|
40
|
+
for (const [path, contents] of original) {
|
|
41
|
+
const current = existsSync(join(root, path)) ? readRegularFile(root, path) : undefined;
|
|
42
|
+
if (current !== contents) {
|
|
43
|
+
throw new Error(`${path}: changed during release preparation.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const [path, contents] of updates) {
|
|
47
|
+
installed.push(path);
|
|
48
|
+
if (contents === undefined) {
|
|
49
|
+
rmSync(join(root, path));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
writeFileSync(join(root, path), contents);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const failed = [];
|
|
58
|
+
for (const path of installed.toReversed()) {
|
|
59
|
+
try {
|
|
60
|
+
const contents = original.get(path);
|
|
61
|
+
const current = existsSync(join(root, path)) ? readRegularFile(root, path) : undefined;
|
|
62
|
+
if (current !== contents) {
|
|
63
|
+
if (contents === undefined) {
|
|
64
|
+
rmSync(join(root, path), { force: true });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
writeFileSync(join(root, path), contents);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
failed.push(path);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (failed.length > 0) {
|
|
76
|
+
throw new Error(`Release rollback incomplete for ${failed.join(', ')}. Use a fresh isolated checkout.`, { cause: error });
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function writeRelease(root, release) {
|
|
82
|
+
const lock = resolve(root, git(root, ['rev-parse', '--git-path', 'changelog-write.lock']).trim());
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(lock);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
88
|
+
throw new Error(`Release lock already exists at ${lock}. Another writer may be active. After an interrupted write, use a fresh isolated checkout.`, { cause: error });
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
installRelease(root, release);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(lock, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Command } from '@loomcli/core';
|
|
2
|
+
import { checkPullRequest } from '../../helpers/pr-check.js';
|
|
3
|
+
export const pr = new Command('pr', { hidden: true }).command(new Command('check')
|
|
4
|
+
.option('base', { required: true, type: 'string' })
|
|
5
|
+
.option('head', { default: 'HEAD', type: 'string' })
|
|
6
|
+
.option('title', { required: true, type: 'string' })
|
|
7
|
+
.option('label', { multiple: true, type: 'string' })
|
|
8
|
+
.action(async ({ host, options, out, passthrough }) => {
|
|
9
|
+
try {
|
|
10
|
+
if (passthrough.length > 0) {
|
|
11
|
+
out.fatal('Arguments after -- are not supported.');
|
|
12
|
+
}
|
|
13
|
+
checkPullRequest(host.cwd, {
|
|
14
|
+
base: options.base,
|
|
15
|
+
head: options.head,
|
|
16
|
+
labels: options.label,
|
|
17
|
+
title: options.title,
|
|
18
|
+
});
|
|
19
|
+
await out.render('PR checks passed.\n', { render: (value) => value });
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
out.fatal(error instanceof Error ? error.message : String(error));
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { Command } from '@loomcli/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { planRelease, planSchema, renderPlan } from '../../helpers/release-plan.js';
|
|
6
|
+
import { recordRelease } from '../../helpers/release-record.js';
|
|
7
|
+
const defaultRegistry = 'https://registry.npmjs.org';
|
|
8
|
+
const defaultGitHubApi = 'https://api.github.com';
|
|
9
|
+
const count = z
|
|
10
|
+
.string()
|
|
11
|
+
.regex(/^\d+$/u, 'Supply a whole number.')
|
|
12
|
+
.transform((value) => Number(value));
|
|
13
|
+
// Convert reconciliation failures to Loom's fatal channel and render the summary verbatim.
|
|
14
|
+
async function report(out, passthrough, work) {
|
|
15
|
+
try {
|
|
16
|
+
if (passthrough.length > 0) {
|
|
17
|
+
out.fatal('Arguments after -- are not supported.');
|
|
18
|
+
}
|
|
19
|
+
const summary = await work();
|
|
20
|
+
await out.render(summary, { render: (value) => value });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
out.fatal(error instanceof Error ? error.message : String(error));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const release = new Command('release', { hidden: true })
|
|
27
|
+
.command(new Command('plan')
|
|
28
|
+
.option('repository', { required: true, type: 'string' })
|
|
29
|
+
.option('output', { required: true, type: 'string' })
|
|
30
|
+
.option('registry', { default: defaultRegistry, type: 'string' })
|
|
31
|
+
.option('github-api', { default: defaultGitHubApi, type: 'string' })
|
|
32
|
+
.option('head', { default: 'HEAD', type: 'string' })
|
|
33
|
+
.option('request-timeout-ms', { default: '30000', type: 'string', validate: count })
|
|
34
|
+
.action(async ({ host, options, out, passthrough }) => {
|
|
35
|
+
await report(out, passthrough, async () => {
|
|
36
|
+
const plan = await planRelease({
|
|
37
|
+
githubApi: options['github-api'],
|
|
38
|
+
head: options.head,
|
|
39
|
+
policy: { requestTimeoutMs: options['request-timeout-ms'] },
|
|
40
|
+
registry: options.registry,
|
|
41
|
+
repository: options.repository,
|
|
42
|
+
root: host.cwd,
|
|
43
|
+
token: host.env.GH_TOKEN,
|
|
44
|
+
});
|
|
45
|
+
writeFileSync(resolve(host.cwd, options.output), `${JSON.stringify(plan, null, 2)}\n`);
|
|
46
|
+
return renderPlan(plan);
|
|
47
|
+
});
|
|
48
|
+
}))
|
|
49
|
+
.command(new Command('record')
|
|
50
|
+
.option('repository', { required: true, type: 'string' })
|
|
51
|
+
.option('plan', { required: true, type: 'string' })
|
|
52
|
+
.option('registry', { default: defaultRegistry, type: 'string' })
|
|
53
|
+
.option('github-api', { default: defaultGitHubApi, type: 'string' })
|
|
54
|
+
.option('request-timeout-ms', { default: '30000', type: 'string', validate: count })
|
|
55
|
+
.option('retry-attempts', { default: '8', type: 'string', validate: count })
|
|
56
|
+
.option('retry-delay-ms', { default: '5000', type: 'string', validate: count })
|
|
57
|
+
.action(async ({ host, options, out, passthrough }) => {
|
|
58
|
+
await report(out, passthrough, async () => {
|
|
59
|
+
const token = host.env.GH_TOKEN;
|
|
60
|
+
if (token === undefined || token === '') {
|
|
61
|
+
throw new Error('Recording a release requires GH_TOKEN.');
|
|
62
|
+
}
|
|
63
|
+
const source = JSON.parse(readFileSync(resolve(host.cwd, options.plan), 'utf8'));
|
|
64
|
+
return recordRelease({
|
|
65
|
+
githubApi: options['github-api'],
|
|
66
|
+
plan: planSchema.parse(source),
|
|
67
|
+
registry: options.registry,
|
|
68
|
+
repository: options.repository,
|
|
69
|
+
retry: {
|
|
70
|
+
attempts: options['retry-attempts'],
|
|
71
|
+
delayMs: options['retry-delay-ms'],
|
|
72
|
+
requestTimeoutMs: options['request-timeout-ms'],
|
|
73
|
+
},
|
|
74
|
+
root: host.cwd,
|
|
75
|
+
token,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}));
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
import { requireClosedBlocks } from './markdown.js';
|
|
3
|
+
import { readDirectory, readRegularFile } from './repository.js';
|
|
4
|
+
function validateBody(name, body) {
|
|
5
|
+
requireClosedBlocks(body, `.changes/${name}`);
|
|
6
|
+
const nodes = fromMarkdown(body).children;
|
|
7
|
+
const migrationIndex = nodes.findIndex((node) => node.type === 'heading');
|
|
8
|
+
const breaking = name.startsWith('breaking.');
|
|
9
|
+
const bullets = breaking && migrationIndex !== -1 ? nodes.slice(0, migrationIndex) : nodes;
|
|
10
|
+
if (bullets.length === 0 ||
|
|
11
|
+
bullets.some((node) => node.type !== 'list' ||
|
|
12
|
+
node.ordered ||
|
|
13
|
+
node.children.some((item) => item.children.length === 0))) {
|
|
14
|
+
throw new Error(`.changes/${name}: expected top-level change bullets.`);
|
|
15
|
+
}
|
|
16
|
+
if (!breaking) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const heading = nodes[migrationIndex];
|
|
20
|
+
if (heading?.type !== 'heading' ||
|
|
21
|
+
heading.depth !== 3 ||
|
|
22
|
+
heading.children.length !== 1 ||
|
|
23
|
+
heading.children[0]?.type !== 'text' ||
|
|
24
|
+
heading.children[0].value !== 'Migration') {
|
|
25
|
+
throw new Error(`.changes/${name}: expected one ### Migration section after the bullets.`);
|
|
26
|
+
}
|
|
27
|
+
const migrationNodes = nodes.slice(migrationIndex + 1);
|
|
28
|
+
if (migrationNodes.some((node) => node.type === 'heading')) {
|
|
29
|
+
throw new Error(`.changes/${name}: expected one ### Migration section.`);
|
|
30
|
+
}
|
|
31
|
+
const labels = ['Affected surface.', 'Why.', 'Before and after.', 'Steps.', 'Validation.'];
|
|
32
|
+
const found = migrationNodes.flatMap((node) => {
|
|
33
|
+
const first = node.type === 'paragraph' ? node.children[0] : undefined;
|
|
34
|
+
if (first?.type !== 'strong' ||
|
|
35
|
+
first.children.length !== 1 ||
|
|
36
|
+
first.children[0]?.type !== 'text') {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return labels.includes(first.children[0].value) ? [first.children[0].value] : [];
|
|
40
|
+
});
|
|
41
|
+
if (labels.some((label) => found.filter((item) => item === label).length !== 1)) {
|
|
42
|
+
throw new Error(`.changes/${name}: Migration requires each label once: ${labels.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function readFragments(root, ref) {
|
|
46
|
+
return readDirectory(root, '.changes', ref)
|
|
47
|
+
.filter((entry) => entry.name !== 'README.md' || !entry.isFile())
|
|
48
|
+
.map((entry) => {
|
|
49
|
+
const { name } = entry;
|
|
50
|
+
if (!entry.isFile() ||
|
|
51
|
+
!/^.+\.md$/u.test(name) ||
|
|
52
|
+
name === 'breaking.md' ||
|
|
53
|
+
name === 'breaking..md') {
|
|
54
|
+
throw new Error(`.changes/${name}: expected <slug>.md or breaking.<slug>.md.`);
|
|
55
|
+
}
|
|
56
|
+
const body = readRegularFile(root, `.changes/${name}`, ref);
|
|
57
|
+
validateBody(name, body);
|
|
58
|
+
return { body, breaking: name.startsWith('breaking.'), name };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { HttpPolicy } from './http.js';
|
|
3
|
+
declare const assetSchema: z.ZodObject<{
|
|
4
|
+
id: z.ZodNumber;
|
|
5
|
+
name: z.ZodString;
|
|
6
|
+
size: z.ZodNumber;
|
|
7
|
+
state: z.ZodString;
|
|
8
|
+
}, z.core.$loose>;
|
|
9
|
+
export interface GitHubTarget {
|
|
10
|
+
api: string;
|
|
11
|
+
policy: HttpPolicy;
|
|
12
|
+
repository: string;
|
|
13
|
+
token: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
export interface GitHubWriter extends GitHubTarget {
|
|
16
|
+
token: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function isUploadedAsset(asset: z.output<typeof assetSchema>): boolean;
|
|
19
|
+
export declare function readTagCommit(target: GitHubTarget, tag: string): Promise<string | undefined>;
|
|
20
|
+
export declare function readRelease(target: GitHubTarget, tag: string): Promise<{
|
|
21
|
+
assets: {
|
|
22
|
+
[x: string]: unknown;
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
size: number;
|
|
26
|
+
state: string;
|
|
27
|
+
}[];
|
|
28
|
+
id: number;
|
|
29
|
+
uploadUrl: string;
|
|
30
|
+
} | undefined>;
|
|
31
|
+
export declare function readAssets(target: GitHubTarget, id: number): Promise<{
|
|
32
|
+
[x: string]: unknown;
|
|
33
|
+
id: number;
|
|
34
|
+
name: string;
|
|
35
|
+
size: number;
|
|
36
|
+
state: string;
|
|
37
|
+
}[]>;
|
|
38
|
+
export declare function createTag(target: GitHubWriter, tag: string, commit: string): Promise<void>;
|
|
39
|
+
export declare function createRelease(target: GitHubWriter, tag: string, body: string): Promise<void>;
|
|
40
|
+
export declare function removeAsset(target: GitHubWriter, id: number): Promise<void>;
|
|
41
|
+
export declare function uploadAsset(target: GitHubWriter, uploadUrl: string, name: string, bytes: Buffer): Promise<void>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readJson, removeResource, writeBytes, writeJson } from './http.js';
|
|
3
|
+
// GitHub serves uploads from its own host, and a self-hosted API serves them from the API host.
|
|
4
|
+
const uploadOrigin = 'https://uploads.github.com';
|
|
5
|
+
// The largest page the GitHub API serves.
|
|
6
|
+
const pageSize = 100;
|
|
7
|
+
const objectSchema = z.looseObject({ sha: z.string().min(1), type: z.string() });
|
|
8
|
+
const referenceSchema = z.looseObject({ object: objectSchema });
|
|
9
|
+
const targetSchema = z.looseObject({ sha: z.string().min(1) });
|
|
10
|
+
const tagObjectSchema = z.looseObject({ object: targetSchema, sha: z.string().min(1) });
|
|
11
|
+
const assetSchema = z.looseObject({
|
|
12
|
+
id: z.number(),
|
|
13
|
+
name: z.string(),
|
|
14
|
+
size: z.number(),
|
|
15
|
+
state: z.string(),
|
|
16
|
+
});
|
|
17
|
+
const assetsSchema = z.array(assetSchema);
|
|
18
|
+
const releaseSchema = z
|
|
19
|
+
.looseObject({ assets: assetsSchema, id: z.number(), upload_url: z.string().min(1) })
|
|
20
|
+
.transform((release) => ({
|
|
21
|
+
assets: release.assets,
|
|
22
|
+
id: release.id,
|
|
23
|
+
uploadUrl: release.upload_url,
|
|
24
|
+
}));
|
|
25
|
+
function repositoryUrl(target, path) {
|
|
26
|
+
return `${target.api.replace(/\/+$/u, '')}/repos/${target.repository}/${path}`;
|
|
27
|
+
}
|
|
28
|
+
// An upload finishes when GitHub reports the uploaded state and the bytes it received.
|
|
29
|
+
// A Release asset in any other state is an interrupted upload that recording replaces.
|
|
30
|
+
export function isUploadedAsset(asset) {
|
|
31
|
+
return asset.state === 'uploaded' && asset.size > 0;
|
|
32
|
+
}
|
|
33
|
+
// The tag reference names an annotated tag object or the commit itself, and callers want the commit.
|
|
34
|
+
export async function readTagCommit(target, tag) {
|
|
35
|
+
const { data } = await readJson(target.policy, repositoryUrl(target, `git/ref/tags/${tag}`), target.token);
|
|
36
|
+
if (data === undefined) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const { object } = referenceSchema.parse(data);
|
|
40
|
+
if (object.type !== 'tag') {
|
|
41
|
+
return object.sha;
|
|
42
|
+
}
|
|
43
|
+
const url = repositoryUrl(target, `git/tags/${object.sha}`);
|
|
44
|
+
const dereferenced = await readJson(target.policy, url, target.token);
|
|
45
|
+
// The reference exists, so an absent tag object is a damaged record rather than an absent tag.
|
|
46
|
+
// Reporting it as an absent tag would let recording create a second tag over the first.
|
|
47
|
+
if (dereferenced.data === undefined) {
|
|
48
|
+
throw new Error(`Tag ${tag} names tag object ${object.sha}, which the API does not return.`);
|
|
49
|
+
}
|
|
50
|
+
return tagObjectSchema.parse(dereferenced.data).object.sha;
|
|
51
|
+
}
|
|
52
|
+
export async function readRelease(target, tag) {
|
|
53
|
+
const { data } = await readJson(target.policy, repositoryUrl(target, `releases/tags/${tag}`), target.token);
|
|
54
|
+
return data === undefined ? undefined : releaseSchema.parse(data);
|
|
55
|
+
}
|
|
56
|
+
// The assets list is paged, and its default page holds 30 of the 1000 assets a Release can carry.
|
|
57
|
+
// A Release with more assets than one page would hide the expected asset and provoke a needless upload.
|
|
58
|
+
export async function readAssets(target, id) {
|
|
59
|
+
const url = repositoryUrl(target, `releases/${String(id)}/assets?per_page=${String(pageSize)}`);
|
|
60
|
+
const { data } = await readJson(target.policy, url, target.token);
|
|
61
|
+
return assetsSchema.parse(data);
|
|
62
|
+
}
|
|
63
|
+
export async function createTag(target, tag, commit) {
|
|
64
|
+
const created = await writeJson(target.policy, repositoryUrl(target, 'git/tags'), target.token, {
|
|
65
|
+
message: tag,
|
|
66
|
+
object: commit,
|
|
67
|
+
tag,
|
|
68
|
+
type: 'commit',
|
|
69
|
+
});
|
|
70
|
+
const object = targetSchema.parse(created);
|
|
71
|
+
await writeJson(target.policy, repositoryUrl(target, 'git/refs'), target.token, {
|
|
72
|
+
ref: `refs/tags/${tag}`,
|
|
73
|
+
sha: object.sha,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export async function createRelease(target, tag, body) {
|
|
77
|
+
await writeJson(target.policy, repositoryUrl(target, 'releases'), target.token, {
|
|
78
|
+
body,
|
|
79
|
+
draft: false,
|
|
80
|
+
name: tag,
|
|
81
|
+
prerelease: false,
|
|
82
|
+
tag_name: tag,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export async function removeAsset(target, id) {
|
|
86
|
+
await removeResource(target.policy, repositoryUrl(target, `releases/assets/${String(id)}`), target.token);
|
|
87
|
+
}
|
|
88
|
+
// The Release publishes its upload endpoint as a URI template whose parameters this call supplies.
|
|
89
|
+
// The endpoint arrives in the Release body, so its origin is checked before the token is sent to it.
|
|
90
|
+
export async function uploadAsset(target, uploadUrl, name, bytes) {
|
|
91
|
+
const origin = new URL(uploadUrl).origin;
|
|
92
|
+
if (origin !== new URL(target.api).origin && origin !== uploadOrigin) {
|
|
93
|
+
throw new Error(`The Release publishes its upload endpoint at ${origin}, which is neither ${new URL(target.api).origin} nor ${uploadOrigin}.`);
|
|
94
|
+
}
|
|
95
|
+
const endpoint = `${uploadUrl.replace('{?name,label}', '')}?name=${encodeURIComponent(name)}`;
|
|
96
|
+
await writeBytes(target.policy, endpoint, target.token, bytes, 'application/gzip');
|
|
97
|
+
}
|