-
Notifications
You must be signed in to change notification settings - Fork 78
176 lines (162 loc) · 7.88 KB
/
Copy pathfly-deploy.yml
File metadata and controls
176 lines (162 loc) · 7.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
name: Deploy to Fly
# Auto-deploy the NodeTool server to Fly.io (https://nodetool.fly.dev).
#
# The deploy unit is the GHCR image published by the "Docker" workflow
# (.github/workflows/docker.yml). We therefore trigger on that workflow's
# COMPLETION rather than on push, so we never release an image before it has
# been built + pushed. On success we release the image tag that corresponds to
# the exact triggering commit — `main-<shortsha>`, which "Docker" publishes via
# `type=sha,prefix={{branch}}-`. We deliberately do NOT deploy `:latest` on
# workflow_run: if two main builds finish out of order, `:latest` may point at a
# newer commit and we'd deploy the wrong image. `:latest` is used only for a
# manual re-deploy (workflow_dispatch).
#
# Ring 1 gate (docs/RELIABILITY_ARCHITECTURE.md §11, docs/RELIABILITY_TASKS.md
# F2): a deploy must also wait on "User Journeys"' `reliability-ring1` job —
# the full hermetic + differential + packaged-journey suite — for the SAME
# commit, not just on the Docker build. Both "Docker" and "User Journeys" fire
# independently off the same `push: main`, so this workflow now also
# workflow_run-triggers on "User Journeys" completion; whichever of the two
# finishes second is the one that actually reaches the `deploy` job, because
# the `gate` job below polls the OTHER workflow's status for the same
# head_sha and only proceeds once both are green. This is the least invasive
# option that doesn't require restructuring either upstream workflow into a
# reusable `workflow_call` (which would also entail duplicating docker.yml's
# and user-journeys.yml's own triggers).
#
# Requires a repo secret FLY_API_TOKEN scoped to the `nodetool` app:
# fly tokens create deploy -a nodetool --expiry 8760h
on:
workflow_run:
workflows: ["Docker", "User Journeys"]
types: [completed]
branches: [main]
# Manual re-deploy of the current :latest image.
workflow_dispatch:
# `gate` additionally needs `actions: read` to poll the sibling workflow's run
# status via the GitHub API (GITHUB_TOKEN, not FLY_API_TOKEN).
permissions:
contents: read
actions: read
concurrency:
group: fly-deploy
cancel-in-progress: true
jobs:
# Confirms BOTH "Docker" and "User Journeys" (specifically its
# reliability-ring1 job) succeeded for the exact commit that triggered this
# run, regardless of which of the two workflow_run events fired us. Polls
# because the two workflows run independently and finish in either order —
# this job is a no-op wait until the other one lands (or times out).
gate:
name: Wait for Docker build + reliability Ring 1
runs-on: ubuntu-latest
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
timeout-minutes: 65
outputs:
sha: ${{ github.event.workflow_run.head_sha }}
deploy: ${{ steps.poll.outputs.deploy }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Poll for both workflows' success on this commit
id: poll
shell: bash
run: |
set -euo pipefail
sha="${{ github.event.workflow_run.head_sha }}"
repo="${{ github.repository }}"
conclusion_for() {
local workflow_file="$1"
gh api "repos/${repo}/actions/workflows/${workflow_file}/runs?head_sha=${sha}&status=completed&per_page=1" \
--jq '.workflow_runs[0].conclusion // "pending"'
}
# True once a newer commit has landed on main. That commit's own
# fly-deploy run releases the newer image, so anything this run could
# still conclude about ${sha} is moot — and a red X here would be
# noise, not a broken deploy.
superseded() {
[ "$(gh api "repos/${repo}/commits/main" --jq .sha)" != "${sha}" ]
}
skip() {
echo "::notice::${1} Superseded on main; skipping the deploy of ${sha}."
echo "deploy=false" >> "$GITHUB_OUTPUT"
exit 0
}
for attempt in $(seq 1 60); do
docker_conclusion="$(conclusion_for docker.yml)"
journeys_conclusion="$(conclusion_for user-journeys.yml)"
echo "attempt ${attempt}/60: docker.yml=${docker_conclusion} user-journeys.yml=${journeys_conclusion}"
if [ "${docker_conclusion}" = "success" ] && [ "${journeys_conclusion}" = "success" ]; then
echo "Both workflows succeeded for ${sha}."
echo "deploy=true" >> "$GITHUB_OUTPUT"
exit 0
fi
case "${docker_conclusion}" in
failure|cancelled|timed_out)
if superseded; then skip "docker.yml concluded '${docker_conclusion}' for ${sha}."; fi
echo "::error::docker.yml concluded '${docker_conclusion}' for ${sha}; not deploying."
exit 1
;;
esac
case "${journeys_conclusion}" in
failure|cancelled|timed_out)
if superseded; then skip "user-journeys.yml (reliability-ring1) concluded '${journeys_conclusion}' for ${sha}."; fi
echo "::error::user-journeys.yml (reliability-ring1) concluded '${journeys_conclusion}' for ${sha}; not deploying."
exit 1
;;
esac
sleep 60
done
if superseded; then
skip "Timed out waiting for docker.yml and user-journeys.yml on ${sha}."
fi
echo "::error::Timed out waiting for docker.yml and user-journeys.yml to both complete for ${sha}."
exit 1
deploy:
# The job name "Deploy server" is load-bearing: web-deploy.yml chains off
# this workflow's completion and releases the SPA only when a job with this
# exact name concluded `success` — renaming it silently parks the web deploy.
name: Deploy server
needs: [gate]
runs-on: ubuntu-latest
# Skip when the image build (or the reliability Ring 1 gate) failed/was
# cancelled. workflow_dispatch always runs (manual re-deploy); on
# workflow_run, `gate` must have completed successfully AND still consider
# this commit worth releasing — it reports deploy=false for a commit main
# has already moved past, whose own run does the releasing.
if: >
always() &&
(github.event_name == 'workflow_dispatch' ||
(needs.gate.result == 'success' && needs.gate.outputs.deploy == 'true'))
steps:
- uses: actions/checkout@v7
with:
# Deploy the fly.toml as it was at the built commit (falls back to the
# dispatch ref for manual runs).
ref: ${{ needs.gate.outputs.sha || github.sha }}
- name: Resolve image tag
id: img
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_run" ]; then
# Pin to the exact commit that was just built (main-<7-char-sha>),
# matching docker.yml's `type=sha,prefix={{branch}}-`.
sha="${{ github.event.workflow_run.head_sha }}"
echo "ref=ghcr.io/nodetool-ai/nodetool:main-${sha:0:7}" >> "$GITHUB_OUTPUT"
else
echo "ref=ghcr.io/nodetool-ai/nodetool:latest" >> "$GITHUB_OUTPUT"
fi
- uses: superfly/flyctl-actions/setup-flyctl@v1.4
# Rolls the machines one at a time, draining each before it is replaced,
# instead of `flyctl deploy` — which restarts a machine with SIGTERM and
# gives it 300 s, while a chat turn can run for half an hour. The script
# also runs the migration fly.toml's release_command runs, since
# `machine update` has no release phase.
#
# It hands over between machines, so the app wants two of them:
# `fly scale count 2` once, by hand. With one machine the drain is an
# outage window rather than a handover.
- name: fly rolling deploy (server)
run: bash scripts/fly-rolling-deploy.sh "${{ steps.img.outputs.ref }}"
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}