breakaway 1.0.2-main.9 → 1.0.2
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/README.md +1 -1
- package/package.json +1 -1
- package/prompts/install.md +6 -0
- package/scripts/install/cli.js +33 -16
- package/scripts/install/init.js +4 -2
- package/scripts/install/lib.js +117 -27
- package/scripts/tasks/hook-config.js +33 -5
- package/scripts/tasks/message-wait.mjs +1 -1
- package/scripts/tasks/repo.js +1 -1
- package/scripts/tasks/session-hook.mjs +11 -4
- package/scripts/tasks/session-messages.js +7 -0
- package/scripts/tasks/settings.js +20 -35
- package/scripts/tasks.mjs +21 -32
- package/src/cli-version.js +2 -3
- package/src/install.js +26 -2
- package/src/redact.js +3 -2
- package/template/.github/workflows/deploy.yml +67 -16
- package/template/README.md +22 -2
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ breakaway publishes releases and never deploys an install. Every install, the ow
|
|
|
174
174
|
- **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), its signature `manifest.json.sig` (Ed25519, made with a key only the release workflow holds; the public key is `src/release-key.js`), and `SHA256SUMS`. The notes list the merged pull requests by title.
|
|
175
175
|
- **A stable release** `vX.Y.Z` is the owner's: they run the **Release** workflow with the pre-release to promote. The bundle is that pre-release's, unchanged, and the notes cover everything since the last stable.
|
|
176
176
|
- **The CLI** is on npm as [`breakaway`](https://www.npmjs.com/package/breakaway), staged on npm by the same workflow, with provenance, and live once the owner approves it there with 2FA. npm's trusted publishing can't yet read the OIDC identity of a repository as new as this one ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), so until it can, a token that can stage but never publish by itself stands in, in an environment only `main` can use. Every pre-release goes out under the `next` dist-tag, and a stable release as `latest`. `npx breakaway <command>` is `node scripts/tasks.mjs <command>`.
|
|
177
|
-
- **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. Data the Durable Object stores changes forward-only and additively, so an install can always go back one release.
|
|
177
|
+
- **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. When the only step is `wrangler deploy` (a new Durable Object class, a cron, a route), it also sets `wranglerDeploy: true`, and an install whose Deploy may run `wrangler deploy` does it itself (the install template's README says when). Data the Durable Object stores changes forward-only and additively, so an install can always go back one release, except across a new Durable Object class, which Cloudflare doesn't roll back.
|
|
178
178
|
- **The version** is `package.json`'s; the release workflow sets it to the pre-release's before it builds. `GET /api/ping` and `GET /api/health` report it as `release`. An install that deploys a stable passes it as the `BREAKAWAY_VERSION` variable, since the bundle was built as the pre-release.
|
|
179
179
|
|
|
180
180
|
</details>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.0.2
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
package/prompts/install.md
CHANGED
|
@@ -54,6 +54,12 @@ The Cloudflare API token is theirs to make. On Cloudflare: My Profile, API Token
|
|
|
54
54
|
gh secret set CLOUDFLARE_API_TOKEN --env production --repo <owner>/<name>
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
That token can run `wrangler deploy`, so Deploy can apply a new address, cron triggers, or a new Durable Object class by itself instead of stopping for them. That's theirs to turn on; after a yes:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
gh variable set BREAKAWAY_DEPLOY_CHANGES --body true --repo <owner>/<name>
|
|
61
|
+
```
|
|
62
|
+
|
|
57
63
|
**Check:** `gh api repos/<owner>/<name>/environments/production/secrets --jq '.secrets[].name'` lists both secrets, and the workflows are on `main`.
|
|
58
64
|
|
|
59
65
|
## 2. The board's secrets
|
package/scripts/install/cli.js
CHANGED
|
@@ -11,14 +11,12 @@ import { ask } from '../tasks/ask.js';
|
|
|
11
11
|
import { QUESTIONS, configFrom, stateFrom, writeInstall } from './init.js';
|
|
12
12
|
import {
|
|
13
13
|
bumpBody,
|
|
14
|
-
|
|
15
|
-
configStop,
|
|
14
|
+
deployPlan,
|
|
16
15
|
deployTarget,
|
|
17
16
|
isHealthy,
|
|
18
17
|
latestReleases,
|
|
19
18
|
parseState,
|
|
20
19
|
previousVersionId,
|
|
21
|
-
shapeChanges,
|
|
22
20
|
shapeOf,
|
|
23
21
|
updatePlan,
|
|
24
22
|
workerMissing,
|
|
@@ -72,6 +70,28 @@ const configIn = (path) => {
|
|
|
72
70
|
}
|
|
73
71
|
};
|
|
74
72
|
|
|
73
|
+
/** A file's JSON, or null when it's missing or isn't JSON. */
|
|
74
|
+
const jsonIn = (path) => {
|
|
75
|
+
try {
|
|
76
|
+
return path ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The running Worker's shape for `check`, or null when nothing says what it is. */
|
|
83
|
+
function runningShape(opts) {
|
|
84
|
+
const running = jsonIn(opts['running-config']);
|
|
85
|
+
if (running && typeof running === 'object') return shapeOf(running);
|
|
86
|
+
const before = jsonIn(opts.before);
|
|
87
|
+
if (!before) return null; // no earlier config (the first deploy, or a dispatch): nothing to compare with
|
|
88
|
+
try {
|
|
89
|
+
return shapeOf(wranglerConfig(parseInstall(before)));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
/** The Worker's wrangler config for a downloaded release: the install's config, with the code and web app where the bundle put them. */
|
|
76
96
|
export function bundleConfig(config, bundle) {
|
|
77
97
|
const out = wranglerConfig(config);
|
|
@@ -99,20 +119,17 @@ export async function runStep(step, opts, io) {
|
|
|
99
119
|
const version = opts.version;
|
|
100
120
|
if (!version) throw new Stop('check needs --version, the release being deployed.');
|
|
101
121
|
const manifest = readJson(opts.manifest ?? 'release/manifest.json', 'the release’s manifest');
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
before = null; // no earlier config (the first deploy, or a dispatch): nothing to compare with
|
|
110
|
-
}
|
|
111
|
-
const now = configIn(join(dir, 'breakaway.config.json'));
|
|
112
|
-
const stop = before && configStop(shapeChanges(shapeOf(wranglerConfig(before)), shapeOf(wranglerConfig(now))));
|
|
113
|
-
if (stop) throw new Stop(stop, 2);
|
|
114
|
-
}
|
|
122
|
+
// The Worker as it runs: the config the running release's own CLI made (--running-config), else this release's code
|
|
123
|
+
// with the config from before this push (--before). Neither (a first deploy, or a dispatch): nothing to compare.
|
|
124
|
+
const before = runningShape(opts);
|
|
125
|
+
const after = before && shapeOf(wranglerConfig(configIn(join(dir, 'breakaway.config.json'))));
|
|
126
|
+
const apply = opts['deploy-changes'] === true || opts['deploy-changes'] === 'true';
|
|
127
|
+
const plan = deployPlan(manifest, { version, running: opts.running || null, before, after, apply });
|
|
128
|
+
if (plan.ok === false) throw new Stop(plan.message, 2);
|
|
115
129
|
io.out('ok=true');
|
|
130
|
+
io.out(`deploy=${plan.deploy}`);
|
|
131
|
+
io.out(`changes=${plan.changes.join(', ')}`);
|
|
132
|
+
io.out(`address_changed=${plan.addressChanged}`);
|
|
116
133
|
return 0;
|
|
117
134
|
}
|
|
118
135
|
if (step === 'config') {
|
package/scripts/install/init.js
CHANGED
|
@@ -92,8 +92,10 @@ export function writeInstall({ dir, templateDir, config, state }) {
|
|
|
92
92
|
writeFileSync(target, text);
|
|
93
93
|
written.push(path);
|
|
94
94
|
};
|
|
95
|
-
// The Worker reads the channel from its config (BRK-10), so it starts the same as breakaway.json's.
|
|
96
|
-
|
|
95
|
+
// The Worker reads the channel from its config (BRK-10), so it starts the same as breakaway.json's. No aliases yet: an
|
|
96
|
+
// empty list is left out, so a release from before them (BRK-78) still reads the file.
|
|
97
|
+
const { aliases, ...named } = config;
|
|
98
|
+
put('breakaway.config.json', json({ ...named, ...(aliases?.length ? { aliases } : {}), channel: state.channel }));
|
|
97
99
|
put('breakaway.json', json(state));
|
|
98
100
|
for (const file of TEMPLATE_FILES) put(file, readFileSync(join(templateDir, file), 'utf8'));
|
|
99
101
|
put('README.md', readmeFor(readFileSync(join(templateDir, 'README.md'), 'utf8'), config));
|
package/scripts/install/lib.js
CHANGED
|
@@ -78,13 +78,19 @@ export function deployTarget(state, releases) {
|
|
|
78
78
|
return { version: latest.version, tag: tagOf(latest.version), channel: 'main' };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** How Deploy is allowed to run wrangler deploy, for the messages that stop it. */
|
|
82
|
+
const LET_DEPLOY =
|
|
83
|
+
"give CLOUDFLARE_API_TOKEN the scopes this repository's README lists for it and set the repository variable BREAKAWAY_DEPLOY_CHANGES to true";
|
|
84
|
+
|
|
81
85
|
/**
|
|
82
86
|
* Whether a downloaded release may be deployed by the workflow. A release that needs hands, or one the running version
|
|
83
|
-
* can't update from, stops with the message the workflow prints.
|
|
84
|
-
*
|
|
85
|
-
* @
|
|
87
|
+
* can't update from, stops with the message the workflow prints. A manual release whose only step is wrangler deploy
|
|
88
|
+
* (`wranglerDeploy`, BRK-62) passes when the install lets Deploy run it (`apply`), and says it needs wrangler deploy.
|
|
89
|
+
* @param {{ version?: string, manual?: boolean, manualSteps?: string[], wranglerDeploy?: boolean, updatesFrom?: string }} manifest
|
|
90
|
+
* @param {{ version: string, running?: string | null, apply?: boolean }} options
|
|
91
|
+
* @returns {{ ok: true, wrangler?: true } | { ok: false, message: string }}
|
|
86
92
|
*/
|
|
87
|
-
export function checkManifest(manifest, { version, running = null }) {
|
|
93
|
+
export function checkManifest(manifest, { version, running = null, apply = false }) {
|
|
88
94
|
if (!manifest || typeof manifest !== 'object')
|
|
89
95
|
return { ok: false, message: 'the release’s manifest.json isn’t readable, so nothing was deployed.' };
|
|
90
96
|
if (manifest.version !== version)
|
|
@@ -92,13 +98,6 @@ export function checkManifest(manifest, { version, running = null }) {
|
|
|
92
98
|
ok: false,
|
|
93
99
|
message: `the manifest is for ${manifest.version}, not ${version}, so nothing was deployed. Try again; if it persists, the release was published wrongly.`,
|
|
94
100
|
};
|
|
95
|
-
if (manifest.manual === true) {
|
|
96
|
-
const steps = (manifest.manualSteps ?? []).map((s) => `\n - ${s}`).join('');
|
|
97
|
-
return {
|
|
98
|
-
ok: false,
|
|
99
|
-
message: `breakaway ${version} needs steps by hand, so the workflow didn't deploy it. Do these, then deploy it yourself with wrangler:${steps}`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
101
|
if (
|
|
103
102
|
running &&
|
|
104
103
|
manifest.updatesFrom &&
|
|
@@ -110,40 +109,131 @@ export function checkManifest(manifest, { version, running = null }) {
|
|
|
110
109
|
ok: false,
|
|
111
110
|
message: `breakaway ${version} updates from ${manifest.updatesFrom} or newer, and this install runs ${running}. Update to ${manifest.updatesFrom} first (set it in breakaway.json), then to ${version}.`,
|
|
112
111
|
};
|
|
112
|
+
if (manifest.manual === true) {
|
|
113
|
+
const byWrangler = manifest.wranglerDeploy === true;
|
|
114
|
+
if (byWrangler && apply) return { ok: true, wrangler: true };
|
|
115
|
+
const steps = (manifest.manualSteps ?? []).map((s) => `\n - ${s}`).join('');
|
|
116
|
+
const hint = byWrangler
|
|
117
|
+
? `\nThese steps are what wrangler deploy does, so Deploy can do them itself: ${LET_DEPLOY}, then run Deploy again.`
|
|
118
|
+
: '';
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `breakaway ${version} needs steps by hand, so the workflow didn't deploy it. Do these, then deploy it yourself with wrangler:${steps}${hint}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
113
124
|
return { ok: true };
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* What a version upload can't change (BRK-62): the Worker and its Durable Object, its routes and crons, and the Durable
|
|
129
|
+
* Object classes and their migrations. Made from the Worker's wrangler config.
|
|
130
|
+
*/
|
|
117
131
|
export function shapeOf(wrangler) {
|
|
132
|
+
const inst = wrangler.vars?.TASKS_INSTALL;
|
|
118
133
|
return {
|
|
134
|
+
worker: wrangler.name ?? null,
|
|
135
|
+
store: (inst && typeof inst === 'object' ? inst.store : null) ?? null,
|
|
136
|
+
jurisdiction: wrangler.vars?.TASKS_JURISDICTION ?? null,
|
|
119
137
|
routes: wrangler.routes ?? [],
|
|
120
138
|
workers_dev: Boolean(wrangler.workers_dev),
|
|
121
139
|
crons: wrangler.triggers?.crons ?? [],
|
|
122
140
|
durable_objects: wrangler.durable_objects?.bindings ?? [],
|
|
123
141
|
migrations: wrangler.migrations ?? [],
|
|
124
|
-
jurisdiction: wrangler.vars?.TASKS_JURISDICTION ?? null,
|
|
125
142
|
};
|
|
126
143
|
}
|
|
127
144
|
|
|
128
|
-
|
|
145
|
+
const json = (value) => JSON.stringify(value);
|
|
146
|
+
/** A migration that only makes classes, which wrangler deploy applies; any other kind deletes, renames, or moves one. */
|
|
147
|
+
const CREATES = new Set(['tag', 'new_sqlite_classes', 'new_classes']);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The classes `after` adds, when adding is all its Durable Object change does: its migrations are `before`'s with only
|
|
151
|
+
* new classes after them, and it keeps every binding `before` has. Null for anything else.
|
|
152
|
+
*/
|
|
153
|
+
function addedClasses(before, after) {
|
|
154
|
+
const kept = before.migrations.every((m, i) => json(m) === json(after.migrations[i]));
|
|
155
|
+
const added = after.migrations.slice(before.migrations.length);
|
|
156
|
+
if (!kept || !added.every((m) => Object.keys(m).every((key) => CREATES.has(key)))) return null;
|
|
157
|
+
if (!before.durable_objects.every((b) => after.durable_objects.some((a) => json(a) === json(b)))) return null;
|
|
158
|
+
return added.flatMap((m) => [...(m.new_sqlite_classes ?? []), ...(m.new_classes ?? [])]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* What differs between the Worker's shape as it runs and as this deploy makes it, each with whether wrangler deploy may
|
|
163
|
+
* apply it (BRK-62): an address, cron triggers, and new Durable Object classes, yes. Another Worker, another Durable
|
|
164
|
+
* Object, or a class deleted, renamed, or moved, never: the first two open an empty board, and the last is a release's
|
|
165
|
+
* manual step. Empty when a version upload carries the whole deploy.
|
|
166
|
+
* @returns {{ change: string, apply: boolean, why?: string, address?: true }[]}
|
|
167
|
+
*/
|
|
129
168
|
export function shapeChanges(before, after) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
169
|
+
const differs = (key) => json(before[key]) !== json(after[key]);
|
|
170
|
+
const out = [];
|
|
171
|
+
const empty = 'another Durable Object is an empty board';
|
|
172
|
+
if (differs('worker'))
|
|
173
|
+
out.push({
|
|
174
|
+
change: 'its Worker’s name (worker)',
|
|
175
|
+
apply: false,
|
|
176
|
+
why: 'a new name makes a new Worker, with an empty board',
|
|
177
|
+
});
|
|
178
|
+
if (differs('store')) out.push({ change: 'its Durable Object (store)', apply: false, why: empty });
|
|
179
|
+
if (differs('jurisdiction')) out.push({ change: 'its jurisdiction', apply: false, why: empty });
|
|
180
|
+
if (differs('routes')) out.push({ change: 'its address (routes)', apply: true, address: true });
|
|
181
|
+
if (differs('workers_dev')) out.push({ change: 'its workers.dev address', apply: true, address: true });
|
|
182
|
+
if (differs('crons')) out.push({ change: 'its cron triggers', apply: true });
|
|
183
|
+
if (differs('durable_objects') || differs('migrations')) {
|
|
184
|
+
const added = addedClasses(before, after);
|
|
185
|
+
if (added === null)
|
|
186
|
+
out.push({
|
|
187
|
+
change: 'its Durable Object classes: it deletes, renames, or moves one',
|
|
188
|
+
apply: false,
|
|
189
|
+
why: 'that is a release’s manual step, and it can lose a board',
|
|
190
|
+
});
|
|
191
|
+
else
|
|
192
|
+
out.push({
|
|
193
|
+
change: added.length
|
|
194
|
+
? `its Durable Object classes: it adds ${added.join(', ')}`
|
|
195
|
+
: 'its Durable Object bindings',
|
|
196
|
+
apply: true,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
141
200
|
}
|
|
142
201
|
|
|
143
|
-
/** The message that stops a deploy
|
|
202
|
+
/** The message that stops a deploy whose changes need wrangler deploy when the install hasn't allowed it, or null. */
|
|
144
203
|
export function configStop(changes) {
|
|
145
204
|
if (!changes.length) return null;
|
|
146
|
-
return `
|
|
205
|
+
return `This deploy changes ${changes.join(', ')}, which a version upload can't carry, so nothing was deployed. To let Deploy run wrangler deploy for it, ${LET_DEPLOY}, then run Deploy again. Or apply it yourself with wrangler (npx wrangler deploy, with the config npx breakaway install config makes), then run Deploy again.`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The message that stops a deploy with a change Deploy never makes. */
|
|
209
|
+
function neverStop(changes) {
|
|
210
|
+
const why = [...new Set(changes.map((c) => c.why))].join('; ');
|
|
211
|
+
return `This deploy changes ${changes.map((c) => c.change).join(', ')}, which Deploy never does, so nothing was deployed: ${why}. If breakaway.config.json changed it, put it back as it was. If the release did, its notes say what to do by hand.`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* What Deploy does with a release (BRK-62): stop with a message, upload a version (`versions`), or run wrangler deploy
|
|
216
|
+
* (`wrangler`) for what a version can't carry. `before` and `after` are the Worker's shapes as it runs and as this deploy
|
|
217
|
+
* makes it; `before` is null when there is nothing to compare with (a first deploy, or a dispatch with no running release
|
|
218
|
+
* to ask). `apply` is whether the install lets Deploy run wrangler deploy: BREAKAWAY_DEPLOY_CHANGES, set for a token that
|
|
219
|
+
* can. `addressChanged` tells the health check to wait for a new custom domain.
|
|
220
|
+
* @returns {{ ok: false, message: string } | { ok: true, deploy: 'versions' | 'wrangler', changes: string[], addressChanged: boolean }}
|
|
221
|
+
*/
|
|
222
|
+
export function deployPlan(manifest, { version, running = null, before = null, after = null, apply = false }) {
|
|
223
|
+
const verdict = checkManifest(manifest, { version, running, apply });
|
|
224
|
+
if (verdict.ok === false) return verdict;
|
|
225
|
+
const found = before && after ? shapeChanges(before, after) : [];
|
|
226
|
+
const never = found.filter((c) => !c.apply);
|
|
227
|
+
if (never.length) return { ok: false, message: neverStop(never) };
|
|
228
|
+
const changes = found.map((c) => c.change);
|
|
229
|
+
const byWrangler = changes.length > 0 || verdict.wrangler === true;
|
|
230
|
+
if (byWrangler && !apply) return { ok: false, message: configStop(changes) };
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
deploy: byWrangler ? 'wrangler' : 'versions',
|
|
234
|
+
changes,
|
|
235
|
+
addressChanged: found.some((c) => c.address === true),
|
|
236
|
+
};
|
|
147
237
|
}
|
|
148
238
|
|
|
149
239
|
/**
|
|
@@ -3,14 +3,32 @@
|
|
|
3
3
|
* `tasks claim`) and where the board is, found the same way as the CLI (scripts/tasks/settings.js).
|
|
4
4
|
* Each returns null/undefined rather than throwing when something's missing, because the hooks stay quiet.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
7
8
|
import { homedir } from 'node:os';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
import { boardUrl, configDir, parseEnvFile, readSetting } from './settings.js';
|
|
10
11
|
|
|
11
|
-
/** The
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** The git root of the current folder, or null outside a repository. */
|
|
13
|
+
function gitRoot() {
|
|
14
|
+
try {
|
|
15
|
+
return (
|
|
16
|
+
execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
19
|
+
}).trim() || null
|
|
20
|
+
);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The project's root: as Claude Code gives it to hooks, and where it doesn't (a cloud session can leave
|
|
28
|
+
* CLAUDE_PROJECT_DIR empty, BRK-88), the git root of the folder the hook runs in, where `tasks claim` marks the task.
|
|
29
|
+
*/
|
|
30
|
+
export function projectRoot(env = process.env, root = gitRoot, cwd = process.cwd()) {
|
|
31
|
+
return env.CLAUDE_PROJECT_DIR || root() || cwd;
|
|
14
32
|
}
|
|
15
33
|
|
|
16
34
|
/** A file's text, or null when it can't be read. */
|
|
@@ -24,7 +42,7 @@ function readOptional(path) {
|
|
|
24
42
|
|
|
25
43
|
/** This machine's tasks.env, as `{ NAME: value }`. */
|
|
26
44
|
function envFile() {
|
|
27
|
-
const dir = configDir({ env: process.env, home: homedir()
|
|
45
|
+
const dir = configDir({ env: process.env, home: homedir() });
|
|
28
46
|
return parseEnvFile(readOptional(join(dir, 'tasks.env')));
|
|
29
47
|
}
|
|
30
48
|
|
|
@@ -55,3 +73,13 @@ export function boardConfig(root = projectRoot()) {
|
|
|
55
73
|
const token = readSetting('TOKEN', { env: process.env, file });
|
|
56
74
|
return { base: url, headers: token ? { Authorization: `Bearer ${token}` } : {} };
|
|
57
75
|
}
|
|
76
|
+
|
|
77
|
+
/** The board says this checkout's task isn't claimed by its agent any more: drop the marker so later calls post nothing (BRK-87). */
|
|
78
|
+
export function dropClaim(claim, root = projectRoot()) {
|
|
79
|
+
try {
|
|
80
|
+
const marker = join(root, '.task-session');
|
|
81
|
+
if (JSON.parse(readFileSync(marker, 'utf8'))?.uuid === claim.uuid) rmSync(marker);
|
|
82
|
+
} catch {
|
|
83
|
+
/* already gone */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* quietly, and a message waits for the agent's next turn; its `timeout` (300 s) outlasts the window,
|
|
8
8
|
* because Claude Code kills an async hook at its timeout (CLD-146).
|
|
9
9
|
*
|
|
10
|
-
* Quiet by design: without a claimed task (.task-session), with BREAKAWAY_SESSION_LOG=off
|
|
10
|
+
* Quiet by design: without a claimed task (.task-session), with BREAKAWAY_SESSION_LOG=off, or
|
|
11
11
|
* on any error, it exits 0. It writes nothing inside the repository: a cloud session's Stop check
|
|
12
12
|
* would take a dirty file as work to commit.
|
|
13
13
|
*/
|
package/scripts/tasks/repo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which repository the CLI works in (docs/specs/IDEA-14-multi-repo.md, section 5; CLD-123): the
|
|
3
3
|
* checkout's, from `git remote get-url origin`, matched against the board's registry. `--repo` or
|
|
4
|
-
* BREAKAWAY_REPO
|
|
4
|
+
* BREAKAWAY_REPO overrides it. Pure, so it's tested without git or the board.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* additionalContext so Claude gets them on its next turn (docs/specs/IDEA-15-message-a-running-agent.md).
|
|
8
8
|
*
|
|
9
9
|
* Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
|
|
10
|
-
* BREAKAWAY_SESSION_LOG=off
|
|
10
|
+
* BREAKAWAY_SESSION_LOG=off, or on any error, it does nothing and exits 0. A post
|
|
11
11
|
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
12
12
|
*/
|
|
13
|
-
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
13
|
+
import { boardConfig, claimedTask, dropClaim, projectRoot } from './hook-config.js';
|
|
14
14
|
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
15
15
|
import { entryFor } from './session-log.js';
|
|
16
|
-
import { CONTEXT_EVENTS, messageOutput } from './session-messages.js';
|
|
16
|
+
import { CONTEXT_EVENTS, messageOutput, releasedOutput } from './session-messages.js';
|
|
17
17
|
|
|
18
18
|
async function main() {
|
|
19
19
|
const root = projectRoot();
|
|
@@ -49,7 +49,14 @@ async function main() {
|
|
|
49
49
|
}
|
|
50
50
|
if (!res.ok) return noteHookFailure(claim.uuid, `HTTP ${res.status}`);
|
|
51
51
|
clearHookFailure(claim.uuid);
|
|
52
|
-
const
|
|
52
|
+
const answer = await res.json();
|
|
53
|
+
if (answer?.released) {
|
|
54
|
+
dropClaim(claim, root);
|
|
55
|
+
const output = releasedOutput(claim, hook.hook_event_name);
|
|
56
|
+
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const output = messageOutput(answer, hook.hook_event_name);
|
|
53
60
|
if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -27,6 +27,13 @@ export function messageOutput(answer, event) {
|
|
|
27
27
|
return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** The one line the hook says when the board no longer has this checkout's task claimed by its agent (BRK-87), or null on an event that can't carry it. */
|
|
31
|
+
export function releasedOutput(claim, event) {
|
|
32
|
+
if (!CONTEXT_EVENTS.has(event)) return null;
|
|
33
|
+
const text = `${claim.wid ?? 'The task'} is no longer claimed by ${claim.agent ?? 'this agent'} (finished or released), so this checkout stops sending its live output. \`tasks claim\` starts it again on another task.`;
|
|
34
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
/** The board's answer → the messages as Claude reads them, one paragraph each, or '' when there are none. */
|
|
31
38
|
export function messageText(answer) {
|
|
32
39
|
const messages = Array.isArray(answer?.messages) ? answer.messages : [];
|
|
@@ -1,52 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which board install the CLI and the session hooks talk to, and the names of their settings (CLD-136,
|
|
3
|
-
* docs/specs/IDEA-13-breakaway.md, section 2).
|
|
4
|
-
* name the first install used before breakaway (SAMEWAVE_TASKS_URL), read as a fallback so existing machines,
|
|
5
|
-
* cloud environments, and agents keep working. Pure (the environment, files, and checks come in as arguments),
|
|
3
|
+
* docs/specs/IDEA-13-breakaway.md, section 2). Pure (the environment, files, and checks come in as arguments),
|
|
6
4
|
* so it's tested without a disk.
|
|
7
5
|
*/
|
|
8
|
-
/** Each setting's
|
|
6
|
+
/** Each setting's name, in the environment and in tasks.env (BRK-77 retired the first install's names). */
|
|
9
7
|
export const NAMES = Object.freeze({
|
|
10
|
-
URL:
|
|
11
|
-
TOKEN:
|
|
12
|
-
AGENT:
|
|
13
|
-
REPO:
|
|
14
|
-
CLIENT_ID:
|
|
15
|
-
SECRET:
|
|
16
|
-
SYNC_KEY:
|
|
17
|
-
SESSION_LOG:
|
|
8
|
+
URL: 'BREAKAWAY_URL',
|
|
9
|
+
TOKEN: 'BREAKAWAY_TOKEN',
|
|
10
|
+
AGENT: 'BREAKAWAY_AGENT',
|
|
11
|
+
REPO: 'BREAKAWAY_REPO',
|
|
12
|
+
CLIENT_ID: 'BREAKAWAY_CLIENT_ID',
|
|
13
|
+
SECRET: 'BREAKAWAY_SECRET',
|
|
14
|
+
SYNC_KEY: 'BREAKAWAY_SYNC_KEY',
|
|
15
|
+
SESSION_LOG: 'BREAKAWAY_SESSION_LOG',
|
|
18
16
|
});
|
|
19
17
|
|
|
20
|
-
const LEGACY_NAMES = new Set(Object.values(NAMES).map(([, legacy]) => legacy));
|
|
21
|
-
|
|
22
18
|
/**
|
|
23
|
-
* A setting's value: from the environment first
|
|
24
|
-
*
|
|
19
|
+
* A setting's value: from the environment first, then the env file, else `fallback`. The environment always wins,
|
|
20
|
+
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere.
|
|
25
21
|
*/
|
|
26
22
|
export function readSetting(key, { env = {}, file = {} }, fallback) {
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
-
return fallback;
|
|
23
|
+
const name = NAMES[key];
|
|
24
|
+
if (!name) throw new Error(`no setting ${key}`);
|
|
25
|
+
return env[name] || file[name] || fallback;
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
/** True when an env file uses the first install's names, so the CLI writes it back with the same ones. */
|
|
34
|
-
export const usesLegacyNames = (file) => Object.keys(file ?? {}).some((name) => LEGACY_NAMES.has(name));
|
|
35
|
-
|
|
36
|
-
/** A setting's name in an env file the CLI writes: the file's own style, breakaway's for a new one. */
|
|
37
|
-
export const nameFor = (key, legacy) => NAMES[key][legacy ? 1 : 0];
|
|
38
|
-
|
|
39
28
|
/**
|
|
40
29
|
* This machine's folder for the board (tasks.env, the Taskwarrior credentials, the routines copy):
|
|
41
|
-
* BREAKAWAY_HOME when it's set (one per install, for a machine that uses two), else ~/.config/breakaway
|
|
42
|
-
* else ~/.config/samewave when only that one exists (a machine set up before breakaway), else ~/.config/breakaway.
|
|
30
|
+
* BREAKAWAY_HOME when it's set (one per install, for a machine that uses two), else ~/.config/breakaway.
|
|
43
31
|
*/
|
|
44
|
-
/** @param {{ env?: Record<string, string | undefined>, home?: string
|
|
45
|
-
export function configDir({ env = {}, home
|
|
32
|
+
/** @param {{ env?: Record<string, string | undefined>, home?: string }} options */
|
|
33
|
+
export function configDir({ env = {}, home }) {
|
|
46
34
|
if (env.BREAKAWAY_HOME) return env.BREAKAWAY_HOME.replace(/\/$/u, '');
|
|
47
|
-
|
|
48
|
-
const samewave = `${home}/.config/samewave`;
|
|
49
|
-
return !exists(breakaway) && exists(samewave) ? samewave : breakaway;
|
|
35
|
+
return `${home}/.config/breakaway`;
|
|
50
36
|
}
|
|
51
37
|
|
|
52
38
|
/** `path` as a Taskwarrior include line can name it: `~/…` under `home`, else as it is. */
|
|
@@ -59,8 +45,7 @@ export function taskrcUrl(text) {
|
|
|
59
45
|
}
|
|
60
46
|
|
|
61
47
|
/**
|
|
62
|
-
* The board's base URL, and where it came from: the environment or env file (BREAKAWAY_URL,
|
|
63
|
-
* SAMEWAVE_TASKS_URL), else the checkout's own .taskrc (`sync.server.url`, so the CLI and Taskwarrior agree),
|
|
48
|
+
* The board's base URL, and where it came from: the environment or env file (BREAKAWAY_URL), else the checkout's own .taskrc (`sync.server.url`, so the CLI and Taskwarrior agree),
|
|
64
49
|
* else the install's breakaway.config.json (`url`), else null: nothing says which board.
|
|
65
50
|
*/
|
|
66
51
|
export function boardUrl({ env = {}, file = {}, taskrc = null, config = null }) {
|
package/scripts/tasks.mjs
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
* Talks to the board's JSON API; claims there are atomic. See docs/tasks.md.
|
|
5
5
|
*
|
|
6
6
|
* Settings come from the environment, then tasks.env in this machine's folder for the board ($BREAKAWAY_HOME,
|
|
7
|
-
* else ~/.config/breakaway
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* BREAKAWAY_CLIENT_ID SAMEWAVE_TASKS_CLIENT_ID for `setup` (Taskwarrior sync), with BREAKAWAY_SECRET
|
|
7
|
+
* else ~/.config/breakaway; scripts/tasks/settings.js):
|
|
8
|
+
* BREAKAWAY_TOKEN API token (or the cloud environment's API credential)
|
|
9
|
+
* BREAKAWAY_URL the board; default: this checkout's .taskrc (sync.server.url),
|
|
10
|
+
* then breakaway.config.json (tools/tasks/ in a copy)
|
|
11
|
+
* BREAKAWAY_AGENT your name on claims (default user@host)
|
|
12
|
+
* BREAKAWAY_REPO the repository to work in (default: the checkout's origin)
|
|
13
|
+
* BREAKAWAY_CLIENT_ID for `setup` (Taskwarrior sync), with BREAKAWAY_SECRET
|
|
15
14
|
*/
|
|
16
15
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
17
16
|
import { createPrivateKey, pbkdf2Sync, randomBytes, randomUUID } from 'node:crypto';
|
|
@@ -53,19 +52,10 @@ import {
|
|
|
53
52
|
unknownSubcommand,
|
|
54
53
|
} from './tasks/cli.js';
|
|
55
54
|
import { CLI_VERSION } from '../src/cli-version.js';
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
boardUrl,
|
|
59
|
-
configDir,
|
|
60
|
-
nameFor,
|
|
61
|
-
parseEnvFile,
|
|
62
|
-
readSetting,
|
|
63
|
-
taskrcFixes,
|
|
64
|
-
tildePath,
|
|
65
|
-
usesLegacyNames,
|
|
66
|
-
} from './tasks/settings.js';
|
|
55
|
+
import { parseInstall, secretName } from '../src/install.js';
|
|
56
|
+
import { NAMES, boardUrl, configDir, parseEnvFile, readSetting, taskrcFixes, tildePath } from './tasks/settings.js';
|
|
67
57
|
|
|
68
|
-
const CONFIG_DIR = configDir({ env: process.env, home: homedir()
|
|
58
|
+
const CONFIG_DIR = configDir({ env: process.env, home: homedir() });
|
|
69
59
|
const ENV_FILE = join(CONFIG_DIR, 'tasks.env');
|
|
70
60
|
// Every other repository's routine, as agents-connect --repo wrote it to the Secrets Store: the owner's copy,
|
|
71
61
|
// so connecting one more merges instead of dropping the others (the Secrets Store never gives a value back).
|
|
@@ -100,12 +90,12 @@ function sharedFile(name) {
|
|
|
100
90
|
const CONFIG_FILE = sharedFile('breakaway.config.json');
|
|
101
91
|
|
|
102
92
|
/**
|
|
103
|
-
* The board's install, from breakaway.config.json: its Secrets Store and the prefix of its secrets'
|
|
104
|
-
*
|
|
105
|
-
*
|
|
93
|
+
* The board's install, from breakaway.config.json: its Secrets Store and the prefix of its secrets' names. Read only by
|
|
94
|
+
* the owner's commands that write secrets; a checkout without the file gets a new install's defaults, as the package
|
|
95
|
+
* does (BRK-77 retired the first install's names).
|
|
106
96
|
*/
|
|
107
97
|
function installConfig() {
|
|
108
|
-
if (!existsSync(CONFIG_FILE)) return {
|
|
98
|
+
if (!existsSync(CONFIG_FILE)) return parseInstall({});
|
|
109
99
|
try {
|
|
110
100
|
return parseInstall(JSON.parse(readFileSync(CONFIG_FILE, 'utf8')));
|
|
111
101
|
} catch (error) {
|
|
@@ -251,7 +241,7 @@ Everywhere: --json for machine-readable output, --as <name> to claim as someone
|
|
|
251
241
|
Projects: ideas and routines are the board's; repos lists each repository's areas.
|
|
252
242
|
|
|
253
243
|
Settings: BREAKAWAY_TOKEN, BREAKAWAY_URL, BREAKAWAY_AGENT, BREAKAWAY_REPO, from the environment or tasks.env in
|
|
254
|
-
$BREAKAWAY_HOME (default ~/.config/breakaway)
|
|
244
|
+
$BREAKAWAY_HOME (default ~/.config/breakaway).
|
|
255
245
|
Without BREAKAWAY_URL the board is this checkout's .taskrc sync.server.url. See docs/tasks.md#another-install.`;
|
|
256
246
|
|
|
257
247
|
// ---- settings ----------------------------------------------------------------------------
|
|
@@ -261,7 +251,7 @@ function readEnvFile() {
|
|
|
261
251
|
}
|
|
262
252
|
|
|
263
253
|
const fileEnv = readEnvFile();
|
|
264
|
-
/** A setting by its key in NAMES (scripts/tasks/settings.js): `TOKEN` reads BREAKAWAY_TOKEN
|
|
254
|
+
/** A setting by its key in NAMES (scripts/tasks/settings.js): `TOKEN` reads BREAKAWAY_TOKEN. */
|
|
265
255
|
const setting = (key, fallback) => readSetting(key, { env: process.env, file: fileEnv }, fallback);
|
|
266
256
|
const BOARD = boardUrl({
|
|
267
257
|
env: process.env,
|
|
@@ -271,9 +261,8 @@ const BOARD = boardUrl({
|
|
|
271
261
|
});
|
|
272
262
|
/** The board's address, or null when nothing says which board (the commands then ask for one). */
|
|
273
263
|
const BASE = BOARD.url;
|
|
274
|
-
/**
|
|
275
|
-
const
|
|
276
|
-
const envName = (key) => nameFor(key, LEGACY_ENV);
|
|
264
|
+
/** A setting's name in tasks.env: `TOKEN` is BREAKAWAY_TOKEN. */
|
|
265
|
+
const envName = (key) => NAMES[key];
|
|
277
266
|
// In a cloud session, go through its proxy: that's where the board's API credential is added.
|
|
278
267
|
routeThroughSessionProxy();
|
|
279
268
|
|
|
@@ -442,7 +431,7 @@ const enc = encodeURIComponent;
|
|
|
442
431
|
|
|
443
432
|
let repoContext;
|
|
444
433
|
/**
|
|
445
|
-
* {slug, registry}: the repository this checkout works in, from --repo,
|
|
434
|
+
* {slug, registry}: the repository this checkout works in, from --repo, BREAKAWAY_REPO, or the
|
|
446
435
|
* origin remote. slug is null on a board without repositories or in a checkout it doesn't know, and
|
|
447
436
|
* then everything works as before.
|
|
448
437
|
*/
|
|
@@ -1415,7 +1404,7 @@ const commands = {
|
|
|
1415
1404
|
/**
|
|
1416
1405
|
* Owner, once per repository: its routine's /fire URL and token (asked for here, never on a command line).
|
|
1417
1406
|
* The default repository's go in its two secrets, as always; `--repo <slug>` puts another's in
|
|
1418
|
-
* the install's ROUTINES secret (
|
|
1407
|
+
* the install's ROUTINES secret (BREAKAWAY_ROUTINES with breakaway's prefix), JSON keyed by slug, merged with this machine's copy of the others.
|
|
1419
1408
|
*/
|
|
1420
1409
|
async 'agents-connect'() {
|
|
1421
1410
|
const { repos, default: fallback } = await call('GET', 'repos');
|
|
@@ -1896,7 +1885,7 @@ function promoteEnvFile() {
|
|
|
1896
1885
|
|
|
1897
1886
|
/**
|
|
1898
1887
|
* Pipes each value into `wrangler secrets-store secret update` (never on a command line). `values` are keyed by
|
|
1899
|
-
* the part of the secret's name after the install's prefix (`API_TOKEN` is
|
|
1888
|
+
* the part of the secret's name after the install's prefix (`API_TOKEN` is BREAKAWAY_API_TOKEN with breakaway's prefix).
|
|
1900
1889
|
* An install without a Secrets Store (the Deploy to Cloudflare button's, CLD-139) keeps them as Worker secrets.
|
|
1901
1890
|
*/
|
|
1902
1891
|
function updateSecretsStore(values) {
|
package/src/cli-version.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* The version of the board's CLI and the files repos init copies with it (CLD-193). The board reports it
|
|
3
3
|
* (every API answer's X-Tasks-Cli header, and health), so a copy in another repository can say it's older
|
|
4
4
|
* and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
|
|
5
|
-
* bump CLI_VERSION and set CLI_FINGERPRINT to the value it prints. Constants only: the board's Worker imports it,
|
|
6
5
|
* so it lives in the board's package (CLD-135) and the CLI imports it from here.
|
|
7
6
|
*/
|
|
8
|
-
export const CLI_VERSION =
|
|
9
|
-
export const CLI_FINGERPRINT = '
|
|
7
|
+
export const CLI_VERSION = 47;
|
|
8
|
+
export const CLI_FINGERPRINT = 'e779f7d4ae3104e4';
|
package/src/install.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
13
13
|
name: 'breakaway',
|
|
14
14
|
worker: 'breakaway',
|
|
15
15
|
url: null,
|
|
16
|
+
aliases: [],
|
|
16
17
|
secretsPrefix: 'BREAKAWAY_',
|
|
17
18
|
secretsStore: null,
|
|
18
19
|
store: 'breakaway',
|
|
@@ -72,6 +73,26 @@ function origin(url) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The other addresses a board answers on beside its url, as https origins (BRK-78): while it moves to a new address,
|
|
78
|
+
* the old one stays an alias until nothing uses it. Each is a custom domain of the Worker, like the url.
|
|
79
|
+
*/
|
|
80
|
+
function aliasesOf(aliases, url) {
|
|
81
|
+
if (!Array.isArray(aliases))
|
|
82
|
+
throw new ConfigError('aliases is a list of https origins, like ["https://old.example.com"]');
|
|
83
|
+
if (aliases.length && url === null)
|
|
84
|
+
throw new ConfigError('aliases need a url: they are the other addresses the board answers on beside it');
|
|
85
|
+
const out = aliases.map((a) => {
|
|
86
|
+
const o = origin(a);
|
|
87
|
+
if (!o) throw new ConfigError(`aliases: ${a} isn't an https origin, like https://old.example.com`);
|
|
88
|
+
if (o === url) throw new ConfigError(`aliases: ${o} is already the url`);
|
|
89
|
+
return o;
|
|
90
|
+
});
|
|
91
|
+
const twice = out.find((o, i) => out.indexOf(o) !== i);
|
|
92
|
+
if (twice) throw new ConfigError(`aliases: ${twice} is there twice`);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/** A breakaway.config.json's contents, checked and with its defaults filled in. Throws ConfigError naming what's wrong. */
|
|
76
97
|
export function parseInstall(raw) {
|
|
77
98
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('the config is a JSON object');
|
|
@@ -88,6 +109,7 @@ export function parseInstall(raw) {
|
|
|
88
109
|
'url is where the board answers, an https origin like https://tasks.example.com, or null for workers.dev',
|
|
89
110
|
);
|
|
90
111
|
c.url = c.url === null ? null : origin(c.url);
|
|
112
|
+
c.aliases = aliasesOf(c.aliases, c.url);
|
|
91
113
|
if (!PREFIX.test(c.secretsPrefix))
|
|
92
114
|
throw new ConfigError('secretsPrefix is uppercase and ends with _, like BREAKAWAY_');
|
|
93
115
|
if (c.secretsStore !== null && !STORE_ID.test(c.secretsStore))
|
|
@@ -147,7 +169,7 @@ export const secretName = (inst, key) => `${inst.secretsPrefix}${key}`;
|
|
|
147
169
|
export const docsLink = (inst, anchor) => (inst.docs ? `${inst.docs}${anchor ? `#${anchor}` : ''}` : null);
|
|
148
170
|
|
|
149
171
|
/** The routes `run_worker_first` sends to the Worker; everything else is the web app. */
|
|
150
|
-
const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/login', '/logout'];
|
|
172
|
+
export const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/login', '/logout'];
|
|
151
173
|
|
|
152
174
|
/**
|
|
153
175
|
* The Worker's wrangler config for an install. `local` is for `wrangler dev` (interop): no custom
|
|
@@ -179,7 +201,9 @@ export function wranglerConfig(config, { local = false, root = '.' } = {}) {
|
|
|
179
201
|
name: c.worker,
|
|
180
202
|
main: at('src/worker.js'),
|
|
181
203
|
compatibility_date: COMPATIBILITY_DATE,
|
|
182
|
-
...(local || !c.url
|
|
204
|
+
...(local || !c.url
|
|
205
|
+
? {}
|
|
206
|
+
: { routes: [c.url, ...c.aliases].map((u) => ({ pattern: new URL(u).host, custom_domain: true })) }),
|
|
183
207
|
workers_dev: !local && !c.url,
|
|
184
208
|
preview_urls: false,
|
|
185
209
|
...(local
|
package/src/redact.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
const PATTERNS = [
|
|
7
7
|
// Private keys, whole blocks.
|
|
8
8
|
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, '[redacted key]'],
|
|
9
|
-
//
|
|
9
|
+
// Settings whose name says they're secret, KEY=value, under any prefix: breakaway's, an install's own (it picks its
|
|
10
|
+
// secrets' prefix), Cloudflare's, GitHub's, and the rest (BRK-77).
|
|
10
11
|
[
|
|
11
|
-
/\b(
|
|
12
|
+
/\b([A-Z][A-Z0-9]*_[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD)[A-Z0-9_]*\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gu,
|
|
12
13
|
'$1[redacted]',
|
|
13
14
|
],
|
|
14
15
|
// Anthropic, GitHub, Slack-style tokens.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# Deploys a breakaway release to this install's Worker: the release breakaway.json pins (stable), or the latest
|
|
2
2
|
# pre-release (main). Runs on a push to main, which is how a merged update pull request deploys, and by hand.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# A deploy uploads a version of the Worker. A change a version can't carry (the address, cron triggers, or a new
|
|
4
|
+
# Durable Object class) needs wrangler deploy: Deploy runs it when the repository variable BREAKAWAY_DEPLOY_CHANGES is
|
|
5
|
+
# true, for a token that can (the README says which), and otherwise stops with a plain message, as it does for a release
|
|
6
|
+
# that needs steps by hand. It never changes the Worker's name or its Durable Object, which would open an empty board.
|
|
5
7
|
name: Deploy
|
|
6
8
|
|
|
7
9
|
on:
|
|
@@ -40,6 +42,8 @@ jobs:
|
|
|
40
42
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
41
43
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
42
44
|
DRY_RUN: ${{ inputs.dry-run }}
|
|
45
|
+
# true lets Deploy run wrangler deploy for an address, cron triggers, or a new Durable Object class (BRK-62).
|
|
46
|
+
DEPLOY_CHANGES: ${{ vars.BREAKAWAY_DEPLOY_CHANGES }}
|
|
43
47
|
steps:
|
|
44
48
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
45
49
|
with:
|
|
@@ -91,6 +95,7 @@ jobs:
|
|
|
91
95
|
(cd release && sha256sum --check SHA256SUMS)
|
|
92
96
|
|
|
93
97
|
- name: Check it can deploy by itself
|
|
98
|
+
id: check
|
|
94
99
|
env:
|
|
95
100
|
CLI: ${{ steps.release.outputs.cli }}
|
|
96
101
|
VERSION: ${{ steps.release.outputs.version }}
|
|
@@ -98,15 +103,34 @@ jobs:
|
|
|
98
103
|
ADDRESS: ${{ vars.BREAKAWAY_URL }}
|
|
99
104
|
run: |
|
|
100
105
|
set -euo pipefail
|
|
101
|
-
#
|
|
102
|
-
|
|
106
|
+
# The config as it was before this push, to see whether it changed what a version upload can't carry.
|
|
107
|
+
rm -f before.json running.wrangler.json
|
|
108
|
+
case "$BEFORE" in ''|0000000000000000000000000000000000000000) ;; *) git show "$BEFORE:breakaway.config.json" > before.json 2>/dev/null || rm -f before.json ;; esac
|
|
109
|
+
was=breakaway.config.json
|
|
110
|
+
if [ -f before.json ]; then was=before.json; fi
|
|
111
|
+
# Where the running board answers: the address from before this push, if the push changes it.
|
|
112
|
+
address=${ADDRESS:-$(node -p "require('./$was').url || ''")}
|
|
103
113
|
running=""
|
|
104
|
-
# A board that doesn't say its release (one from before BRK-10) runs none this check knows.
|
|
114
|
+
# A board that doesn't say its release (one from before BRK-10) runs none this check knows. Nothing is deployed
|
|
115
|
+
# yet on the first run, so there is no running release to compare with.
|
|
105
116
|
if [ -n "$address" ]; then running=$(curl -fsS --max-time 20 "$address/api/ping" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).release ?? ''" 2>/dev/null || true); fi
|
|
106
|
-
# The
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
# The Worker as it runs: the config the running release's own CLI makes from the config it was deployed with, so
|
|
118
|
+
# a release that changes the cron triggers or the Durable Object classes shows as a change too. Without it (the
|
|
119
|
+
# same release, or one that can't be read), the check compares the configs with this release's code.
|
|
120
|
+
if [ -n "$running" ] && [ "$running" != "$VERSION" ]; then
|
|
121
|
+
dir="$RUNNER_TEMP/running"
|
|
122
|
+
mkdir -p "$dir/src"
|
|
123
|
+
if gh release download "v$running" --repo "$BREAKAWAY_REPO" --archive tar.gz -O - 2>/dev/null | tar -xz -C "$dir/src" --strip-components 1 2>/dev/null; then
|
|
124
|
+
cp "$was" "$dir/breakaway.config.json"
|
|
125
|
+
(cd "$dir" && node src/scripts/tasks.mjs install config --out "$GITHUB_WORKSPACE/running.wrangler.json" > /dev/null 2>&1) || rm -f running.wrangler.json
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
status=0
|
|
129
|
+
node "$CLI" install check --version "$VERSION" --manifest release/manifest.json --running "$running" \
|
|
130
|
+
--before before.json --running-config running.wrangler.json --deploy-changes "${DEPLOY_CHANGES:-false}" > check.out || status=$?
|
|
131
|
+
cat check.out
|
|
132
|
+
if [ "$status" -ne 0 ]; then exit "$status"; fi
|
|
133
|
+
cat check.out >> "$GITHUB_OUTPUT"
|
|
110
134
|
|
|
111
135
|
- name: Make the Worker config
|
|
112
136
|
env:
|
|
@@ -127,7 +151,15 @@ jobs:
|
|
|
127
151
|
|
|
128
152
|
- name: Dry run
|
|
129
153
|
if: ${{ inputs.dry-run }}
|
|
130
|
-
|
|
154
|
+
env:
|
|
155
|
+
DEPLOY: ${{ steps.check.outputs.deploy }}
|
|
156
|
+
CHANGES: ${{ steps.check.outputs.changes }}
|
|
157
|
+
run: |
|
|
158
|
+
set -euo pipefail
|
|
159
|
+
npx --yes wrangler@4 deploy --dry-run --outdir "$RUNNER_TEMP/dry" -c wrangler.generated.json
|
|
160
|
+
if [ "$DEPLOY" != wrangler ]; then echo "A deploy would upload a version."
|
|
161
|
+
elif [ -n "$CHANGES" ]; then echo "A deploy would run wrangler deploy for $CHANGES."
|
|
162
|
+
else echo "A deploy would run wrangler deploy, the release's manual step."; fi
|
|
131
163
|
|
|
132
164
|
- name: Upload and deploy
|
|
133
165
|
if: ${{ !inputs.dry-run }}
|
|
@@ -135,6 +167,9 @@ jobs:
|
|
|
135
167
|
CLI: ${{ steps.release.outputs.cli }}
|
|
136
168
|
VERSION: ${{ steps.release.outputs.version }}
|
|
137
169
|
ADDRESS: ${{ vars.BREAKAWAY_URL }}
|
|
170
|
+
DEPLOY: ${{ steps.check.outputs.deploy }}
|
|
171
|
+
CHANGES: ${{ steps.check.outputs.changes }}
|
|
172
|
+
ADDRESS_CHANGED: ${{ steps.check.outputs.address_changed }}
|
|
138
173
|
run: |
|
|
139
174
|
set -euo pipefail
|
|
140
175
|
wrangler() { npx --yes wrangler@4 "$@" -c wrangler.generated.json; }
|
|
@@ -150,6 +185,11 @@ jobs:
|
|
|
150
185
|
fi
|
|
151
186
|
if [ -z "$previous" ]; then
|
|
152
187
|
wrangler deploy --var "BREAKAWAY_VERSION:$VERSION"
|
|
188
|
+
elif [ "$DEPLOY" = wrangler ]; then
|
|
189
|
+
# What a version can't carry (BRK-62): wrangler deploy applies the address, cron triggers, and new Durable
|
|
190
|
+
# Object classes with the code, and the Worker can still go back to $previous unless a class was added.
|
|
191
|
+
if [ -n "$CHANGES" ]; then echo "This deploy changes $CHANGES, so it runs wrangler deploy."; else echo "breakaway $VERSION's manual step is wrangler deploy, so this deploy runs it."; fi
|
|
192
|
+
wrangler deploy --var "BREAKAWAY_VERSION:$VERSION" --message "breakaway $VERSION"
|
|
153
193
|
else
|
|
154
194
|
export WRANGLER_OUTPUT_FILE_PATH="$RUNNER_TEMP/wrangler-output.ndjson"
|
|
155
195
|
wrangler versions upload --var "BREAKAWAY_VERSION:$VERSION" --message "breakaway $VERSION"
|
|
@@ -157,22 +197,33 @@ jobs:
|
|
|
157
197
|
wrangler versions deploy "$uploaded@100%" --yes
|
|
158
198
|
fi
|
|
159
199
|
# Health check: the new release answers /api/ping. Without it, go back to the previous version.
|
|
160
|
-
|
|
200
|
+
url=$(node -p "require('./breakaway.config.json').url || ''")
|
|
201
|
+
address=${ADDRESS:-$url}
|
|
202
|
+
tries=12
|
|
203
|
+
if [ "$ADDRESS_CHANGED" = true ]; then
|
|
204
|
+
# A new address: check it, not the old one, and give its custom domain five minutes for its certificate.
|
|
205
|
+
address=${url:-$ADDRESS}
|
|
206
|
+
tries=60
|
|
207
|
+
fi
|
|
161
208
|
if [ -z "$address" ]; then
|
|
162
209
|
echo "::warning title=Not checked::There is no address to check. Set the repository variable BREAKAWAY_URL to where the board answers (a workers.dev address, say) and the next deploy checks it."
|
|
163
210
|
exit 0
|
|
164
211
|
fi
|
|
165
|
-
for attempt in $(seq 1
|
|
212
|
+
for attempt in $(seq 1 "$tries"); do
|
|
166
213
|
if curl -fsS --max-time 15 "$address/api/ping" | node "$CLI" install healthy --version "$VERSION"; then
|
|
167
214
|
echo "breakaway $VERSION is running at $address."
|
|
168
215
|
exit 0
|
|
169
216
|
fi
|
|
170
217
|
sleep 5
|
|
171
218
|
done
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
echo "::error title=
|
|
219
|
+
waited=$([ "$tries" -gt 12 ] && echo "five minutes" || echo "a minute")
|
|
220
|
+
if [ -z "$previous" ]; then
|
|
221
|
+
echo "::error title=Failed its check::breakaway $VERSION didn't answer $address/api/ping within $waited, and this was the first deploy, so there is no version to go back to. Read the Worker's logs on Cloudflare."
|
|
222
|
+
elif wrangler rollback "$previous" --message "breakaway $VERSION failed its check" --yes; then
|
|
223
|
+
kept=""
|
|
224
|
+
if [ "$DEPLOY" = wrangler ]; then kept=" Rolling back brings back the code only: what wrangler deploy changed${CHANGES:+ ($CHANGES)} stays as it left it."; fi
|
|
225
|
+
echo "::error title=Rolled back::breakaway $VERSION didn't answer $address/api/ping within $waited, so the Worker went back to its previous version.$kept Nothing is lost; read the Worker's logs on Cloudflare."
|
|
175
226
|
else
|
|
176
|
-
echo "::error title=
|
|
227
|
+
echo "::error title=Couldn't roll back::breakaway $VERSION didn't answer $address/api/ping within $waited, and the Worker couldn't go back to its previous version: Cloudflare doesn't roll back across a new Durable Object class. The board's data is still in its Durable Object. Read the Worker's logs on Cloudflare, then deploy a fix."
|
|
177
228
|
fi
|
|
178
229
|
exit 1
|
package/template/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The board answers at {{address}}.
|
|
|
17
17
|
## Set it up once
|
|
18
18
|
|
|
19
19
|
1. **Push this to a private GitHub repository.**
|
|
20
|
-
2. **Make a GitHub environment named `production`** (Settings, Environments) with two secrets: `CLOUDFLARE_API_TOKEN`, a
|
|
20
|
+
2. **Make a GitHub environment named `production`** (Settings, Environments) with two secrets: `CLOUDFLARE_API_TOKEN`, a Cloudflare API token ([which token does what](#which-token-does-what)), and `CLOUDFLARE_ACCOUNT_ID`.
|
|
21
21
|
3. **Allow the update workflow to open pull requests:** Settings, Actions, General, "Allow GitHub Actions to create and approve pull requests".
|
|
22
22
|
4. **Set the board's secrets** (`npx breakaway init-secrets`, then the Worker's secrets or your Secrets Store: see `.dev.vars.example`).
|
|
23
23
|
5. **Run the Deploy workflow** from the Actions tab. Choose "dry-run" first to check everything up to the Worker without changing it.
|
|
@@ -32,8 +32,28 @@ Both workflows run breakaway's CLI from the release's own source on GitHub, with
|
|
|
32
32
|
|
|
33
33
|
If Deploy stops with "Couldn't list the Worker's deployments", it changed nothing: only a Worker that doesn't exist yet counts as a first deploy. Check that `CLOUDFLARE_ACCOUNT_ID` is your account's ID (32 hex characters) and that `CLOUDFLARE_API_TOKEN` can read and edit Workers on it, then run Deploy again.
|
|
34
34
|
|
|
35
|
-
A release can need steps by hand (a
|
|
35
|
+
A release can need steps by hand (a Durable Object class deleted or renamed, say). The workflow stops with those steps in its message and deploys nothing; do them, then deploy with `wrangler`. A release whose only step is `wrangler deploy` says so in its notes, and Deploy runs it itself when it may ([below](#which-token-does-what)). The same goes for a new address in `breakaway.config.json`, which a version upload can't carry: without that, apply it yourself (`npx breakaway install config` makes the Worker config), then run Deploy again.
|
|
36
|
+
|
|
37
|
+
## Which token does what
|
|
38
|
+
|
|
39
|
+
`CLOUDFLARE_API_TOKEN` decides what Deploy can do by itself. Most deploys only upload a new version of the Worker. A deploy that changes the address, the cron triggers, or the Durable Object classes needs `wrangler deploy`, and so a token that can run it:
|
|
40
|
+
|
|
41
|
+
| Token | What Deploy does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| **Workers Editor on this Worker** | Uploads each release, checks it, and goes back if it fails. It stops on an address, cron, or Durable Object class change, and you apply that yourself. |
|
|
44
|
+
| **Workers Editor on every Worker**, and **Zone, Workers Routes, Write** on the board's zone | The same, and with the repository variable `BREAKAWAY_DEPLOY_CHANGES` set to `true`, it runs `wrangler deploy` for those changes too. Custom domains don't support per-Worker roles yet, so Editor has to cover every Worker. |
|
|
45
|
+
|
|
46
|
+
Either token also needs **Account, Secrets Store, Edit** when the board has a Secrets Store: every deploy binds its secrets. The very first deploy makes the Worker, which takes **Workers Admin**; after that, Editor is enough.
|
|
47
|
+
|
|
48
|
+
Whatever the token, Deploy never changes the Worker's name, its Durable Object (`store`), or its jurisdiction: each opens an empty board, so it stops instead.
|
|
49
|
+
|
|
50
|
+
With `wrangler deploy`:
|
|
51
|
+
|
|
52
|
+
- **A new address replaces the old one.** The Worker answers only on the addresses in its config, so the old one stops answering, and in a workflow `wrangler deploy` takes over the new hostname's DNS record, or another Worker's domain on it, without asking. Moving to another zone needs Workers Routes, Write on both zones. The check after the deploy asks the new address, for up to five minutes while its certificate is issued. If you set `BREAKAWAY_URL`, change it with the address.
|
|
53
|
+
- **Going back brings back the code only.** The address and cron triggers stay as `wrangler deploy` left them, and Cloudflare doesn't roll back across a new Durable Object class, so after one the only way is forward.
|
|
36
54
|
|
|
37
55
|
## Change the install
|
|
38
56
|
|
|
39
57
|
Edit `breakaway.config.json` and merge it: Deploy runs on every push to `main`.
|
|
58
|
+
|
|
59
|
+
To move the board to a new address without a gap, add the new one under `aliases` first, so both answer; then swap `url` and the alias; and remove the alias once nothing uses the old address. breakaway's [docs/tasks.md](https://github.com/TheAnarchoX/breakaway/blob/main/docs/tasks.md#moving-to-a-new-address) has the whole list of what names the address.
|