ci: retry a failed Fly deploy, and fail loudly when the two apps disagree - #61
Merged
Conversation
…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.
…iability # Conflicts: # .gitignore
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The incident this closes
On v1.15.1 the PyPI publish and the
propertydatadeploy succeeded while theproperty-shareddeploy failed twice, five minutes apart: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 ladderDepot first (the default and faster path; one intermittent incident is not a
reason to discard it permanently), then two
--depot=falseattempts — the onecausally 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.
--appand--configare now always explicit. CI previously relied on cwddiscovery of
fly.toml, so the command that ran was not the command anyone hadwritten down.
scripts/verify_release.py— the job that would have noticedneeds: [deploy, deploy-propertydata]withif: ${{ !cancelled() }}— it mustrun precisely when a deploy has failed. Not
always(), so a cancelled runisn't reported as drift.
Verified live (2026-09-04): both apps expose
/.well-known/mcp/server-card.json→serverInfo.versionfrom the sameinstalled
property-shareddistribution.propertydata's/healthcarries noversion and cannot serve this.
It keeps two failures distinguishable:
version is baked into the image at build time, so tagging
v1.18.3withoutbumping
pyproject.tomldeploys two apps that both honestly report1.18.2;no amount of polling fixes that.
--require-consecutive 2because Fly's proxy may front more than one Machineand 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 amisleading 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 suitenever 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-flyctlpinned@master→ed8efb3(tag 1.6). That job holds aproduction deploy token and
@masterruns whatever is on the branch at thatmoment. The SHA is what
@masteralready resolved to, so the pin changesnothing 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_appsasserted the literal inlineflyctl deploy --remote-only --ha=false, which has moved into the script. Thefact it guards — every release still deploys
propertydata, which the Stage 1runbook'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.sh→ 2040 passed, 27 skipped.