breakaway 1.4.0-main.6 → 1.4.0-main.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.4.0-main.6",
3
+ "version": "1.4.0-main.8",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "src/prompt.js",
29
29
  "src/redact.js",
30
30
  "src/repos.js",
31
+ "src/specs.js",
31
32
  "src/versions.js",
32
33
  "prompts/*.md",
33
34
  "taskrc",
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The steps a repository's Deploy, Promote, Roll back, and Release workflows run before they act
4
+ * (scripts/lib/deploy-plan.js). Each prints key=value lines for $GITHUB_OUTPUT and its reason on stderr.
5
+ * node scripts/deploy-plan.mjs checks --sha <commit> --checks '["CI"]'
6
+ * ready=true when every check workflow passed on the commit
7
+ * node scripts/deploy-plan.mjs plan --staging <worker> --sha <commit> --checks '["CI"]' [--branch main] [--paths <file>]
8
+ * deploy=true when staging should deploy the commit, and from=<the commit staging ran before>
9
+ * node scripts/deploy-plan.mjs live --environment <worker> sha=<the commit it runs>
10
+ * node scripts/deploy-plan.mjs sha-of --environment <worker> --version <id> sha=<the commit that version came from>
11
+ * node scripts/deploy-plan.mjs current < deployments.json version=<what wrangler deployments list runs>
12
+ * node scripts/deploy-plan.mjs uploaded < wrangler-output.ndjson version=<what wrangler just uploaded>
13
+ * node scripts/deploy-plan.mjs missing < wrangler-error.txt exits 1 unless the Worker doesn't exist yet
14
+ * Reads GITHUB_TOKEN and GITHUB_REPOSITORY (or --repo owner/name). Copied into a repository by `repos init`.
15
+ */
16
+ import { execFileSync } from 'node:child_process';
17
+ import { readFileSync } from 'node:fs';
18
+ import { parseArgs } from 'node:util';
19
+ import {
20
+ checksPassed,
21
+ currentVersionId,
22
+ deployPatterns,
23
+ liveSha,
24
+ planDeploy,
25
+ shaOfVersion,
26
+ uploadedVersionId,
27
+ workerMissing,
28
+ } from './lib/deploy-plan.js';
29
+ import { USER_AGENT, deploymentsOf } from './lib/deployments.js';
30
+
31
+ const { positionals, values: o } = parseArgs({
32
+ allowPositionals: true,
33
+ options: {
34
+ repo: { type: 'string' },
35
+ sha: { type: 'string' },
36
+ checks: { type: 'string' },
37
+ staging: { type: 'string' },
38
+ branch: { type: 'string', default: 'main' },
39
+ paths: { type: 'string' },
40
+ environment: { type: 'string' },
41
+ version: { type: 'string' },
42
+ },
43
+ });
44
+ const token = process.env.GITHUB_TOKEN;
45
+ const repo = o.repo ?? process.env.GITHUB_REPOSITORY;
46
+ const stdin = () => readFileSync(0, 'utf8');
47
+
48
+ /** @returns {Promise<any>} */
49
+ async function get(path) {
50
+ const res = await fetch(`https://api.github.com/repos/${repo}${path}`, {
51
+ headers: {
52
+ accept: 'application/vnd.github+json',
53
+ authorization: `Bearer ${token}`,
54
+ 'user-agent': USER_AGENT,
55
+ 'x-github-api-version': '2022-11-28',
56
+ },
57
+ });
58
+ if (!res.ok) throw new Error(`GitHub answered ${res.status} for ${path}: ${(await res.text()).slice(0, 200)}`);
59
+ return res.json();
60
+ }
61
+
62
+ async function checks() {
63
+ if (!/^[0-9a-f]{40}$/u.test(o.sha ?? '')) throw new Error('Give the full commit: --sha <commit>.');
64
+ let names;
65
+ try {
66
+ names = JSON.parse(o.checks ?? '');
67
+ } catch {
68
+ names = null;
69
+ }
70
+ if (!Array.isArray(names) || !names.length) throw new Error('Name the check workflows as JSON: --checks \'["CI"]\'.');
71
+ const { workflow_runs: runs } = await get(`/actions/runs?head_sha=${o.sha}&per_page=100`);
72
+ return checksPassed(runs, names, o.sha);
73
+ }
74
+
75
+ /** The files changed between two commits, or null when git can't say (the older one isn't in this checkout). */
76
+ function changedFiles(from, to) {
77
+ try {
78
+ return execFileSync('git', ['diff', '--name-only', from, to], { encoding: 'utf8' }).split('\n').filter(Boolean);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ try {
85
+ const [command] = positionals;
86
+ if (command === 'checks') {
87
+ const result = await checks();
88
+ console.error(result.reason);
89
+ console.log(`ready=${result.ready}`);
90
+ } else if (command === 'plan') {
91
+ if (!o.staging) throw new Error('Name the staging Worker: --staging <worker>.');
92
+ const ready = await checks();
93
+ const [tip, staging] = ready.ready
94
+ ? await Promise.all([
95
+ get(`/commits/${encodeURIComponent(o.branch)}`),
96
+ deploymentsOf({ token, repo, environment: o.staging }),
97
+ ])
98
+ : [null, []];
99
+ const from = staging.find((d) => d.task === 'deploy' && d.state === 'success')?.sha;
100
+ const patterns = o.paths ? deployPatterns(JSON.parse(readFileSync(o.paths, 'utf8'))) : [];
101
+ const plan = planDeploy({
102
+ sha: o.sha,
103
+ tip: tip?.sha ?? null,
104
+ checks: ready,
105
+ staging,
106
+ files: from ? changedFiles(from, o.sha) : null,
107
+ patterns,
108
+ });
109
+ console.error(plan.reason);
110
+ console.log(`deploy=${plan.deploy}\nfrom=${plan.from}`);
111
+ } else if (command === 'live' || command === 'sha-of') {
112
+ if (!o.environment) throw new Error('Name the Worker: --environment <worker>.');
113
+ const list = await deploymentsOf({ token, repo, environment: o.environment });
114
+ console.log(`sha=${(command === 'live' ? liveSha(list) : shaOfVersion(list, o.version)) ?? ''}`);
115
+ } else if (command === 'current') {
116
+ console.log(`version=${currentVersionId(JSON.parse(stdin() || '[]')) ?? ''}`);
117
+ } else if (command === 'uploaded') {
118
+ const version = uploadedVersionId(stdin());
119
+ if (!version) throw new Error("wrangler's output names no version it uploaded.");
120
+ console.log(`version=${version}`);
121
+ } else if (command === 'missing') {
122
+ if (!workerMissing(stdin()))
123
+ throw new Error(
124
+ "Couldn't list the Worker's deployments, so there would be nothing to roll back to. Check that CLOUDFLARE_ACCOUNT_ID is your account's ID and that CLOUDFLARE_API_TOKEN can read and edit the Worker.",
125
+ );
126
+ console.log('missing=true');
127
+ } else {
128
+ throw new Error(
129
+ 'Usage: deploy-plan.mjs checks | plan | live | sha-of | current | uploaded | missing (see the file)',
130
+ );
131
+ }
132
+ } catch (error) {
133
+ console.error(error.message);
134
+ process.exit(1);
135
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * What a repository's Deploy, Promote, Roll back, and Release workflows decide before they act
3
+ * (docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section 2): whether every check passed on the commit,
4
+ * whether staging needs it, and which Worker version is which. Pure over GitHub's and wrangler's answers, so the
5
+ * tests run without either. Copied into a repository by `repos init`, with scripts/deploy-plan.mjs.
6
+ */
7
+ import { candidate, productionSha, versionOf } from './promote.js';
8
+
9
+ /**
10
+ * Whether every check workflow passed on `sha`. `runs` are GitHub's workflow runs for the commit
11
+ * (`{ id, name, head_sha, status, conclusion }`); each check counts by its latest run.
12
+ * @param {Array<{ id: number, name: string, head_sha: string, status: string, conclusion: string | null }>} runs
13
+ * @param {string[]} checks the check workflows' names
14
+ * @param {string} sha
15
+ * @returns {{ ready: boolean, reason: string }}
16
+ */
17
+ export function checksPassed(runs, checks, sha) {
18
+ const latest = new Map();
19
+ for (const run of runs ?? []) {
20
+ if (run.head_sha !== sha || !checks.includes(run.name)) continue;
21
+ if (!latest.has(run.name) || latest.get(run.name).id < run.id) latest.set(run.name, run);
22
+ }
23
+ const short = sha.slice(0, 7);
24
+ const missing = checks.filter((name) => !latest.has(name));
25
+ if (missing.length) return { ready: false, reason: `${missing.join(', ')} hasn't run on ${short} yet.` };
26
+ const failed = checks.filter(
27
+ (name) => latest.get(name).status === 'completed' && latest.get(name).conclusion !== 'success',
28
+ );
29
+ if (failed.length) return { ready: false, reason: `${failed.join(', ')} didn't pass on ${short}.` };
30
+ const running = checks.filter((name) => latest.get(name).status !== 'completed');
31
+ if (running.length)
32
+ return { ready: false, reason: `${running.join(', ')} is still running on ${short}; its own run deploys it.` };
33
+ return { ready: true, reason: `${checks.join(', ')} passed on ${short}.` };
34
+ }
35
+
36
+ /**
37
+ * Whether staging should deploy `sha`. `tip` is the branch's latest commit, `staging` the staging Worker's
38
+ * Deployments (newest first, as deploymentsOf reads them), `files` the paths changed since staging's last successful
39
+ * deploy (null when they can't be known), and `patterns` the deploy paths' regular expressions.
40
+ * @returns {{ deploy: boolean, from: string, reason: string }}
41
+ */
42
+ export function planDeploy({ sha, tip, checks, staging, files, patterns }) {
43
+ const short = sha.slice(0, 7);
44
+ const no = (reason, from = '') => ({ deploy: false, from, reason });
45
+ if (!checks.ready) return no(checks.reason);
46
+ if (tip && tip !== sha)
47
+ return no(`${short} isn't the branch's latest commit any more: ${tip.slice(0, 7)} deploys next, with it.`);
48
+ const last = candidate(staging ?? []);
49
+ const from = last?.sha ?? '';
50
+ if (from === sha) return no(`Staging already runs ${short}.`, from);
51
+ if (!from) return { deploy: true, from, reason: `Staging has no deploy yet, so ${short} goes first.` };
52
+ if (files && patterns?.length && !files.some((file) => patterns.some((pattern) => pattern.test(file))))
53
+ return no(`Nothing since ${from.slice(0, 7)} touches the deploy paths, so staging stays as it is.`, from);
54
+ return { deploy: true, from, reason: `${short} goes to staging, after ${from.slice(0, 7)}.` };
55
+ }
56
+
57
+ /** The deploy paths file (`{ worker: "regex" }`) as regular expressions; a pattern that doesn't compile is an error. */
58
+ export function deployPatterns(json) {
59
+ if (!json || typeof json !== 'object' || Array.isArray(json))
60
+ throw new Error('The deploy paths file is an object of Worker names and regular expressions.');
61
+ return Object.entries(json).map(([worker, pattern]) => {
62
+ try {
63
+ return new RegExp(String(pattern), 'u');
64
+ } catch {
65
+ throw new Error(`The deploy path for ${worker} isn't a regular expression: ${pattern}`);
66
+ }
67
+ });
68
+ }
69
+
70
+ /** The version a Worker runs, from `wrangler deployments list --json`: what a rollback goes back to. */
71
+ export function currentVersionId(deployments) {
72
+ const list = Array.isArray(deployments) ? deployments : [];
73
+ const newest = [...list].sort((a, b) => String(b.created_on ?? '').localeCompare(String(a.created_on ?? '')))[0];
74
+ const best = [...(newest?.versions ?? [])].sort((a, b) => (b.percentage ?? 0) - (a.percentage ?? 0))[0];
75
+ return best?.version_id ?? null;
76
+ }
77
+
78
+ /** The version wrangler just uploaded or deployed, from its output file (WRANGLER_OUTPUT_FILE_PATH, one JSON a line). */
79
+ export function uploadedVersionId(ndjson) {
80
+ const entries = String(ndjson ?? '')
81
+ .split('\n')
82
+ .filter((line) => line.trim())
83
+ .flatMap((line) => {
84
+ try {
85
+ return [JSON.parse(line)];
86
+ } catch {
87
+ return [];
88
+ }
89
+ });
90
+ return entries.filter((e) => ['version-upload', 'deploy'].includes(e.type) && e.version_id).pop()?.version_id ?? null;
91
+ }
92
+
93
+ /** Whether wrangler's output from a failed `deployments list` says the Worker doesn't exist yet (Cloudflare error 10007). */
94
+ export const workerMissing = (output) =>
95
+ /\[code: 10007\]|worker does not exist on your account/iu.test(String(output ?? ''));
96
+
97
+ /** The commit an environment runs now: its latest successful deploy or rollback. */
98
+ export const liveSha = (deployments) => productionSha(deployments ?? []);
99
+
100
+ /** The commit a Worker version was deployed from, by the Deployments that recorded it. */
101
+ export const shaOfVersion = (deployments, version) =>
102
+ (deployments ?? []).find((d) => d.state === 'success' && versionOf(d.description) === version)?.sha ?? null;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The version numbers of a repository's npm releases (docs/specs/IDEA-27-move-ci-cd-to-the-deploy-flow.md, section
3
+ * 2b), worked out the way breakaway's own Release workflow does (scripts/release/lib.js uses these): every merge
4
+ * stages `X.Y.Z-main.N` on next, and the owner promotes one to a stable `X.Y.Z` on latest. The tags remember the
5
+ * numbers: `<prefix>X.Y.Z-main.N` for each pre-release and `<prefix>X.Y.Z` for each stable, where the prefix is `v`,
6
+ * or `<package>@` in a repository whose production deploys already tag `v…`. Pure, so it's tested without git.
7
+ * Copied into a repository by `repos init`, with scripts/package-release.mjs.
8
+ */
9
+
10
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/u;
11
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
12
+ const prereleasePattern = (prefix) => new RegExp(`^${escapeRegex(prefix)}(\\d+\\.\\d+\\.\\d+)-main\\.(\\d+)$`, 'u');
13
+ const stablePattern = (prefix) => new RegExp(`^${escapeRegex(prefix)}(\\d+\\.\\d+\\.\\d+)$`, 'u');
14
+
15
+ /** @param {string} v @returns {[number, number, number]} */
16
+ function parts(v) {
17
+ const m = SEMVER.exec(v);
18
+ if (!m) throw new Error(`"${v}" isn't a version like 1.4.0.`);
19
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
20
+ }
21
+
22
+ /** Negative, zero, or positive, like a sort comparator, for two X.Y.Z versions. */
23
+ export function compareVersions(a, b) {
24
+ const [x, y] = [parts(a), parts(b)];
25
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
26
+ }
27
+
28
+ /** The tags' prefix: `v`, or `<package>@` when the repository's production deploys already tag `v…`. */
29
+ export const tagPrefix = (name, { workers = false } = {}) => (workers ? `${name}@` : 'v');
30
+
31
+ /**
32
+ * The next pre-release. `current` is package.json's version, the release being worked toward; once its stable tag
33
+ * exists the work is toward the next patch, so a pre-release never sorts below a stable.
34
+ * @param {string} current
35
+ * @param {string[]} tags every tag in the repository
36
+ * @param {string} [prefix]
37
+ * @returns {{ version: string, base: string, tag: string }}
38
+ */
39
+ export function nextPrerelease(current, tags, prefix = 'v') {
40
+ parts(current);
41
+ const stable = stablePattern(prefix);
42
+ const prerelease = prereleasePattern(prefix);
43
+ const stables = tags.map((t) => stable.exec(t)?.[1]).filter(Boolean);
44
+ let base = current;
45
+ for (const s of stables) {
46
+ if (compareVersions(s, base) >= 0) {
47
+ const [maj, min, pat] = parts(s);
48
+ base = `${maj}.${min}.${pat + 1}`;
49
+ }
50
+ }
51
+ const last = tags
52
+ .map((t) => prerelease.exec(t))
53
+ .filter((m) => m?.[1] === base)
54
+ .reduce((n, m) => Math.max(n, Number(m[2])), 0);
55
+ const version = `${base}-main.${last + 1}`;
56
+ return { version, base, tag: `${prefix}${version}` };
57
+ }
58
+
59
+ /** The pre-release among `tags` (the tags on one commit), if one was already staged from it. */
60
+ export function prereleaseAmong(tags, prefix = 'v') {
61
+ const prerelease = prereleasePattern(prefix);
62
+ const tag = tags.find((t) => prerelease.test(t));
63
+ return tag ? { tag, version: tag.slice(prefix.length) } : null;
64
+ }
65
+
66
+ /** The stable version a pre-release tag (`v1.4.0-main.37`) is promoted to. */
67
+ export function stableOf(prereleaseTag, prefix = 'v') {
68
+ const m = prereleasePattern(prefix).exec(prereleaseTag);
69
+ if (!m) throw new Error(`"${prereleaseTag}" isn't a pre-release tag like ${prefix}1.4.0-main.37.`);
70
+ return m[1];
71
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Release workflow's version numbers for a repository's npm package (scripts/lib/package-release.js). Prints
4
+ * key=value lines for $GITHUB_OUTPUT.
5
+ * node scripts/package-release.mjs prerelease --dir <path> --prefix <tag prefix> [--sha <commit>]
6
+ * the next pre-release's version and tag, from <path>/package.json and the repository's tags; with --sha,
7
+ * exists=true when that commit already has a pre-release (a second run for one merge stages nothing)
8
+ * node scripts/package-release.mjs stable <pre-release tag> --prefix <tag prefix>
9
+ * the stable version and tag it becomes, and the commit it was staged from; stops if that stable exists
10
+ * Reads the tags with git, so run it in a checkout with its tags (fetch-depth: 0). Copied by `repos init`.
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { parseArgs } from 'node:util';
16
+ import { nextPrerelease, prereleaseAmong, stableOf } from './lib/package-release.js';
17
+
18
+ const { positionals, values: o } = parseArgs({
19
+ allowPositionals: true,
20
+ options: { dir: { type: 'string', default: '.' }, prefix: { type: 'string', default: 'v' }, sha: { type: 'string' } },
21
+ });
22
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).split('\n').filter(Boolean);
23
+
24
+ try {
25
+ const [command, tag] = positionals;
26
+ if (command === 'prerelease') {
27
+ const current = JSON.parse(readFileSync(join(o.dir, 'package.json'), 'utf8')).version;
28
+ const staged = o.sha ? prereleaseAmong(git('tag', '--points-at', o.sha), o.prefix) : null;
29
+ if (staged) {
30
+ console.error(`${o.sha.slice(0, 7)} already has a pre-release, ${staged.tag}, so this run stages nothing.`);
31
+ console.log(`version=${staged.version}\ntag=${staged.tag}\nexists=true`);
32
+ } else {
33
+ const next = nextPrerelease(current, git('tag', '-l'), o.prefix);
34
+ console.log(`version=${next.version}\ntag=${next.tag}\nexists=false`);
35
+ }
36
+ } else if (command === 'stable' && tag) {
37
+ const version = stableOf(tag, o.prefix);
38
+ const tags = git('tag', '-l');
39
+ if (!tags.includes(tag)) throw new Error(`There is no pre-release ${tag}. Pick one the Release workflow staged.`);
40
+ if (tags.includes(`${o.prefix}${version}`))
41
+ throw new Error(`${o.prefix}${version} is already released. Pick a newer pre-release.`);
42
+ const [commit] = git('rev-list', '-n', '1', `refs/tags/${tag}`);
43
+ console.log(`version=${version}\ntag=${o.prefix}${version}\ncommit=${commit}`);
44
+ } else {
45
+ throw new Error(
46
+ 'Usage: package-release.mjs prerelease --dir <path> --prefix <p> [--sha <commit>] | stable <tag> --prefix <p>',
47
+ );
48
+ }
49
+ } catch (error) {
50
+ console.error(error.message);
51
+ process.exit(1);
52
+ }
@@ -32,13 +32,18 @@ export const HOOKS_FROM_COPY = false;
32
32
  /** The session hooks' entry files, and where their copy goes: its own folder, so it never touches the repository's src/. */
33
33
  export const HOOK_ENTRIES = ['scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
34
34
  export const HOOKS_DIR = 'tools/tasks/cli/';
35
- /** The release helpers a repository's Deploy, Promote, and Roll back workflows run (BRK-45), copied with what they import. */
35
+ /**
36
+ * The release helpers a repository's Deploy, Promote, Roll back, and Release workflows run (BRK-45), copied with what
37
+ * they import: the deploy helpers, and the package's version numbers (BRK-90, `npx breakaway pipeline init`).
38
+ */
36
39
  export const RELEASE_ENTRIES = [
37
40
  'scripts/record-deployment.mjs',
38
41
  'scripts/promote-check.mjs',
39
42
  'scripts/release-notes.mjs',
40
43
  'scripts/check-migrations.mjs',
41
44
  'scripts/release-artifact.mjs',
45
+ 'scripts/deploy-plan.mjs',
46
+ 'scripts/package-release.mjs',
42
47
  ];
43
48
  /** Copied unchanged: the board's shared core and stub, and the Taskwarrior settings the new .taskrc includes. */
44
49
  const COPIED = ['prompts/core.md', 'prompts/stub.md'];