breakaway 1.3.2 → 1.4.0-main.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/scripts/deploy-plan.mjs +135 -0
- package/scripts/lib/deploy-plan.js +102 -0
- package/scripts/lib/package-release.js +71 -0
- package/scripts/package-release.mjs +52 -0
- package/scripts/tasks/cli.js +132 -9
- package/scripts/tasks/init.js +6 -1
- package/scripts/tasks/pipeline.js +747 -0
- package/scripts/tasks.mjs +61 -4
- package/src/cli-version.js +2 -2
- package/src/packages.js +56 -0
- package/src/repos.js +28 -9
- package/src/specs.js +96 -0
- package/template/pipeline/deploy.yml +178 -0
- package/template/pipeline/promote.yml +203 -0
- package/template/pipeline/release.yml +212 -0
- package/template/pipeline/rollback.yml +98 -0
|
@@ -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
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# {{header}}
|
|
2
|
+
#
|
|
3
|
+
# Release: the npm package {{packageName}}. Every merge to {{branchName}} that passes its checks stages a pre-release,
|
|
4
|
+
# X.Y.Z-main.N, on next; the owner releases one as the stable X.Y.Z, on latest, from the same commit. Nothing is
|
|
5
|
+
# published by itself: each version waits, staged on npm, until the owner approves it with 2FA (npm stage approve, or
|
|
6
|
+
# Staged Packages on npmjs.com). The version comes from {{packageDirectory}}/package.json. Keep this file's name: npm's
|
|
7
|
+
# trusted publisher names it, with the npm environment. Agents never run this workflow.
|
|
8
|
+
name: Release
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
workflow_run:
|
|
12
|
+
workflows: {{checks}}
|
|
13
|
+
types: [completed]
|
|
14
|
+
branches: [{{branch}}]
|
|
15
|
+
# The owner releases a pre-release as stable: the board's Release button, or Actions, Run workflow.
|
|
16
|
+
workflow_dispatch:
|
|
17
|
+
inputs:
|
|
18
|
+
prerelease:
|
|
19
|
+
description: The pre-release to release as stable, like {{prefixName}}1.4.0-main.5
|
|
20
|
+
required: true
|
|
21
|
+
type: string
|
|
22
|
+
|
|
23
|
+
permissions: {}
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: release
|
|
27
|
+
cancel-in-progress: false
|
|
28
|
+
|
|
29
|
+
env:
|
|
30
|
+
PACKAGE: {{package}}
|
|
31
|
+
DIRECTORY: {{directory}}
|
|
32
|
+
ACCESS: {{access}}
|
|
33
|
+
# The tags that count the versions: <prefix>X.Y.Z-main.N for each pre-release, <prefix>X.Y.Z for each stable.
|
|
34
|
+
PREFIX: {{prefix}}
|
|
35
|
+
CHECKS: {{checksEnv}}
|
|
36
|
+
|
|
37
|
+
jobs:
|
|
38
|
+
prerelease:
|
|
39
|
+
name: pre-release
|
|
40
|
+
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 20
|
|
43
|
+
# The npm environment, which only {{branchName}} may use, is where npm's trusted publisher points, and holds
|
|
44
|
+
# NPM_TOKEN until that applies.
|
|
45
|
+
environment: npm
|
|
46
|
+
permissions:
|
|
47
|
+
contents: write
|
|
48
|
+
id-token: write
|
|
49
|
+
actions: read
|
|
50
|
+
env:
|
|
51
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
52
|
+
GH_TOKEN: ${{ github.token }}
|
|
53
|
+
SHA: ${{ github.event.workflow_run.head_sha }}
|
|
54
|
+
DEPLOYS_PAUSED: ${{ vars.DEPLOYS_PAUSED }}
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
57
|
+
with:
|
|
58
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
59
|
+
fetch-depth: 0
|
|
60
|
+
persist-credentials: false
|
|
61
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
62
|
+
with:
|
|
63
|
+
node-version: 22
|
|
64
|
+
registry-url: https://registry.npmjs.org
|
|
65
|
+
|
|
66
|
+
- name: Check it should release
|
|
67
|
+
id: plan
|
|
68
|
+
run: |
|
|
69
|
+
set -euo pipefail
|
|
70
|
+
if [ "$DEPLOYS_PAUSED" = true ]; then
|
|
71
|
+
echo "::notice title=Paused::Releases are paused (DEPLOYS_PAUSED is true), so ${SHA:0:7} staged nothing."
|
|
72
|
+
echo "ready=false" >> "$GITHUB_OUTPUT"
|
|
73
|
+
exit 0
|
|
74
|
+
fi
|
|
75
|
+
node scripts/deploy-plan.mjs checks --sha "$SHA" --checks "$CHECKS" >> "$GITHUB_OUTPUT"
|
|
76
|
+
|
|
77
|
+
- name: Pick the version
|
|
78
|
+
id: version
|
|
79
|
+
if: steps.plan.outputs.ready == 'true'
|
|
80
|
+
run: node scripts/package-release.mjs prerelease --dir "$DIRECTORY" --prefix "$PREFIX" --sha "$SHA" >> "$GITHUB_OUTPUT"
|
|
81
|
+
|
|
82
|
+
- name: Install and build
|
|
83
|
+
if: steps.plan.outputs.ready == 'true' && steps.version.outputs.exists == 'false'
|
|
84
|
+
env:
|
|
85
|
+
VERSION: ${{ steps.version.outputs.version }}
|
|
86
|
+
run: |
|
|
87
|
+
set -euo pipefail
|
|
88
|
+
if grep -q '"packageManager"' package.json 2>/dev/null; then corepack enable; fi
|
|
89
|
+
{{@install}}
|
|
90
|
+
{{@build}}
|
|
91
|
+
node -e "const f=require('path').join(process.env.DIRECTORY,'package.json'),p=JSON.parse(require('fs').readFileSync(f));p.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\n')"
|
|
92
|
+
|
|
93
|
+
# Staged, never published: it goes live on next once the owner approves it with 2FA. npm tries trusted publishing
|
|
94
|
+
# first; NPM_TOKEN stands in until it applies, a token that can't bypass 2FA, so it never publishes by itself.
|
|
95
|
+
- name: Stage it on npm
|
|
96
|
+
if: steps.plan.outputs.ready == 'true' && steps.version.outputs.exists == 'false'
|
|
97
|
+
env:
|
|
98
|
+
VERSION: ${{ steps.version.outputs.version }}
|
|
99
|
+
DIST_TAG: next
|
|
100
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
101
|
+
# npm refuses provenance from a private repository.
|
|
102
|
+
PROVENANCE: ${{ github.event.repository.private && 'no' || 'yes' }}
|
|
103
|
+
run: |
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
cd "$DIRECTORY"
|
|
106
|
+
npm install -g npm@^11.15.0
|
|
107
|
+
provenance=""
|
|
108
|
+
if [ "$PROVENANCE" = yes ]; then provenance=--provenance; fi
|
|
109
|
+
# npm says why trusted publishing didn't apply only in its verbose log. Keep that log to the runner and show
|
|
110
|
+
# npm's usual lines, and on a failure its oidc lines: never the whole verbose log.
|
|
111
|
+
if npm stage publish $provenance --access "$ACCESS" --tag "$DIST_TAG" --loglevel verbose > "$RUNNER_TEMP/npm.log" 2>&1; then
|
|
112
|
+
grep -vE '^npm (verbose|http|timing|silly)' "$RUNNER_TEMP/npm.log" || true
|
|
113
|
+
echo "::notice title=Staged on npm::$PACKAGE@$VERSION goes live on $DIST_TAG once the owner approves it with 2FA: npm stage approve <id>, or Staged Packages on npmjs.com."
|
|
114
|
+
else
|
|
115
|
+
grep -vE '^npm (verbose|http|timing|silly)' "$RUNNER_TEMP/npm.log" || true
|
|
116
|
+
if grep -qiE 'previously published|cannot publish over' "$RUNNER_TEMP/npm.log"; then
|
|
117
|
+
echo "::error title=Already on npm::$PACKAGE@$VERSION is already on npm, and npm takes a version once. Nothing was staged."
|
|
118
|
+
exit 1
|
|
119
|
+
fi
|
|
120
|
+
echo "npm's trusted publishing (OIDC):"
|
|
121
|
+
oidc=$(grep -i 'oidc' "$RUNNER_TEMP/npm.log" | grep -v 'eyJ' | cut -c1-300 || true)
|
|
122
|
+
echo "${oidc:-npm logged nothing about it.}"
|
|
123
|
+
echo "::error title=npm refused to stage it::npm's trusted publisher for $PACKAGE must name exactly: GitHub Actions, user ${GITHUB_REPOSITORY%%/*}, repository ${GITHUB_REPOSITORY#*/}, workflow release.yml, environment npm. Until it applies, the npm environment needs NPM_TOKEN: a granular token with read and write access to $PACKAGE that doesn't bypass 2FA."
|
|
124
|
+
exit 1
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
- name: Tag it
|
|
128
|
+
if: steps.plan.outputs.ready == 'true' && steps.version.outputs.exists == 'false'
|
|
129
|
+
env:
|
|
130
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
131
|
+
run: gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$SHA" > /dev/null
|
|
132
|
+
|
|
133
|
+
stable:
|
|
134
|
+
name: stable
|
|
135
|
+
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/{{branchName}}'
|
|
136
|
+
runs-on: ubuntu-latest
|
|
137
|
+
timeout-minutes: 20
|
|
138
|
+
environment: npm
|
|
139
|
+
permissions:
|
|
140
|
+
contents: write
|
|
141
|
+
id-token: write
|
|
142
|
+
env:
|
|
143
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
144
|
+
GH_TOKEN: ${{ github.token }}
|
|
145
|
+
PRERELEASE: ${{ inputs.prerelease }}
|
|
146
|
+
DEPLOYS_PAUSED: ${{ vars.DEPLOYS_PAUSED }}
|
|
147
|
+
steps:
|
|
148
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
149
|
+
with:
|
|
150
|
+
fetch-depth: 0
|
|
151
|
+
persist-credentials: false
|
|
152
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
153
|
+
with:
|
|
154
|
+
node-version: 22
|
|
155
|
+
registry-url: https://registry.npmjs.org
|
|
156
|
+
|
|
157
|
+
- name: Pick the stable
|
|
158
|
+
id: version
|
|
159
|
+
run: |
|
|
160
|
+
set -euo pipefail
|
|
161
|
+
if [ "$DEPLOYS_PAUSED" = true ]; then
|
|
162
|
+
echo "::error title=Paused::Releases are paused (DEPLOYS_PAUSED is true). Release again when the pause is over."
|
|
163
|
+
exit 1
|
|
164
|
+
fi
|
|
165
|
+
node scripts/package-release.mjs stable "$PRERELEASE" --prefix "$PREFIX" >> "$GITHUB_OUTPUT"
|
|
166
|
+
|
|
167
|
+
# The pre-release's own commit, under the stable version: nothing from a later commit.
|
|
168
|
+
- name: Install and build the pre-release's commit
|
|
169
|
+
env:
|
|
170
|
+
VERSION: ${{ steps.version.outputs.version }}
|
|
171
|
+
COMMIT: ${{ steps.version.outputs.commit }}
|
|
172
|
+
run: |
|
|
173
|
+
set -euo pipefail
|
|
174
|
+
git checkout --detach "$COMMIT"
|
|
175
|
+
if grep -q '"packageManager"' package.json 2>/dev/null; then corepack enable; fi
|
|
176
|
+
{{@install}}
|
|
177
|
+
{{@build}}
|
|
178
|
+
node -e "const f=require('path').join(process.env.DIRECTORY,'package.json'),p=JSON.parse(require('fs').readFileSync(f));p.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\n')"
|
|
179
|
+
|
|
180
|
+
- name: Stage it on npm
|
|
181
|
+
env:
|
|
182
|
+
VERSION: ${{ steps.version.outputs.version }}
|
|
183
|
+
DIST_TAG: latest
|
|
184
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
185
|
+
PROVENANCE: ${{ github.event.repository.private && 'no' || 'yes' }}
|
|
186
|
+
run: |
|
|
187
|
+
set -euo pipefail
|
|
188
|
+
cd "$DIRECTORY"
|
|
189
|
+
npm install -g npm@^11.15.0
|
|
190
|
+
provenance=""
|
|
191
|
+
if [ "$PROVENANCE" = yes ]; then provenance=--provenance; fi
|
|
192
|
+
if npm stage publish $provenance --access "$ACCESS" --tag "$DIST_TAG" --loglevel verbose > "$RUNNER_TEMP/npm.log" 2>&1; then
|
|
193
|
+
grep -vE '^npm (verbose|http|timing|silly)' "$RUNNER_TEMP/npm.log" || true
|
|
194
|
+
echo "::notice title=Staged on npm::$PACKAGE@$VERSION goes live on $DIST_TAG once the owner approves it with 2FA: npm stage approve <id>, or Staged Packages on npmjs.com."
|
|
195
|
+
else
|
|
196
|
+
grep -vE '^npm (verbose|http|timing|silly)' "$RUNNER_TEMP/npm.log" || true
|
|
197
|
+
if grep -qiE 'previously published|cannot publish over' "$RUNNER_TEMP/npm.log"; then
|
|
198
|
+
echo "::error title=Already on npm::$PACKAGE@$VERSION is already on npm, and npm takes a version once. Nothing was staged."
|
|
199
|
+
exit 1
|
|
200
|
+
fi
|
|
201
|
+
echo "npm's trusted publishing (OIDC):"
|
|
202
|
+
oidc=$(grep -i 'oidc' "$RUNNER_TEMP/npm.log" | grep -v 'eyJ' | cut -c1-300 || true)
|
|
203
|
+
echo "${oidc:-npm logged nothing about it.}"
|
|
204
|
+
echo "::error title=npm refused to stage it::npm's trusted publisher for $PACKAGE must name exactly: GitHub Actions, user ${GITHUB_REPOSITORY%%/*}, repository ${GITHUB_REPOSITORY#*/}, workflow release.yml, environment npm. Until it applies, the npm environment needs NPM_TOKEN: a granular token with read and write access to $PACKAGE that doesn't bypass 2FA."
|
|
205
|
+
exit 1
|
|
206
|
+
fi
|
|
207
|
+
|
|
208
|
+
- name: Tag it
|
|
209
|
+
env:
|
|
210
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
211
|
+
COMMIT: ${{ steps.version.outputs.commit }}
|
|
212
|
+
run: gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" > /dev/null
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# {{header}}
|
|
2
|
+
#
|
|
3
|
+
# Roll back: puts production, the Worker {{productionName}}, back on a version it ran before. The board's Roll back
|
|
4
|
+
# button starts it, or Actions, Run workflow (the GitHub mobile app too). It is never paused. Staging goes back by
|
|
5
|
+
# deploying again. Agents never run this workflow.
|
|
6
|
+
name: Roll back
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
inputs:
|
|
11
|
+
worker:
|
|
12
|
+
description: The Worker to roll back (production's)
|
|
13
|
+
required: true
|
|
14
|
+
default: {{production}}
|
|
15
|
+
type: string
|
|
16
|
+
version:
|
|
17
|
+
description: The version to go back to (blank for the one before)
|
|
18
|
+
required: false
|
|
19
|
+
type: string
|
|
20
|
+
reason:
|
|
21
|
+
description: What broke
|
|
22
|
+
required: true
|
|
23
|
+
type: string
|
|
24
|
+
|
|
25
|
+
permissions: {}
|
|
26
|
+
|
|
27
|
+
concurrency:
|
|
28
|
+
group: deploy-production
|
|
29
|
+
cancel-in-progress: false
|
|
30
|
+
|
|
31
|
+
env:
|
|
32
|
+
PRODUCTION: {{production}}
|
|
33
|
+
HEALTH_URL: {{healthProduction}}
|
|
34
|
+
|
|
35
|
+
jobs:
|
|
36
|
+
rollback:
|
|
37
|
+
name: roll back
|
|
38
|
+
if: github.ref == 'refs/heads/{{branchName}}'
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
timeout-minutes: 15
|
|
41
|
+
environment: production
|
|
42
|
+
permissions:
|
|
43
|
+
contents: read
|
|
44
|
+
deployments: write
|
|
45
|
+
env:
|
|
46
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
47
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
48
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
|
|
49
|
+
TARGET: ${{ inputs.worker }}
|
|
50
|
+
VERSION: ${{ inputs.version }}
|
|
51
|
+
REASON: ${{ inputs.reason }}
|
|
52
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
55
|
+
with:
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
58
|
+
with:
|
|
59
|
+
node-version: 22
|
|
60
|
+
|
|
61
|
+
- name: Roll back
|
|
62
|
+
run: |
|
|
63
|
+
set -euo pipefail
|
|
64
|
+
if [ "$TARGET" != "$PRODUCTION" ]; then
|
|
65
|
+
echo "::error title=Production only::Roll back runs on production, $PRODUCTION, not $TARGET. Staging goes back by deploying again."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
if [ -n "$VERSION" ] && ! [[ "$VERSION" =~ ^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$ ]]; then
|
|
69
|
+
echo "::error title=Not a version::$VERSION isn't a Worker version ID. Leave it blank to go back to the one before."
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
helpers="$GITHUB_WORKSPACE/scripts"
|
|
73
|
+
# Outside the checkout, so wrangler reads no config: the Worker is named, and only its versions change.
|
|
74
|
+
cd "$RUNNER_TEMP"
|
|
75
|
+
wrangler() { npx --yes wrangler@4 "$@" --name "$PRODUCTION"; }
|
|
76
|
+
wrangler rollback ${VERSION:+"$VERSION"} --message "$REASON" --yes
|
|
77
|
+
wrangler deployments list --json > deployments.json
|
|
78
|
+
now=$(node "$helpers/deploy-plan.mjs" current < deployments.json | sed 's/^version=//')
|
|
79
|
+
sha=$(node "$helpers/deploy-plan.mjs" sha-of --environment "$PRODUCTION" --version "$now" | sed 's/^sha=//')
|
|
80
|
+
if [ -z "$sha" ]; then
|
|
81
|
+
echo "::warning title=Commit unknown::No Deployment recorded version $now, so the rollback is recorded on $GITHUB_SHA."
|
|
82
|
+
sha=$GITHUB_SHA
|
|
83
|
+
fi
|
|
84
|
+
deployment=$(node "$helpers/record-deployment.mjs" --environment "$PRODUCTION" --sha "$sha" --task rollback --production --state in_progress --description "rolling back" --log-url "$RUN_URL")
|
|
85
|
+
state=success
|
|
86
|
+
if [ -n "$HEALTH_URL" ]; then
|
|
87
|
+
state=failure
|
|
88
|
+
for attempt in $(seq 1 18); do
|
|
89
|
+
if curl -fsS --max-time 10 -o /dev/null "$HEALTH_URL"; then state=success; break; fi
|
|
90
|
+
sleep 5
|
|
91
|
+
done
|
|
92
|
+
fi
|
|
93
|
+
node "$helpers/record-deployment.mjs" --deployment "$deployment" --state "$state" --note "rolled back: $REASON" --version "$now" --log-url "$RUN_URL" > /dev/null
|
|
94
|
+
if [ "$state" != success ]; then
|
|
95
|
+
echo "::error title=Still failing::Production went back to version $now, but $HEALTH_URL doesn't answer. Read the Worker's logs on Cloudflare."
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
echo "Production runs version $now again (${sha:0:7})."
|