Skip to content

ci: retry a failed Fly deploy, and fail loudly when the two apps disagree - #61

Merged
paulieb89 merged 2 commits into
mainfrom
ci/release-deploy-reliability
Sep 4, 2026
Merged

ci: retry a failed Fly deploy, and fail loudly when the two apps disagree#61
paulieb89 merged 2 commits into
mainfrom
ci/release-deploy-reliability

Conversation

@paulieb89

Copy link
Copy Markdown
Owner

The incident this closes

On v1.15.1 the PyPI publish and the propertydata deploy succeeded while the
property-shared deploy failed twice, five minutes apart:

error releasing builder: deadline_exceeded: context deadline exceeded
timed out connecting to machine: failed to list workers:
  Unavailable: ... authentication handshake failed: EOF

The two deploys are independent leaf jobs, so one failing while the other
succeeds is a normal terminal state of this graph
. Nothing observed the pair.
The apps ran different versions for about an hour — with the app that needed a
critical hotfix still serving the broken build — until a human noticed and
deployed manually with --depot=false.

Two changes: the cause, and the silence.

scripts/deploy_fly.py — the retry ladder

Depot first (the default and faster path; one intermittent incident is not a
reason to discard it permanently), then two --depot=false attempts — the one
causally relevant difference from the successful manual recovery.

The per-attempt 900s timeout is load-bearing. Both observed failures were
timeouts. Without a bound, one hung attempt consumes the job budget and the
fallback never runs, which would make the retry decorative.

--app and --config are now always explicit. CI previously relied on cwd
discovery of fly.toml, so the command that ran was not the command anyone had
written down.

scripts/verify_release.py — the job that would have noticed

needs: [deploy, deploy-propertydata] with if: ${{ !cancelled() }} — it must
run precisely when a deploy has failed. Not always(), so a cancelled run
isn't reported as drift.

Verified live (2026-09-04): both apps expose
/.well-known/mcp/server-card.jsonserverInfo.version from the same
installed property-shared distribution. propertydata's /health carries no
version and cannot serve this.

It keeps two failures distinguishable:

  • tag/pyproject mismatch — fails immediately with its own message. The
    version is baked into the image at build time, so tagging v1.18.3 without
    bumping pyproject.toml deploys two apps that both honestly report 1.18.2;
    no amount of polling fixes that.
  • release drift — reported with both observed versions.

--require-consecutive 2 because Fly's proxy may front more than one Machine
and a single 200 cannot distinguish "propagated" from "hit the updated
Machine". Each app runs exactly one Machine today — an observation, not a
guarantee — so the defence stays. An unreachable target is a failure, not an
unknown.

Why scripts and not YAML

Retry logic in workflow YAML cannot be tested. The if: failure()
alternative needs continue-on-error, renders a failed first attempt as a
misleading green, and writes the flyctl arguments twice — and two copies
drifting apart is the exact failure class this change exists to close.

Both are stdlib-only operator scripts, loaded by path and driven through
injected runners, following scripts/fly_observability_snapshot.py. The suite
never shells out and never needs a Fly token.

Notable cases: exit 0 when the fallback succeeds (the bug the two-step form
would have had); a hung attempt is bounded and the fallback still runs; the
v1.15.1 incident replayed as test_one_target_behind_fails_and_names_both_versions;
a flapping version doesn't pass; a mismatch spends zero polling rounds.

Action pinning

setup-flyctl pinned @mastered8efb3 (tag 1.6). That job holds a
production deploy token and @master runs whatever is on the branch at that
moment. The SHA is what @master already resolved to, so the pin changes
nothing but reproducibility. The flyctl binary stays unpinned — it talks to a
moving server API, so pinning it would turn a rare flake into certain rot.

One existing test updated, not relaxed

test_the_release_workflow_still_deploys_both_apps asserted the literal inline
flyctl deploy --remote-only --ha=false, which has moved into the script. The
fact it guards — every release still deploys propertydata, which the Stage 1
runbook's honesty depends on — is unchanged, so the assertions moved to where
each half now lives: the app/config pairing pinned in the workflow, the flags
pinned in the script.

Verification limit, stated

This cannot be verified end to end without a real release. The ladder and
the reconcile logic are unit-tested; the workflow wiring is only exercised on
the next release.

./scripts/validate.sh2040 passed, 27 skipped.

…gree

On the v1.15.1 release the PyPI publish and the propertydata deploy succeeded
while the property-shared deploy failed twice, five minutes apart, on Depot
builder timeouts:

    error releasing builder: deadline_exceeded: context deadline exceeded
    timed out connecting to machine: failed to list workers:
      Unavailable: ... authentication handshake failed: EOF

The two deploys are independent leaf jobs, so one failing while the other
succeeds is a NORMAL terminal state of this graph. Nothing observed the pair.
The apps ran different versions for about an hour, with the app that needed a
critical hotfix still serving the broken build, until a human noticed and
deployed manually with `--depot=false`.

Two changes, addressing the cause and the silence separately.

scripts/deploy_fly.py -- attempt with Depot (the default and faster path; one
intermittent incident is not a reason to discard it), then fall back twice to
`--depot=false`, which is the one causally relevant difference from the manual
recovery. Each attempt is bounded by its own 900 s timeout: both observed
failures were timeouts, so without a per-attempt bound one hang consumes the
job budget and the fallback never runs, making the retry decorative. `--app`
and `--config` are now always explicit; CI previously relied on cwd discovery
of fly.toml, so the command that ran was not the command anyone had written.

scripts/verify_release.py -- a reconcile job needing BOTH deploys, which polls
each app's /.well-known/mcp/server-card.json until every target reports the
released version. `if: !cancelled()`, so it runs precisely when a deploy has
failed; not `always()`, so a cancelled run is not reported as drift. Both apps
expose the same version field from the same installed distribution (verified
live 2026-09-04); propertydata's /health carries no version and cannot serve.

It separates two failures that need different responses: a tag/pyproject
mismatch fails immediately with its own message, because the version is baked
in at build time and no amount of polling can fix it, while release drift is
reported with both observed versions. It requires two CONSECUTIVE agreeing
rounds, because Fly's proxy may front more than one Machine and a single 200
cannot distinguish "propagated" from "hit the updated Machine". Each app runs
exactly one Machine today, which is an observation rather than a guarantee, so
the defence stays.

Both are stdlib-only operator scripts loaded by path in tests and driven
through injected runners, following scripts/fly_observability_snapshot.py --
the suite never shells out and never needs a Fly token. Retry logic in workflow
YAML cannot be tested at all, and the `if: failure()` two-step alternative needs
continue-on-error, renders a failed first attempt as a misleading green, and
writes the flyctl arguments twice; two copies drifting apart is the failure
class this change exists to close.

setup-flyctl pinned from @master to ed8efb3 (tag 1.6). That job holds a
production deploy token and @master runs whatever is on the branch at that
moment. The SHA is what @master already resolved to, so the pin changes nothing
but reproducibility. The flyctl binary stays unpinned: it talks to a moving
server API, so pinning it would turn a rare flake into certain rot.

test_the_release_workflow_still_deploys_both_apps asserted the literal inline
`flyctl deploy --remote-only --ha=false`, which has moved into the script. The
fact it guards -- every release still deploys propertydata, which the Stage 1
runbook's honesty depends on -- is unchanged, so the assertions were moved to
where each half now lives rather than relaxed: the app/config pairing is pinned
in the workflow, the flags in the script.

Note this cannot be verified end to end without a real release; the ladder and
the reconcile logic are covered by unit tests, the workflow wiring is exercised
on the next release.

./scripts/validate.sh: 2040 passed, 27 skipped.
@paulieb89
paulieb89 merged commit ec38d5d into main Sep 4, 2026
1 check passed
@paulieb89
paulieb89 deleted the ci/release-deploy-reliability branch September 4, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant