Skip to content

fix(ci): wait briefly for the image-verification policy handoff instead of failing the deploy on first contention #2869

Closed

Description

@devantler

🤖 Generated by the Agentic Engineer

Evidence

A production deploy failed today at the image-verification policy handoff gate, and the very next
dispatch of the same workflow on the same commit sailed straight through it:

Run Started Outcome
30696866649 2026-08-01T11:01:22Z failure at 11:03:13Z — Another transaction already owns the image-verification policy handoff; refusing cluster mutation.
30696960482 2026-08-01T11:04:11Z passed the same gate (same sha 4cfe7d9f)

So the contention window that failed the deploy had already closed under a minute later.

Problem

pause_flux_policy_handoff in scripts/refresh-flux-ghcr-auth.sh reads the owner annotation
platform.devantler.tech/ghcr-policy-handoff-owner and, if it is non-empty, returns 1 immediately:

  • no wait, no backoff, no bounded retry — the check is a single read, and the enclosing step
    fails the job outright;
  • everything downstream (Push manifests to GHCR, cosign signing, SBOM/provenance attestation, Flux
    reconciliation, Talos cluster update) is skipped, so the deploy is abandoned mid-transaction and
    a human must re-dispatch it.

The two mutual-exclusion primitives in this same script do not have the same shape. The GHCR sync
Lease is a real Kubernetes Lease carrying leaseDurationSeconds/renewTime, so it self-expires
and a waiter can reason about it. The handoff owner is an opaque, timestamp-free annotation
("${desired_revision:0:16}-$$-${RANDOM}") with no expiry and no age anywhere in the script's 3481
lines — grep for the annotation returns 4 references, none of them staleness logic.

Refusing to mutate the cluster while another transaction holds the handoff is correct and must
stay.
The gap is not the refusal, it is that the guard has zero patience for a condition that is
normal, brief, and self-clearing.

Why it matters

Deploys to production are the delivery path for every other change here. A gate that converts a
sub-minute overlap into a failed deploy plus a manual re-dispatch makes the safe path the annoying
one, which is exactly the pressure that gets guards weakened later.

Related but distinct, and worth noting rather than fixing here: because the annotation has no expiry,
a run killed in a way that skips the cleanup_refresh_work EXIT trap (SIGKILL, runner eviction,
cancellation) leaves the handoff owned with no automatic recovery. The trap covers ordinary failure
exits, so this is a narrow residual rather than today's cause.

Expected behaviour

Wait for the handoff to be released, up to a bounded budget, before refusing.

Acceptance criteria

  • pause_flux_policy_handoff polls the owner annotation with a bounded wait and backoff instead
    of failing on the first observation of a non-empty owner.
  • The wait budget is explicit and short enough to fail fast when a holder is genuinely stuck.
  • On timeout it still refuses with the current error — the fail-closed property is unchanged,
    and a test proves a still-held handoff is refused rather than adopted.
  • A test proves a handoff released partway through the wait is acquired rather than refused
    (the case that failed today).
  • No change to what the guard protects: an owned handoff never yields a cluster mutation.

Size: small — one function, plus its two tests.

Activity

  1. added theissue type on Aug 1, 2026
  2. coderabbitai commented on Aug 1, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #2633 - fix(deploy): verify incoming image on Talos nodes [merged]
    #2635 - fix(deploy): reboot stale nodes so containerd adopts refreshed GHCR auth [merged]
    #2793 - feat(cilium): activate homogeneous device selection [merged]
    #2813 - fix(platform): harden Cilium exposure and deploy lease [merged]
    #2825 - fix(ci): queue prod deploys instead of dropping the pending one [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. moved this to 📥 Backlog in 🌊 Project Boardon Aug 1, 2026
  4. devantler commented on Aug 1, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    The asymmetry is inside the same script, which makes the fix a matter of consistency

    Measured at main, scripts/refresh-flux-ghcr-auth.sh:

    retries on contention self-expires
    acquire_sync_lease (GHCR sync Lease) yes — for attempt in 1 2 3, with a CAS re-read per attempt yes — SYNC_LEASE_DURATION_SECONDS on a Kubernetes Lease
    pause_flux_policy_handoff (image-verification handoff) no — one read, then return 1 no — an opaque annotation, "${desired_revision:0:16}-$$-${RANDOM}", no timestamp

    So the deploy already treats brief contention on its other mutual-exclusion primitive as normal and
    waits it out. The handoff is the outlier, and today's failure is exactly the case the sync lease
    already handles: run 30696866649
    refused at 11:03:13Z, and run 30696960482
    passed the same gate a minute later and completed successfully, so main is healthy again and
    this is a reliability defect rather than an outage.

    Reusing the retry shape acquire_sync_lease already establishes keeps the fix small and keeps the two
    primitives reasoning the same way, which is worth more than a bespoke wait here.

  5. devantler commented on Aug 11, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Evidence from today qualifies this issue's premise, so flagging it before the fix is built.

    A wait already exists — measured on #3090 today, the gate ran for 3m57s (deploy,
    reassert_flux_ghcr_auth) and 3m56s (heal, stage_flux_ghcr_auth) before failing. So the
    current behaviour is no longer "fails on first contention"; it waits ~4 minutes and still fails.

    More importantly, today's blocker is not a contention window at all. flux_policy_handoff_is_quiescent()
    requires flux-system/infrastructure to have no Reconciling=True condition, and that Kustomization
    is stuck in a permanent 20-minute ProgressingWithRetry loop because a Longhorn volume fault leaves
    Cluster/observability/coroot-db unhealthy. No bounded wait outlasts that — a longer timeout would
    turn a 4-minute failure into a 20-minute one and change nothing.

    The 2026-08-01 observation in the issue body (window closed under a minute) still stands for genuine
    peer-transaction contention. It just is not the only thing that trips this gate, and the other cause
    needs a different fix — distinguishing "parent is mid-apply" from "a workload it owns is persistently
    unhealthy".

    Full chain: #3092.

  6. devantler commented on Sep 19, 2026

    @devantler
    ContributorAuthor

    🤖 Generated by the Agentic Engineer

    Closing: the proposed fix can't help, and the real cause has been handled

    Waiting for a live peer is impossible now. Every workflow that takes the handoff (the merge-group deploy-prod and heal-prod-on-failure jobs in ci.yaml, cd.yaml, and the dr-rebuild.yaml rebuild job) holds the shared prod-deploy concurrency group with cancel-in-progress: false and queue: max (#2825). No two of them run at once. So an owner annotation that is present when a run starts was left behind by a run that exited without releasing it. That's an orphan, not a peer mid-transaction. Waiting with backoff (this issue's proposed fix) would only turn a quick failure into a slow one.

    The orphan case now has a proper path. #3034 made the error name ./scripts/refresh-flux-ghcr-auth.sh --fences, which lists every held fence with its liveness evidence and the exact release command. It also added the runbook section Recover an orphaned GHCR deploy fence. The Kustomization-stuck-reconciling case from the 2026-08-11 comment is tracked on #3092.

    It hasn't happened again. I scanned the logs of all 62 failed platform deploy runs since #3034 merged (2026-08-16): merge-group ci.yaml plus cd.yaml. None contained this error. Controls: those logs are readable (each carries ##[error] lines), and the same pattern does match the original 2026-08-01 run 30696866649.

    If it comes back, --fences output from that run is the starting evidence. Reopen with it.

  7. moved this from 📥 Backlog to ✅ Done in 🌊 Project Boardon Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions