Deploy to Fly #2841
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |