breakaway 1.4.0-main.2 → 1.4.0-main.21
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 +4 -1
- package/scripts/board-files.mjs +18 -0
- 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 +3 -562
- package/scripts/tasks/pipeline.js +747 -0
- package/scripts/tasks.mjs +73 -8
- package/src/cli-version.js +2 -2
- package/src/init.js +590 -0
- package/src/packages.js +56 -0
- package/src/prompt.js +1 -1
- 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/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
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# {{header}}
|
|
2
|
+
#
|
|
3
|
+
# Promote: puts the staging build the owner chose live on production, the Worker {{productionName}}. It deploys the
|
|
4
|
+
# files Deploy built and kept for that commit, and builds nothing. The board's Promote button starts it, or Actions,
|
|
5
|
+
# Run workflow, with the commit staging runs. Agents never run this workflow.
|
|
6
|
+
name: Promote
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
inputs:
|
|
11
|
+
sha:
|
|
12
|
+
description: The full commit of the staging build to put live
|
|
13
|
+
required: true
|
|
14
|
+
type: string
|
|
15
|
+
destructive_ok:
|
|
16
|
+
description: I've read the destructive migrations it carries, and they may run
|
|
17
|
+
required: false
|
|
18
|
+
default: false
|
|
19
|
+
type: boolean
|
|
20
|
+
|
|
21
|
+
permissions: {}
|
|
22
|
+
|
|
23
|
+
# Production deploys one at a time, and a rollback waits for a promote (and the other way round).
|
|
24
|
+
concurrency:
|
|
25
|
+
group: deploy-production
|
|
26
|
+
cancel-in-progress: false
|
|
27
|
+
|
|
28
|
+
env:
|
|
29
|
+
STAGING: {{staging}}
|
|
30
|
+
PRODUCTION: {{production}}
|
|
31
|
+
BRANCH: {{branch}}
|
|
32
|
+
HEALTH_URL: {{healthProduction}}
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
production:
|
|
36
|
+
name: production
|
|
37
|
+
if: github.ref == 'refs/heads/{{branchName}}'
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
timeout-minutes: 30
|
|
40
|
+
# CLOUDFLARE_API_TOKEN is a secret of this environment, which only {{branchName}} may use: a token for the
|
|
41
|
+
# production Worker only.
|
|
42
|
+
environment: production
|
|
43
|
+
permissions:
|
|
44
|
+
contents: write
|
|
45
|
+
deployments: write
|
|
46
|
+
actions: read
|
|
47
|
+
pull-requests: read
|
|
48
|
+
env:
|
|
49
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
50
|
+
GH_TOKEN: ${{ github.token }}
|
|
51
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
52
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
53
|
+
DEPLOYS_PAUSED: ${{ vars.DEPLOYS_PAUSED }}
|
|
54
|
+
SHA: ${{ inputs.sha }}
|
|
55
|
+
DESTRUCTIVE_OK: ${{ inputs.destructive_ok }}
|
|
56
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
57
|
+
BREAKAWAY_ENV: production
|
|
58
|
+
WORKER: {{production}}
|
|
59
|
+
steps:
|
|
60
|
+
# The helpers run from {{branchName}}; the build comes from the artifact Deploy kept.
|
|
61
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
62
|
+
with:
|
|
63
|
+
fetch-depth: 0
|
|
64
|
+
persist-credentials: false
|
|
65
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
66
|
+
with:
|
|
67
|
+
node-version: 22
|
|
68
|
+
|
|
69
|
+
- name: Fetch the staging build
|
|
70
|
+
id: artifact
|
|
71
|
+
run: |
|
|
72
|
+
set -euo pipefail
|
|
73
|
+
if ! [[ "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
|
74
|
+
echo "::error title=Not a commit::Give the full commit of the staging build to promote (40 characters)."
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
id=$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=release-$SHA&per_page=1" --jq '[.artifacts[] | select(.expired | not)][0].id // empty')
|
|
78
|
+
if [ -z "$id" ]; then
|
|
79
|
+
echo "::error title=No stored build::Deploy kept no build of ${SHA:0:7}, or it expired after 90 days, so there is nothing to promote. Merge to $BRANCH to make a new candidate."
|
|
80
|
+
exit 1
|
|
81
|
+
fi
|
|
82
|
+
mkdir -p "$RUNNER_TEMP/release"
|
|
83
|
+
gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" > "$RUNNER_TEMP/release.zip"
|
|
84
|
+
unzip -q "$RUNNER_TEMP/release.zip" -d "$RUNNER_TEMP/release"
|
|
85
|
+
echo "digest=$(node scripts/release-artifact.mjs digest "$RUNNER_TEMP/release")" >> "$GITHUB_OUTPUT"
|
|
86
|
+
|
|
87
|
+
# The same refusals as the board's Promote button, checked again here: not paused, the latest successful staging
|
|
88
|
+
# build, on {{branchName}}, not live already, and the stored build is the one staging ran.
|
|
89
|
+
- name: Check the candidate
|
|
90
|
+
id: check
|
|
91
|
+
env:
|
|
92
|
+
DIGEST: ${{ steps.artifact.outputs.digest }}
|
|
93
|
+
run: node scripts/promote-check.mjs --staging "$STAGING" --production "$PRODUCTION" --sha "$SHA" --artifact-digest "$DIGEST" --branch "$BRANCH"
|
|
94
|
+
|
|
95
|
+
- name: Check its migrations
|
|
96
|
+
id: migrations
|
|
97
|
+
run: |
|
|
98
|
+
set -euo pipefail
|
|
99
|
+
live=$(node scripts/deploy-plan.mjs live --environment "$PRODUCTION" | sed 's/^sha=//')
|
|
100
|
+
echo "live=$live" >> "$GITHUB_OUTPUT"
|
|
101
|
+
if [ -n "$live" ]; then
|
|
102
|
+
added=$(git diff --name-only --diff-filter=A "$live" "$SHA" -- migrations | grep '\.sql$' || true)
|
|
103
|
+
else
|
|
104
|
+
added=$(git ls-tree -r --name-only "$SHA" -- migrations | grep '\.sql$' || true)
|
|
105
|
+
fi
|
|
106
|
+
echo "migrations=$(echo "$added" | xargs -r -n1 basename | paste -sd, -)" >> "$GITHUB_OUTPUT"
|
|
107
|
+
# A migration that destroys data carries an owner-approved line, and runs only when the owner ticked it.
|
|
108
|
+
destructive=""
|
|
109
|
+
for file in $added; do
|
|
110
|
+
if git show "$SHA:$file" | grep -qE '^[[:space:]]*--[[:space:]]*owner-approved:[[:space:]]*[^[:space:]]'; then destructive="$destructive $(basename "$file")"; fi
|
|
111
|
+
done
|
|
112
|
+
if [ -n "$destructive" ] && [ "$DESTRUCTIVE_OK" != true ]; then
|
|
113
|
+
echo "::error title=Destructive migration::${SHA:0:7} carries a migration that destroys data:$destructive. Read it, then promote again with destructive_ok."
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
- name: Record the start
|
|
118
|
+
id: record
|
|
119
|
+
run: |
|
|
120
|
+
node scripts/record-deployment.mjs --environment "$PRODUCTION" --sha "$SHA" --production --state in_progress --description "promoting: starting" --log-url "$RUN_URL"
|
|
121
|
+
|
|
122
|
+
- name: Deploy to production
|
|
123
|
+
id: deploy
|
|
124
|
+
env:
|
|
125
|
+
DEPLOYMENT: ${{ steps.record.outputs.deployment }}
|
|
126
|
+
run: |
|
|
127
|
+
set -euo pipefail
|
|
128
|
+
helpers="$GITHUB_WORKSPACE/scripts"
|
|
129
|
+
step() { node "$helpers/record-deployment.mjs" --deployment "$DEPLOYMENT" --state in_progress --description "promoting: $1" > /dev/null; }
|
|
130
|
+
mkdir -p "$RUNNER_TEMP/app"
|
|
131
|
+
tar -xzf "$RUNNER_TEMP/release/tree.tgz" -C "$RUNNER_TEMP/app"
|
|
132
|
+
cd "$RUNNER_TEMP/app"
|
|
133
|
+
wrangler() { npx --yes wrangler "$@" --name "$WORKER"{{productionEnvFlag}}; }
|
|
134
|
+
previous=""
|
|
135
|
+
if wrangler deployments list --json > "$RUNNER_TEMP/deployments.json" 2> "$RUNNER_TEMP/list-error.txt"; then
|
|
136
|
+
previous=$(node "$helpers/deploy-plan.mjs" current < "$RUNNER_TEMP/deployments.json" | sed 's/^version=//')
|
|
137
|
+
else
|
|
138
|
+
cat "$RUNNER_TEMP/deployments.json" "$RUNNER_TEMP/list-error.txt" | node "$helpers/deploy-plan.mjs" missing > /dev/null
|
|
139
|
+
fi
|
|
140
|
+
step migrating
|
|
141
|
+
{{@beforeDeploy}}
|
|
142
|
+
step deploying
|
|
143
|
+
export WRANGLER_OUTPUT_FILE_PATH="$RUNNER_TEMP/wrangler-output.ndjson"
|
|
144
|
+
if [ -z "$previous" ]; then
|
|
145
|
+
wrangler deploy --message "${SHA:0:7}"
|
|
146
|
+
version=$(node "$helpers/deploy-plan.mjs" uploaded < "$WRANGLER_OUTPUT_FILE_PATH" | sed 's/^version=//')
|
|
147
|
+
else
|
|
148
|
+
wrangler versions upload --message "${SHA:0:7}"
|
|
149
|
+
version=$(node "$helpers/deploy-plan.mjs" uploaded < "$WRANGLER_OUTPUT_FILE_PATH" | sed 's/^version=//')
|
|
150
|
+
wrangler versions deploy "$version@100%" --message "${SHA:0:7}" --yes
|
|
151
|
+
fi
|
|
152
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
153
|
+
step checking
|
|
154
|
+
if [ -z "$HEALTH_URL" ]; then
|
|
155
|
+
echo "::notice title=Not checked::Production runs ${SHA:0:7} as version $version. Set healthCheck.production in .github/breakaway-pipeline.json and the next promote checks it, and goes back if it doesn't answer."
|
|
156
|
+
exit 0
|
|
157
|
+
fi
|
|
158
|
+
for attempt in $(seq 1 18); do
|
|
159
|
+
if curl -fsS --max-time 10 -o /dev/null "$HEALTH_URL"; then
|
|
160
|
+
echo "Production runs ${SHA:0:7} as version $version, and $HEALTH_URL answers."
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
sleep 5
|
|
164
|
+
done
|
|
165
|
+
if [ -n "$previous" ] && wrangler rollback "$previous" --message "${SHA:0:7} failed its check" --yes; then
|
|
166
|
+
echo "rolled_back=true" >> "$GITHUB_OUTPUT"
|
|
167
|
+
echo "::error title=Rolled back::${SHA:0:7} didn't answer $HEALTH_URL within 90 seconds, so production went back to version $previous. Read the Worker's logs on Cloudflare."
|
|
168
|
+
else
|
|
169
|
+
echo "::error title=Failed its check::${SHA:0:7} didn't answer $HEALTH_URL within 90 seconds, and production had no version to go back to. Read the Worker's logs on Cloudflare."
|
|
170
|
+
fi
|
|
171
|
+
exit 1
|
|
172
|
+
|
|
173
|
+
- name: Record the end
|
|
174
|
+
if: always() && steps.record.outputs.deployment
|
|
175
|
+
env:
|
|
176
|
+
DEPLOYMENT: ${{ steps.record.outputs.deployment }}
|
|
177
|
+
OUTCOME: ${{ job.status }}
|
|
178
|
+
VERSION: ${{ steps.deploy.outputs.version }}
|
|
179
|
+
DIGEST: ${{ steps.artifact.outputs.digest }}
|
|
180
|
+
MIGRATIONS: ${{ steps.migrations.outputs.migrations }}
|
|
181
|
+
ROLLED_BACK: ${{ steps.deploy.outputs.rolled_back }}
|
|
182
|
+
run: |
|
|
183
|
+
set -euo pipefail
|
|
184
|
+
record() { node scripts/record-deployment.mjs --deployment "$DEPLOYMENT" --log-url "$RUN_URL" "$@" > /dev/null; }
|
|
185
|
+
if [ "$OUTCOME" = success ]; then
|
|
186
|
+
record --state success --version "$VERSION" --artifact "$DIGEST" --migrations "$MIGRATIONS" ${HEALTH_URL:+--environment-url "$HEALTH_URL"}
|
|
187
|
+
elif [ "$ROLLED_BACK" = true ]; then
|
|
188
|
+
record --state failure --description "rolled back: version $VERSION failed its check"
|
|
189
|
+
else
|
|
190
|
+
record --state failure --description "failed: the run says why"
|
|
191
|
+
fi
|
|
192
|
+
|
|
193
|
+
# Production only: a tag and a GitHub release with every pull request since what production ran before.
|
|
194
|
+
- name: Tag and release
|
|
195
|
+
env:
|
|
196
|
+
LIVE: ${{ steps.migrations.outputs.live }}
|
|
197
|
+
VERSION: ${{ steps.deploy.outputs.version }}
|
|
198
|
+
run: |
|
|
199
|
+
set -euo pipefail
|
|
200
|
+
tag="v$(date -u +%Y%m%d)-${SHA:0:7}"
|
|
201
|
+
from=${LIVE:-$(git rev-list --max-parents=0 "$SHA" | tail -1)}
|
|
202
|
+
node scripts/release-notes.mjs --title "${GITHUB_REPOSITORY#*/}" --version "$tag" --from "$from" --to "$SHA" > "$RUNNER_TEMP/notes.md"
|
|
203
|
+
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --target "$SHA" --title "$tag" --notes-file "$RUNNER_TEMP/notes.md" --latest
|