breakaway 1.3.2 → 1.4.0-main.18
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/package.json +3 -1
- package/scripts/deploy-plan.mjs +135 -0
- package/scripts/lib/deploy-plan.js +102 -0
- package/scripts/lib/package-release.js +71 -0
- package/scripts/package-release.mjs +52 -0
- package/scripts/tasks/cli.js +132 -9
- package/scripts/tasks/init.js +6 -1
- package/scripts/tasks/pipeline.js +747 -0
- package/scripts/tasks.mjs +61 -4
- package/src/cli-version.js +2 -2
- package/src/packages.js +56 -0
- package/src/repos.js +28 -9
- package/src/specs.js +96 -0
- package/template/pipeline/deploy.yml +178 -0
- package/template/pipeline/promote.yml +203 -0
- package/template/pipeline/release.yml +212 -0
- package/template/pipeline/rollback.yml +98 -0
package/scripts/tasks.mjs
CHANGED
|
@@ -54,7 +54,12 @@ import {
|
|
|
54
54
|
generalAgentRequest,
|
|
55
55
|
generalAgentSummary,
|
|
56
56
|
githubRequest,
|
|
57
|
+
specLines,
|
|
58
|
+
specListLines,
|
|
59
|
+
specRequest,
|
|
60
|
+
specsRequest,
|
|
57
61
|
ideaTask,
|
|
62
|
+
packageReleaseRequest,
|
|
58
63
|
pullAgentRequest,
|
|
59
64
|
pullAgentSummary,
|
|
60
65
|
reviewRequest,
|
|
@@ -147,6 +152,8 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
147
152
|
agents new "<prompt>" start an agent from a prompt: it makes its own task [--image <file>]… [--repo <slug>] [--force] (owner)
|
|
148
153
|
agents new --decision <ref> ["<note>"] start an agent that brings the work waiting for an answered decision in line with its answers; the board writes its prompt [--force] (owner)
|
|
149
154
|
agents new --next minor|major ["<note>"] start an agent that sets package.json to the next minor or major release; the board writes its prompt [--repo <slug>] [--force] (owner)
|
|
155
|
+
agents new --spec <path> "<what should change>" start an agent that changes a spec as you ask and brings the tasks that
|
|
156
|
+
link it in line; the board writes its prompt [--repo <slug>] [--force] (owner)
|
|
150
157
|
agents start <ref> start a Claude cloud agent on a task [--note <text>] [--force]
|
|
151
158
|
agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change> [--force]
|
|
152
159
|
agents plan [<plan>] your Claude plan and what it allows; pro, max5, or max20 picks one (owner) and sets the limits to its defaults
|
|
@@ -165,6 +172,10 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
165
172
|
github review <n> start an agent that reviews a pull request that can merge as it stands, on the task it closes, as
|
|
166
173
|
Review with an agent does; on a Dependabot one it tests the update, as Safe to merge? does
|
|
167
174
|
(owner) [--note <text>] [--repo <slug>] [--force]
|
|
175
|
+
github release <pre-release> release a package's pre-release (1.4.0-main.5) as its stable on latest, as Release on the
|
|
176
|
+
GitHub page does: the board starts release.yml's stable job, and npm waits for your 2FA (owner) [--repo <slug>]
|
|
177
|
+
specs the repository's specs, newest first: each one's status and its tasks [--repo <slug>]
|
|
178
|
+
specs show <path> one spec: its status, last change, Markdown, and the tasks that link it [--repo <slug>]
|
|
168
179
|
github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
|
|
169
180
|
hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
|
|
170
181
|
health the server's state
|
|
@@ -236,7 +247,9 @@ Working
|
|
|
236
247
|
repos modify <slug> change one (owner): --area <project:PREFIX> adds an area, --remove-area <project> drops one with no tasks, --name, --branch, --github,
|
|
237
248
|
--agents-max <n|none> and --agents-hourly <n|none> cap its agents under the board's shared limits,
|
|
238
249
|
--prompt <path|none> says where its agent prompt is in its checkout (default tools/tasks/routine-prompt.md)
|
|
239
|
-
--
|
|
250
|
+
--specs <dir|none> says where its specs are (default docs/specs)
|
|
251
|
+
--pipeline <file.json|none> sets its deploy pipeline ({"workers": {"staging", "production"}, "package": "<npm name>", "workflows": {...}, "deployPaths"},
|
|
252
|
+
with workers, package, or both) or clears it
|
|
240
253
|
features add <slug> new feature: its tasks join by carrying <slug> as a tag [--title <text>]
|
|
241
254
|
[--brief <text> | --brief-file <path>] [--release <x.y.z>] (agents add one without a release)
|
|
242
255
|
--from <ref> (owner): made from the group <ref> is in on the Dependencies view: its open tasks
|
|
@@ -268,6 +281,12 @@ Install repository (no board needed: the files and steps that deploy a board
|
|
|
268
281
|
[--channel stable|main] [--version <release>]
|
|
269
282
|
install resolve|check|config|previous|healthy|update the steps those workflows run (docs in the install's README)
|
|
270
283
|
|
|
284
|
+
Deploy and release flows (no board needed: run in the checkout of the repository that deploys or publishes)
|
|
285
|
+
pipeline init render .github/breakaway-pipeline.json into Deploy, Promote, and Roll back (for its workers),
|
|
286
|
+
.github/deploy-paths.json, and Release (for its npm package); without the config it prints an
|
|
287
|
+
example. Never overwrites a file [--update] replaces what it rendered before [--dry-run]
|
|
288
|
+
pipeline check say whether the config is sound and the workflows are what it renders now (exits 1 if not)
|
|
289
|
+
|
|
271
290
|
Repositories
|
|
272
291
|
The checkout's repository is the one its origin remote names (git remote get-url origin), matched
|
|
273
292
|
against repos. --repo <slug> or BREAKAWAY_REPO=<slug> picks another, --all shows every repository
|
|
@@ -792,11 +811,13 @@ const commands = {
|
|
|
792
811
|
if (sub === 'new') {
|
|
793
812
|
const decision = typeof opts.decision === 'string' ? opts.decision : null;
|
|
794
813
|
const next = typeof opts.next === 'string' ? opts.next : null;
|
|
814
|
+
const spec = typeof opts.spec === 'string' ? opts.spec : null;
|
|
795
815
|
const built = generalAgentRequest(args.slice(1).join(' '), {
|
|
796
816
|
// From a decision, the board runs it in the decision's repository unless --repo says otherwise.
|
|
797
817
|
repo: opts.repo ?? (decision ? null : (await checkoutRepo()).slug),
|
|
798
818
|
decision,
|
|
799
819
|
next,
|
|
820
|
+
spec,
|
|
800
821
|
force: Boolean(opts.force),
|
|
801
822
|
by: opts.as ?? setting('AGENT'),
|
|
802
823
|
});
|
|
@@ -809,7 +830,7 @@ const commands = {
|
|
|
809
830
|
const image = await upload(ref(answer.task), file);
|
|
810
831
|
if (!opts.json) console.log(`Attached ${image.name} (${Math.ceil(image.size / 1024)} KB).`);
|
|
811
832
|
}
|
|
812
|
-
print(answer, (d) => generalAgentSummary(d, { next }));
|
|
833
|
+
print(answer, (d) => generalAgentSummary(d, { next, spec }));
|
|
813
834
|
return;
|
|
814
835
|
}
|
|
815
836
|
if (sub === 'start') {
|
|
@@ -935,9 +956,14 @@ const commands = {
|
|
|
935
956
|
if (sub === 'modify') {
|
|
936
957
|
const slug = need(args[1], 'repository');
|
|
937
958
|
const change = fields();
|
|
959
|
+
const routineFlags =
|
|
960
|
+
opts['agents-max'] !== undefined || opts['agents-hourly'] !== undefined || opts.prompt !== undefined;
|
|
961
|
+
const current =
|
|
962
|
+
routineFlags || opts.specs !== undefined
|
|
963
|
+
? (await call('GET', 'repos')).repos.find((r) => r.slug === slug.toLowerCase())
|
|
964
|
+
: null;
|
|
938
965
|
// Its caps on agents, under the board's shared limits, and where its agent prompt is (CLD-127): kept with the rest of its routine settings.
|
|
939
|
-
if (
|
|
940
|
-
const current = (await call('GET', 'repos')).repos.find((r) => r.slug === slug.toLowerCase());
|
|
966
|
+
if (routineFlags) {
|
|
941
967
|
const routine = { ...current?.routine };
|
|
942
968
|
for (const [flag, key] of [
|
|
943
969
|
['agents-max', 'max'],
|
|
@@ -948,6 +974,9 @@ const commands = {
|
|
|
948
974
|
if (opts.prompt !== undefined) routine.prompt = opts.prompt === 'none' ? null : String(opts.prompt);
|
|
949
975
|
change.routine = routine;
|
|
950
976
|
}
|
|
977
|
+
// Where its specs are (IDEA-31), kept with the rest of its settings; none goes back to docs/specs.
|
|
978
|
+
if (opts.specs !== undefined)
|
|
979
|
+
change.settings = { ...current?.settings, specs: opts.specs === 'none' ? null : String(opts.specs) };
|
|
951
980
|
// Its deploy pipeline (BRK-44): a JSON file, or none to clear it; the board refuses one it couldn't use, with the reason.
|
|
952
981
|
if (opts.pipeline !== undefined) {
|
|
953
982
|
if (opts.pipeline === 'none') change.pipeline = null;
|
|
@@ -1080,6 +1109,17 @@ const commands = {
|
|
|
1080
1109
|
].join('\n'),
|
|
1081
1110
|
);
|
|
1082
1111
|
},
|
|
1112
|
+
async specs() {
|
|
1113
|
+
// A repository's specs (IDEA-31), read from its default branch on GitHub: the checkout's unless --repo names another.
|
|
1114
|
+
const { slug } = await checkoutRepo();
|
|
1115
|
+
if (args[0] === 'show') {
|
|
1116
|
+
const built = specRequest(args.slice(1).join(' ') || undefined, slug);
|
|
1117
|
+
if (built.error || !built.request) fail(built.error ?? 'bad request');
|
|
1118
|
+
print(await call(...built.request), (d) => specLines(d).join('\n'));
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
print(await call(...specsRequest(slug)), (d) => specListLines(d).join('\n'));
|
|
1122
|
+
},
|
|
1083
1123
|
async features() {
|
|
1084
1124
|
const sub = args[0];
|
|
1085
1125
|
const body = () =>
|
|
@@ -1381,6 +1421,20 @@ const commands = {
|
|
|
1381
1421
|
print(answer, (a) => pullAgentSummary(action, args[1].replace(/^#/u, ''), a));
|
|
1382
1422
|
return;
|
|
1383
1423
|
}
|
|
1424
|
+
if (action === 'release') {
|
|
1425
|
+
const built = packageReleaseRequest(args[1], {
|
|
1426
|
+
repo: (await checkoutRepo()).slug,
|
|
1427
|
+
by: opts.as ?? setting('AGENT'),
|
|
1428
|
+
});
|
|
1429
|
+
if (built.error || !built.request) fail(built.error ?? 'bad request');
|
|
1430
|
+
const answer = await call(...built.request);
|
|
1431
|
+
print(
|
|
1432
|
+
answer,
|
|
1433
|
+
() =>
|
|
1434
|
+
`Started ${answer.workflow}'s stable job for ${args[1]}. It stages the stable on npm's latest, where it waits for your approval with 2FA.`,
|
|
1435
|
+
);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1384
1438
|
const g = await call(...githubRequest((await checkoutRepo()).slug, { sync: Boolean(opts.sync) }));
|
|
1385
1439
|
print(g, (d) => {
|
|
1386
1440
|
if (!d.connected) return "GitHub isn't connected yet: open the GitHub view on the board (docs/tasks.md#github).";
|
|
@@ -2122,6 +2176,9 @@ if (opts.help || command === 'help') {
|
|
|
2122
2176
|
} else if (command === 'install') {
|
|
2123
2177
|
// An install repository's own steps (BRK-9): they need no board, so they run before the board's address is checked.
|
|
2124
2178
|
await (await import('./install/cli.js')).run(args, opts);
|
|
2179
|
+
} else if (command === 'pipeline') {
|
|
2180
|
+
// A repository's deploy and release workflows (BRK-90): rendered from its own config, so they need no board either.
|
|
2181
|
+
process.exitCode = (await import('./tasks/pipeline.js')).run(args, opts);
|
|
2125
2182
|
} else if (!commands[command]) {
|
|
2126
2183
|
fail(`no command "${command}". npx breakaway help lists them.`);
|
|
2127
2184
|
} else if (!BASE && command !== 'init-secrets') {
|
package/src/cli-version.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
|
|
5
5
|
* so it lives in the board's package (CLD-135) and the CLI imports it from here.
|
|
6
6
|
*/
|
|
7
|
-
export const CLI_VERSION =
|
|
8
|
-
export const CLI_FINGERPRINT = '
|
|
7
|
+
export const CLI_VERSION = 68;
|
|
8
|
+
export const CLI_FINGERPRINT = 'd62907097b579845';
|
package/src/packages.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Packages feed's pure half (BRK-101, docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section 2b): what a
|
|
3
|
+
* workflow run's annotations say it staged on npm, and what npm's public registry answers about it. Read-only: the
|
|
4
|
+
* board never publishes or approves a package; approving a staged version needs the owner's 2FA on npm.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** npm's public registry. The npmjs.com pages answer 403 to anything but a browser, so the board reads this. */
|
|
8
|
+
export const REGISTRY = 'https://registry.npmjs.org';
|
|
9
|
+
|
|
10
|
+
/** How staging works and how a person approves a staged version (with 2FA): npm's own docs. */
|
|
11
|
+
export const STAGING_DOCS = 'https://docs.npmjs.com/staged-publishing';
|
|
12
|
+
|
|
13
|
+
/** The package's page on npm; its Staged Packages tab is where the owner approves a staged version. */
|
|
14
|
+
export const packageUrl = (name, version = null) =>
|
|
15
|
+
`https://www.npmjs.com/package/${name}${version ? `/v/${version}` : ''}`;
|
|
16
|
+
|
|
17
|
+
const NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/u;
|
|
18
|
+
/** Whether `name` is a name npm would take for a package, scoped or not. */
|
|
19
|
+
export const isPackageName = (name) => typeof name === 'string' && name.length <= 214 && NAME.test(name);
|
|
20
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
21
|
+
const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The line the release flow prints when it stages a version (`::notice title=Staged on npm::…`), word for word:
|
|
25
|
+
* `<package>@<version> goes live on <dist-tag> once the owner approves it with 2FA: …`. The package may be scoped.
|
|
26
|
+
*/
|
|
27
|
+
const STAGED = /(?<=^|\s)((?:@[^\s@/]+\/)?[^\s@/]+)@(\S+) goes live on (\S+) once the owner approves it\b/gu;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The versions an annotation's message says were staged: `{ name, version, tag }` each, only names, versions, and
|
|
31
|
+
* dist-tags npm would accept, so nothing else from a run reaches a URL or the board.
|
|
32
|
+
*/
|
|
33
|
+
export function stagedIn(message) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const m of String(message ?? '').matchAll(STAGED)) {
|
|
36
|
+
const [, name, version, tag] = m;
|
|
37
|
+
if (isPackageName(name) && VERSION.test(version) && TAG.test(tag)) out.push({ name, version, tag });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A package name as the registry's path has it: a scoped one's slash is escaped. */
|
|
43
|
+
export const registryName = (name) => name.replace('/', '%2f');
|
|
44
|
+
|
|
45
|
+
/** The registry's URL for a package's metadata, or for one version of it. */
|
|
46
|
+
export const registryUrl = (name, version = null, base = REGISTRY) =>
|
|
47
|
+
`${base.replace(/\/$/u, '')}/${registryName(name)}${version ? `/${encodeURIComponent(version)}` : ''}`;
|
|
48
|
+
|
|
49
|
+
/** A package's dist-tags from its metadata, only the ones that name a version. */
|
|
50
|
+
export function distTags(metadata) {
|
|
51
|
+
const tags = metadata?.['dist-tags'];
|
|
52
|
+
if (!tags || typeof tags !== 'object') return {};
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
Object.entries(tags).filter(([tag, version]) => TAG.test(tag) && VERSION.test(String(version))),
|
|
55
|
+
);
|
|
56
|
+
}
|
package/src/repos.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* repository, and a prefix to exactly one area, so a work ID means one task across the install.
|
|
11
11
|
*/
|
|
12
12
|
import { AREA_NAMES, InputError, PROJECTS } from './model.js';
|
|
13
|
+
import { isPackageName } from './packages.js';
|
|
14
|
+
import { checkSettings } from './specs.js';
|
|
13
15
|
|
|
14
16
|
/** The slug a task without `repo` falls back to while the registry has no default: nothing is registered, so no task is. */
|
|
15
17
|
export const NO_REPO = 'default';
|
|
@@ -114,26 +116,42 @@ function jsonField(value, what) {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
const WORKER_NAME = /^[\w.-]{1,100}$/u;
|
|
119
|
+
/** The fields a repository's pipeline has, as `repos modify --pipeline` and Turn on deploys (WEB-13) set it. */
|
|
120
|
+
export const PIPELINE_KEYS = ['workers', 'workflows', 'deployPaths', 'package'];
|
|
117
121
|
const DEPLOY_PATHS_PATH = /^(?!\/)(?!.*\.\.)[\w./-]{1,200}\.json$/u;
|
|
118
122
|
|
|
119
123
|
/**
|
|
120
|
-
* A repository's deploy pipeline as the owner sets it: the shape `pipelineOf` (release.js)
|
|
121
|
-
* the reason when
|
|
122
|
-
*
|
|
124
|
+
* A repository's deploy pipeline as the owner sets it: the shape `pipelineOf` and `packageOf` (release.js) read,
|
|
125
|
+
* refused with the reason when they would read none. It needs `workers` (`staging` and `production`, the Workers it
|
|
126
|
+
* deploys), `package` (the npm package it releases, BRK-103), or both. `workflows` (deploy, promote, rollback, and
|
|
127
|
+
* release, workflow file names) and `deployPaths` (a JSON file in the repository) are optional.
|
|
123
128
|
*/
|
|
124
|
-
function checkPipeline(pipeline) {
|
|
129
|
+
export function checkPipeline(pipeline) {
|
|
125
130
|
if (!pipeline) return null;
|
|
126
|
-
const known = { workers: ['staging', 'production'], workflows: ['deploy', 'promote', 'rollback'] };
|
|
131
|
+
const known = { workers: ['staging', 'production'], workflows: ['deploy', 'promote', 'rollback', 'release'] };
|
|
127
132
|
for (const key of Object.keys(pipeline)) {
|
|
128
|
-
if (!
|
|
129
|
-
throw new InputError(
|
|
133
|
+
if (!PIPELINE_KEYS.includes(key))
|
|
134
|
+
throw new InputError(
|
|
135
|
+
`pipeline has no "${key.slice(0, 40)}"; it has workers, package, workflows, and deployPaths`,
|
|
136
|
+
);
|
|
130
137
|
}
|
|
131
138
|
const out = {};
|
|
139
|
+
const hasPackage = pipeline.package !== undefined && pipeline.package !== null && pipeline.package !== '';
|
|
140
|
+
if (hasPackage) {
|
|
141
|
+
const name = typeof pipeline.package === 'string' ? pipeline.package.trim() : null;
|
|
142
|
+
if (!isPackageName(name))
|
|
143
|
+
throw new InputError(
|
|
144
|
+
'pipeline.package is the npm package’s name, as its package.json says, like widgets or @acme/widgets',
|
|
145
|
+
);
|
|
146
|
+
out.package = name;
|
|
147
|
+
}
|
|
132
148
|
for (const [group, keys] of Object.entries(known)) {
|
|
133
149
|
const value = pipeline[group];
|
|
134
150
|
if (value === undefined || value === null) {
|
|
135
|
-
if (group === 'workers')
|
|
136
|
-
throw new InputError(
|
|
151
|
+
if (group === 'workers' && !hasPackage)
|
|
152
|
+
throw new InputError(
|
|
153
|
+
'pipeline needs workers (staging and production, the names of the two Workers it deploys), package (the npm package it releases), or both',
|
|
154
|
+
);
|
|
137
155
|
continue;
|
|
138
156
|
}
|
|
139
157
|
if (typeof value !== 'object' || Array.isArray(value)) throw new InputError(`pipeline.${group} is an object`);
|
|
@@ -292,6 +310,7 @@ export function checkRepo(
|
|
|
292
310
|
for (const key of JSON_FIELDS) if (key in input) row[key] = jsonField(input[key], key);
|
|
293
311
|
if ('pipeline' in input) row.pipeline = checkPipeline(row.pipeline);
|
|
294
312
|
if ('routine' in input) row.routine = checkRoutine(row.routine, caps);
|
|
313
|
+
if ('settings' in input) row.settings = checkSettings(row.settings);
|
|
295
314
|
|
|
296
315
|
const adding = [...list(input.areas), ...list(input.addAreas)].map(parseArea);
|
|
297
316
|
for (const project of list(input.removeAreas).map((p) => String(p).trim().toLowerCase())) {
|
package/src/specs.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A repository's specs (docs/specs/IDEA-31-specs-view.md, sections 1 and 2): where they are, and what the board
|
|
3
|
+
* reads from each file. Pure, so it's tested without the Durable Object; reading them through GitHub is in
|
|
4
|
+
* store-specs.js. The board never stores a spec: it reads the files on the default branch when asked.
|
|
5
|
+
*/
|
|
6
|
+
import { InputError } from './model.js';
|
|
7
|
+
|
|
8
|
+
/** Where a repository keeps its specs when its settings don't say: where breakaway and the template put them. */
|
|
9
|
+
export const DEFAULT_SPECS_DIR = 'docs/specs';
|
|
10
|
+
/** The largest spec the board reads; a bigger one is a link to GitHub. */
|
|
11
|
+
export const SPEC_MAX_BYTES = 1_048_576;
|
|
12
|
+
const SPECS_DIR = /^(?!\/)(?!.*\.\.)[\w./-]{1,200}$/u;
|
|
13
|
+
const SPEC_NAME = /^[\w.-]{1,200}\.md$/u;
|
|
14
|
+
const WID = /^([A-Z]{2,8}-\d+)(?:[-.]|$)/u;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A repository's specs directory as the owner sets it (`settings.specs`): a relative directory, with no `..`,
|
|
18
|
+
* of at most 200 characters, checked like `routine.prompt`. Empty or null means the default, so it's left out.
|
|
19
|
+
* @param {unknown} value
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function checkSpecsDir(value) {
|
|
23
|
+
if (value === null || value === undefined || value === '') return null;
|
|
24
|
+
const dir = String(value).trim().replace(/^\.\//u, '').replace(/\/+$/u, '');
|
|
25
|
+
if (!SPECS_DIR.test(dir) || dir.split('/').some((part) => part === '' || part === '.'))
|
|
26
|
+
throw new InputError('settings.specs is a directory in the repository, like docs/specs');
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A repository's `settings`, checked: its specs directory (`specs`) when it has one; the rest is kept as it is.
|
|
32
|
+
* @param {Record<string, any> | null} settings
|
|
33
|
+
*/
|
|
34
|
+
export function checkSettings(settings) {
|
|
35
|
+
if (!settings) return null;
|
|
36
|
+
const out = { ...settings };
|
|
37
|
+
const specs = checkSpecsDir(out.specs);
|
|
38
|
+
if (specs) out.specs = specs;
|
|
39
|
+
else delete out.specs;
|
|
40
|
+
return Object.keys(out).length ? out : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The directory, in the repository, its specs are in. */
|
|
44
|
+
export const specsDirOf = (repo) => repo?.settings?.specs || DEFAULT_SPECS_DIR;
|
|
45
|
+
|
|
46
|
+
/** A path as a task's `spec` field or a request may give it: no leading `./` or `/`. */
|
|
47
|
+
export const normalPath = (path) =>
|
|
48
|
+
String(path ?? '')
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^(?:\.\/|\/)+/u, '');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether `path` is a file the board reads as one of the repository's specs: a Markdown file directly in `dir`
|
|
54
|
+
* (its README.md too). Subdirectories, other files, and anything outside it are refused.
|
|
55
|
+
* @param {string} dir
|
|
56
|
+
* @param {string} path
|
|
57
|
+
*/
|
|
58
|
+
export function inSpecsDir(dir, path) {
|
|
59
|
+
const p = normalPath(path);
|
|
60
|
+
if (!p.startsWith(`${dir}/`)) return false;
|
|
61
|
+
const name = p.slice(dir.length + 1);
|
|
62
|
+
return SPEC_NAME.test(name) && !name.startsWith('.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a file in the directory is a spec, not its introduction. */
|
|
66
|
+
export const isSpecFile = (name) => SPEC_NAME.test(name) && !name.startsWith('.') && name.toLowerCase() !== 'readme.md';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the list shows for one spec: its title (the first `# ` heading, else the file name), its status (the
|
|
70
|
+
* first word after `Status:` on the line under the heading, as the template has it, else null), and the work ID
|
|
71
|
+
* its file name starts with, if any. `text` is null for a file the board didn't read (too large).
|
|
72
|
+
* @param {string} name the file name
|
|
73
|
+
* @param {string | null} text
|
|
74
|
+
*/
|
|
75
|
+
export function specMeta(name, text) {
|
|
76
|
+
const wid = WID.exec(name)?.[1] ?? null;
|
|
77
|
+
const fallback = name.replace(/\.md$/u, '');
|
|
78
|
+
if (text === null || text === undefined) return { wid, title: fallback, status: null };
|
|
79
|
+
const lines = String(text).split(/\r?\n/u);
|
|
80
|
+
const at = lines.findIndex((l) => /^#\s+\S/u.test(l));
|
|
81
|
+
if (at < 0) return { wid, title: fallback, status: null };
|
|
82
|
+
const title =
|
|
83
|
+
lines[at]
|
|
84
|
+
.replace(/^#\s+/u, '')
|
|
85
|
+
.replace(/\s+#+\s*$/u, '')
|
|
86
|
+
.trim() || fallback;
|
|
87
|
+
const under = lines.slice(at + 1).find((l) => l.trim() !== '') ?? '';
|
|
88
|
+
const status = /\bStatus:\s*\**\s*([A-Za-z][\w-]*)/u.exec(under)?.[1]?.toLowerCase() ?? null;
|
|
89
|
+
return { wid, title, status };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Newest first by the work ID's number, then by path; specs without a work ID last. */
|
|
93
|
+
export function bySpecOrder(a, b) {
|
|
94
|
+
const n = (s) => (s.wid ? Number(s.wid.split('-')[1]) : -1);
|
|
95
|
+
return n(b) - n(a) || a.path.localeCompare(b.path);
|
|
96
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# {{header}}
|
|
2
|
+
#
|
|
3
|
+
# Deploy: a merge to {{branchName}} that passes its checks goes to staging, the Worker {{stagingName}}. Production
|
|
4
|
+
# changes only when the owner promotes a staging build (promote.yml). The build is kept as the workflow artifact
|
|
5
|
+
# release-<commit>, and Promote deploys those same files to production. Deploys record GitHub Deployments, which the
|
|
6
|
+
# board reads. Agents never run this workflow.
|
|
7
|
+
name: Deploy
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
# After every check workflow has passed on a push to {{branchName}}: each one's completion starts this, and only the
|
|
11
|
+
# run that finds them all passed deploys.
|
|
12
|
+
workflow_run:
|
|
13
|
+
workflows: {{checks}}
|
|
14
|
+
types: [completed]
|
|
15
|
+
branches: [{{branch}}]
|
|
16
|
+
# By hand: deploy {{branchName}}'s latest commit to staging, once its checks have passed.
|
|
17
|
+
workflow_dispatch:
|
|
18
|
+
|
|
19
|
+
permissions: {}
|
|
20
|
+
|
|
21
|
+
# One staging deploy at a time. One that waits finds staging already runs its commit, or a newer one, and stops.
|
|
22
|
+
concurrency:
|
|
23
|
+
group: deploy-staging
|
|
24
|
+
cancel-in-progress: false
|
|
25
|
+
|
|
26
|
+
env:
|
|
27
|
+
STAGING: {{staging}}
|
|
28
|
+
BRANCH: {{branch}}
|
|
29
|
+
CHECKS: {{checksEnv}}
|
|
30
|
+
HEALTH_URL: {{healthStaging}}
|
|
31
|
+
|
|
32
|
+
jobs:
|
|
33
|
+
staging:
|
|
34
|
+
name: staging
|
|
35
|
+
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository)
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
timeout-minutes: 30
|
|
38
|
+
# CLOUDFLARE_API_TOKEN is a secret of this environment, which only {{branchName}} may use: a token for the staging
|
|
39
|
+
# Worker only. CLOUDFLARE_ACCOUNT_ID is a repository variable.
|
|
40
|
+
environment: staging
|
|
41
|
+
permissions:
|
|
42
|
+
contents: read
|
|
43
|
+
deployments: write
|
|
44
|
+
actions: read
|
|
45
|
+
env:
|
|
46
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
47
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
48
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
49
|
+
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
50
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
51
|
+
# What the repository's own commands (beforeDeploy) read to know where they run.
|
|
52
|
+
BREAKAWAY_ENV: staging
|
|
53
|
+
WORKER: {{staging}}
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
56
|
+
with:
|
|
57
|
+
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
|
58
|
+
fetch-depth: 0
|
|
59
|
+
persist-credentials: false
|
|
60
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
61
|
+
with:
|
|
62
|
+
node-version: 22
|
|
63
|
+
|
|
64
|
+
- name: Check it should deploy
|
|
65
|
+
id: plan
|
|
66
|
+
run: node scripts/deploy-plan.mjs plan --staging "$STAGING" --sha "$SHA" --branch "$BRANCH" --checks "$CHECKS" --paths .github/deploy-paths.json >> "$GITHUB_OUTPUT"
|
|
67
|
+
|
|
68
|
+
- name: Record the start
|
|
69
|
+
id: record
|
|
70
|
+
if: steps.plan.outputs.deploy == 'true'
|
|
71
|
+
run: node scripts/record-deployment.mjs --environment "$STAGING" --sha "$SHA" --state in_progress --description "pre-release · building" --log-url "$RUN_URL"
|
|
72
|
+
|
|
73
|
+
- name: Install and build
|
|
74
|
+
if: steps.plan.outputs.deploy == 'true'
|
|
75
|
+
run: |
|
|
76
|
+
set -euo pipefail
|
|
77
|
+
if grep -q '"packageManager"' package.json 2>/dev/null; then corepack enable; fi
|
|
78
|
+
{{@install}}
|
|
79
|
+
{{@build}}
|
|
80
|
+
|
|
81
|
+
- name: Keep the build as the release artifact
|
|
82
|
+
id: artifact
|
|
83
|
+
if: steps.plan.outputs.deploy == 'true'
|
|
84
|
+
run: |
|
|
85
|
+
set -euo pipefail
|
|
86
|
+
# The commit as installed and built: Promote deploys these files to production, and builds nothing.
|
|
87
|
+
mkdir -p "$RUNNER_TEMP/release"
|
|
88
|
+
tar --exclude=./.git -czf "$RUNNER_TEMP/release/tree.tgz" .
|
|
89
|
+
echo "digest=$(node scripts/release-artifact.mjs digest "$RUNNER_TEMP/release")" >> "$GITHUB_OUTPUT"
|
|
90
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
91
|
+
if: steps.plan.outputs.deploy == 'true'
|
|
92
|
+
with:
|
|
93
|
+
name: release-${{ github.event.workflow_run.head_sha || github.sha }}
|
|
94
|
+
path: ${{ runner.temp }}/release
|
|
95
|
+
retention-days: 90
|
|
96
|
+
compression-level: 0
|
|
97
|
+
if-no-files-found: error
|
|
98
|
+
|
|
99
|
+
- name: Deploy to staging
|
|
100
|
+
id: deploy
|
|
101
|
+
if: steps.plan.outputs.deploy == 'true'
|
|
102
|
+
env:
|
|
103
|
+
FROM: ${{ steps.plan.outputs.from }}
|
|
104
|
+
DEPLOYMENT: ${{ steps.record.outputs.deployment }}
|
|
105
|
+
run: |
|
|
106
|
+
set -euo pipefail
|
|
107
|
+
wrangler() { npx --yes wrangler "$@" --name "$WORKER"{{stagingEnvFlag}}; }
|
|
108
|
+
step() { node scripts/record-deployment.mjs --deployment "$DEPLOYMENT" --state in_progress --description "pre-release · $1" > /dev/null; }
|
|
109
|
+
# The version running now, which a failed check goes back to. Only a Worker that doesn't exist yet has none:
|
|
110
|
+
# any other failure to list stops here, since deploying with nothing to go back to isn't safe.
|
|
111
|
+
previous=""
|
|
112
|
+
if wrangler deployments list --json > "$RUNNER_TEMP/deployments.json" 2> "$RUNNER_TEMP/list-error.txt"; then
|
|
113
|
+
previous=$(node scripts/deploy-plan.mjs current < "$RUNNER_TEMP/deployments.json" | sed 's/^version=//')
|
|
114
|
+
else
|
|
115
|
+
cat "$RUNNER_TEMP/deployments.json" "$RUNNER_TEMP/list-error.txt" | node scripts/deploy-plan.mjs missing > /dev/null
|
|
116
|
+
fi
|
|
117
|
+
# The migrations this deploy runs: the ones added since staging's last deploy.
|
|
118
|
+
migrations=""
|
|
119
|
+
if [ -d migrations ]; then
|
|
120
|
+
node scripts/check-migrations.mjs --dir migrations ${FROM:+--base "$FROM"}
|
|
121
|
+
if [ -n "$FROM" ]; then
|
|
122
|
+
migrations=$(git diff --name-only --diff-filter=A "$FROM" "$SHA" -- migrations | grep '\.sql$' | xargs -r -n1 basename | paste -sd, - || true)
|
|
123
|
+
fi
|
|
124
|
+
fi
|
|
125
|
+
echo "migrations=$migrations" >> "$GITHUB_OUTPUT"
|
|
126
|
+
step migrating
|
|
127
|
+
{{@beforeDeploy}}
|
|
128
|
+
step deploying
|
|
129
|
+
export WRANGLER_OUTPUT_FILE_PATH="$RUNNER_TEMP/wrangler-output.ndjson"
|
|
130
|
+
if [ -z "$previous" ]; then
|
|
131
|
+
# The first deploy makes the Worker.
|
|
132
|
+
wrangler deploy --message "${SHA:0:7}"
|
|
133
|
+
version=$(node scripts/deploy-plan.mjs uploaded < "$WRANGLER_OUTPUT_FILE_PATH" | sed 's/^version=//')
|
|
134
|
+
else
|
|
135
|
+
wrangler versions upload --message "${SHA:0:7}"
|
|
136
|
+
version=$(node scripts/deploy-plan.mjs uploaded < "$WRANGLER_OUTPUT_FILE_PATH" | sed 's/^version=//')
|
|
137
|
+
wrangler versions deploy "$version@100%" --message "${SHA:0:7}" --yes
|
|
138
|
+
fi
|
|
139
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
140
|
+
step checking
|
|
141
|
+
if [ -z "$HEALTH_URL" ]; then
|
|
142
|
+
echo "::notice title=Not checked::Staging runs ${SHA:0:7} as version $version. Set healthCheck in .github/breakaway-pipeline.json and the next deploy checks it, and goes back if it doesn't answer."
|
|
143
|
+
exit 0
|
|
144
|
+
fi
|
|
145
|
+
for attempt in $(seq 1 18); do
|
|
146
|
+
if curl -fsS --max-time 10 -o /dev/null "$HEALTH_URL"; then
|
|
147
|
+
echo "Staging runs ${SHA:0:7} as version $version, and $HEALTH_URL answers."
|
|
148
|
+
exit 0
|
|
149
|
+
fi
|
|
150
|
+
sleep 5
|
|
151
|
+
done
|
|
152
|
+
if [ -n "$previous" ] && wrangler rollback "$previous" --message "${SHA:0:7} failed its check" --yes; then
|
|
153
|
+
echo "rolled_back=true" >> "$GITHUB_OUTPUT"
|
|
154
|
+
echo "::error title=Rolled back::${SHA:0:7} didn't answer $HEALTH_URL within 90 seconds, so staging went back to version $previous. Read the Worker's logs on Cloudflare."
|
|
155
|
+
else
|
|
156
|
+
echo "::error title=Failed its check::${SHA:0:7} didn't answer $HEALTH_URL within 90 seconds, and staging had no version to go back to. Read the Worker's logs on Cloudflare."
|
|
157
|
+
fi
|
|
158
|
+
exit 1
|
|
159
|
+
|
|
160
|
+
- name: Record the end
|
|
161
|
+
if: always() && steps.record.outputs.deployment
|
|
162
|
+
env:
|
|
163
|
+
DEPLOYMENT: ${{ steps.record.outputs.deployment }}
|
|
164
|
+
OUTCOME: ${{ job.status }}
|
|
165
|
+
VERSION: ${{ steps.deploy.outputs.version }}
|
|
166
|
+
DIGEST: ${{ steps.artifact.outputs.digest }}
|
|
167
|
+
MIGRATIONS: ${{ steps.deploy.outputs.migrations }}
|
|
168
|
+
ROLLED_BACK: ${{ steps.deploy.outputs.rolled_back }}
|
|
169
|
+
run: |
|
|
170
|
+
set -euo pipefail
|
|
171
|
+
record() { node scripts/record-deployment.mjs --deployment "$DEPLOYMENT" --log-url "$RUN_URL" "$@" > /dev/null; }
|
|
172
|
+
if [ "$OUTCOME" = success ]; then
|
|
173
|
+
record --state success --note pre-release --version "$VERSION" --artifact "$DIGEST" --migrations "$MIGRATIONS" ${HEALTH_URL:+--environment-url "$HEALTH_URL"}
|
|
174
|
+
elif [ "$ROLLED_BACK" = true ]; then
|
|
175
|
+
record --state failure --description "rolled back: version $VERSION failed its check"
|
|
176
|
+
else
|
|
177
|
+
record --state failure --description "failed: the run says why"
|
|
178
|
+
fi
|