Docs · Glidepath
ADR-0021: Release events are facts from the Rollout, interpreted on dev
Status: Accepted. Phases 0 to 3c are built and live (2026-10-06): facts from the Rollout,
the reducer, the hooks removed, the relay renamed glidepath-relay. Phase 4 (rollback) is
designed in Phase 4: rollback below and in progress.
Supersedes the mechanism in ADR-0005; that ADR’s
trust boundary and its per-cluster ArgoCD decision are kept.
Context
A release to a Flight environment on another cluster is reported back to the dev cluster
by three Argo CD sync-hook Jobs (PreSync, PostSync, SyncFail) rendered by
airframe-application (templates/release-tracking/hooks.yaml). Each runs
catalog/lib/argocd-outcome-hook.sh, which builds a CDEvent from about eight values baked
into release.yaml and POSTs it to argocd-outcome-relay, which forwards it to the broker.
Each event then starts a release-outcome-notify PipelineRun (five Tasks). The full
history, including why hooks replaced Argo CD Notifications, is in
multi-cluster.md.
The mechanism works and is live on five apps (baggage, boarding, checkin, flight and gate,
all prod/staging, all Rollouts). It has costs, and the roadmap now needs things it
cannot do.
Costs that are observed or follow directly from the code:
- PreSync runs in the deploy path. A PreSync Job that fails stops the sync before the
Rollout is touched. The Job pulls
toolbox:latest, waits up to 30 s for an ESO-synced Secret, and calls a remote relay. Any of those failing is a failed deploy. - Per-app machinery. Each tenant carries three Jobs, a ServiceAccount, a Role, a
RoleBinding and an ExternalSecret (in
airframe-identity), arelayHostAliasIP, and about eight baked fields including a base64cicd.yaml. Argo CD’s retry sweep deleting that ServiceAccount under a running Job (2026-09-18) is the same design pressure. - Dev spawns a lot per release. Every event is a PipelineRun with five Task pods.
Cost confirmed in Phase 0 (spike S1; a control without the hook finished in 0 s): PostSync runs only once the
Application is Healthy, and a canary that is paused or running analysis is not Healthy.
The sync operation therefore stays Running for the whole canary, so a revert merged during
a bad canary may not start syncing until the operation ends.
What hooks cannot see, by construction. They fire inside a sync. They never see an
auto-abort by an AnalysisRun, a manual abort, an out-of-band edit, or a rollback that is
not a sync. We have not talked about rollback yet, and it is exactly the case that leaves
no sync behind.
Why not Argo CD Notifications (revisited)
Argo CD Notifications was replaced in August 2026 because it fired on self-heal. That test
used oncePer: app.status.operationState.finishedAt, which cannot distinguish self-heal
from a release. Argo’s own deployment pattern keys oncePer on
operationState.syncResult.revision. A heuristic circulating in a design review,
syncResult.revision != sync.revision, does not work: during a real release both fields
hold the new revision. Even a correct trigger would still not solve the real problem:
Argo CD templates see .app only, never the Rollout’s annotations, so release identity
cannot ride along.
Decision (proposed)
- Prod emits facts, not events. The Argo Rollouts controller already on every
cluster (v1.9.1 on prod) has a built-in notifications engine. Its templates
receive the whole
.rolloutobject, annotations included. One webhook service and one template per cluster post a small JSON fact on each Rollout phase change: rollout identity (namespace, name, uid),metadata.generation,status.phase, current step and step count,currentPodHash,status.abort, the images, a timestamp, and thehangar.io/release-idannotation. A fact carries nocicd.yaml, no flow time, no PR data. Subscription is by label (hangar.io/release-tracked=true) rather than a per-app annotation, if the engine supports it (spike S2). - Release identity rides on the Rollout.
open-release-pralready writesrelease.yaml. It adds one machine-owned value,release.id, and theairframe-applicationchart renders it ashangar.io/release-idon the Rollout’s own metadata (not the pod template, so it never forces a new ReplicaSet). The chart has no Rollout-metadata annotation support today; adding it is part of Phase 1. The id is opaque and unique per release to one environment, for example<chain-id>:<cluster>/<env>. - Dev holds the meaning.
open-release-pralready writes arelease-tracking-<chain-id>ConfigMap on dev forpr-urlandpr-created-at. It becomes the ReleaseRecord: it also carries flow start time,cicd.yamlsnapshot, PR, target digest, kind (promoteorrollback) androllbackOf, and it is kept until the release is terminal plus a TTL instead of being deleted on first read.argocd-outcome-relaybecomes a reducer: it authenticates a fact, joins it to the record byrelease-id, advances the state machine below, and emits the same CDEvents as today (environment.deploying,environment.deployed) so the broker, Triggers,release-outcome-notify, DORA, the release log and the outcome span are unchanged in phase 1. New event types (service.rolledbackand others) come later and follow ADR-0022, which also retiresenvironment.deploying/environment.deployed(not CDEvents spec events). - Argo CD Notifications return in one narrow role: sync failure. A sync that fails
before the Rollout changes produces no Rollout fact. One Argo CD trigger on
argocd-apps(the tenant instance; prod runs two),operationState.phase in [Failed, Error], posts a failure fact keyed by the gitops revision. Dev maps revision to release by the open record for that app and env. - Auth and transport. One bearer token per cluster, stored in the
argo-rollouts-notification-secretthat the install already ships, checked by the relay againstcluster-registryexactly as now, including the rule thatsubject.content.clustermust match the authenticated path. The per-app ServiceAccount, Role, RoleBinding, ExternalSecret,relayHostAliasIPand the three Jobs are deleted. The DNS gap for*.lab.internalbecomes one fix on one Deployment instead of one per app. - Delivery is at-most-once per fact, and a failure is silent (confirmed in Phase 0,
findings): with the receiver down the engine logged no error,
marked the facts notified, and never redelivered. So the stream must be made
level-triggered. A heartbeat trigger re-sends current state once per time bucket (the
Rollouts controller re-reconciles every rollout every 15 minutes, so a lost fact becomes
a late one), and the stall alert (a record merged with no fact for N minutes) is
mandatory. The fact id is deterministic,
(rollout uid, generation, phase, step), and the reducer is idempotent. The heartbeat works but adds one key per resync to the Rollout’s unprunednotified...annotation (about four weeks to the 256 KiB cap). Decided (owner, 2026-10-05): keep the engine and add a small prod CronJob that strips thenotified...annotation from tracked Rollouts on a schedule. That bounds the annotation and, because the engine then sees empty state, resends the current state at the next reconcile, which is the heartbeat. It needs a Role that can patch Rollouts. The prod-side adapter remains the fallback if the CronJob proves unreliable (see the findings). Two further rules from Phase 0: every trigger must requireobservedGeneration == string(generation)(without it the engine sentHealthyfacts carrying the new release-id and the old pod hash before the controller had seen the update), and the reducer counts a fact as a release event only when the release-id or image changed (scale and restart produce facts with an unchanged pod hash).
- Rollback is a release. A rollback is a new release: new
release-id, the target digest of an earlier release,rollbackOf: <old release-id>,reason. It goes through the same PR, gates, merge and facts as any other, and when it turns Healthy the reducer marks the release it replacedrolled-backand emitsservice.rolledback. A handgit revertrestores the oldrelease-idand cannot be told from the original, so the reducer treats an observedrelease-idthat matches no open record as drift and alerts, instead of guessing.
-
Gates on a rollback PR (accepted 2026-10-05). Gates are split by what they protect, not skipped as a set. Using the current
releaseGuardrails:Class Gates On a rollback to an eligible target Why Integrity provenance,values, commit signing, and “the digest still exists in the registry”Block, always Cheap, deterministic, and they prove the thing we are about to run is the thing we built. A rollback must never be a way around them. Content sast,sbom,image-scanRun and record, do not block The target already ran in this environment. A CVE database that moved overnight must not be able to stop recovery, and re-running SAST on unchanged source cannot find anything new. Process itsm,qa,policy-validation,image-promotionUnchanged The class is named for what the gates protect, not their build state: these four are stubs today and stay loud as ADR-0003 requires. Approval CODEOWNERS review on Flight Unchanged “An approval that can be skipped is not an approval” (ADR-0020). Eligible means the digest was Healthy in this exact app, environment and cluster within the last N releases (default 5), as the ReleaseRecord proves. Anything else is an ordinary promotion and takes the full gate set.
Alternative considered: run every gate and rely on the existing break-glass bypass. It needs no new policy code, and for most rollbacks it is fine. It was not chosen as the default because a rollback that must be bypass-merged at an unattended hour makes
bypassa normal event, and then the DORA bypass metric and the Slack alert stop meaning anything. Advisory content gates keepbypassrare. The cost is that a rollback can reintroduce an image with a known finding. The finding is recorded on the rollback’s own check runs and shown in the Release Record, so the choice is visible. -
Workloads with no Rollout (accepted 2026-10-05). Today there are none that use release tracking: the one
rollout: nullrelease (gitops-infra-skyport-broker) has norelease.yamland no release flow. So no Argo CD fallback is built now. The fact schema is source-agnostic (source: rollout | argocd | task), and the chart fails to render whenreleaseTrackingis set on a release with no Rollout, rather than silently emitting nothing. When a real consumer appears, the fallback is an Argo CDon-deployedtrigger (phase == Succeeded && health == Healthy,oncePeronsyncResult.revision), joined on dev by gitops revision. The join needs one GitHub App call per release (commits/{sha}/pulls), cached in the record, and the GitHub rate limit is a standing problem on this platform, so that cost is the reason not to build it speculatively. Cloud targets (aws-ecs,aws-lambda,azure-container-apps) already deploy from a Task on dev and emit facts of the same shape directly.
Consequences
Gains
- No Job in the sync path. A relay outage, a late secret or an image pull can no longer fail or delay a deploy.
- Per-app delivery machinery drops to one label and one annotation, and one webhook per cluster.
- The reducer sees aborts, analysis failures, out-of-band changes and rollbacks, which no sync hook can.
- Canary step and analysis state arrive as facts, so a Tower canary timeline and per-step spans become possible without more prod-side machinery.
- Change-failure rate and time to restore become defensible: an abort or rollback is a failure, and the restoring release is the restore.
- Flow start time stays authoritative on dev. It cannot be derived from Argo (
startedAtis deploy time, not commit time) and the DORA lead-time anchor depends on it.
Costs and risks
- The reducer is stateful.
argocd-outcome-relayis stateless today. State lives in ConfigMaps, so the reducer can be one replica and recover by reading them, but it is a component whose bugs now matter more. - The Rollouts controller is in the event path. Its notifications engine, its
oncePerbookkeeping annotation on the Rollout, and its retry behaviour are all things we would be depending on and have not verified (S2). - A chart change and a tag. Rollout metadata annotations are a chart gap. The change needs the airframe tag and all three pin locations bumped.
- Rollout-shaped. The model fits Rollouts well and everything else poorly. That is acceptable only because there are no other release-tracked workloads today (item 9).
- Facts are not durable on prod. If the relay is down for longer than the engine retries, facts are lost. Mitigated by the stall alert and by Tower reading Rollout status directly, not eliminated.
Unverified, to be settled by Phase 0 before any code
| # | Question | How |
|---|---|---|
| S1 | Does a paused canary hold the Argo CD operation open today? How long do PreSync and PostSync actually take per release? | Read operationState during a paused canary on a prod app; read the hook Jobs’ timestamps for recent releases. |
| S2 | On Rollouts 1.9.1: webhook headers from the secret; .rollout annotations visible in templates; oncePer semantics and its state annotation; subscription by label selector; behaviour when the receiver is down; does the extra annotation make Argo CD show OutOfSync? |
Install the config on one prod app’s namespace, point it at a request catcher. |
| S3 | Which facts arrive for a normal canary, an abort, a scale-only change, a restart, and kubectl argo rollouts undo? Does selfHeal revert an undo? (Expected yes, which would make abort the emergency lever and the rollback PR the way git catches up.) |
Capture on a throwaway app. |
| S4 | Can the Rollouts controller pod reach the dev relay (DNS, NetworkPolicy)? | One hostAliases patch or an IP URL on one Deployment. |
| S5 | Re-enable Argo CD notifications on prod for the failure-only trigger. | Bootstrap config; test with a broken manifest. |
| S6 | Do byte-identical CDEvents come out of the new path and the hook path for the same release? | Run both on one app in phase 1. |
If S2 fails (for example the engine cannot send the fields we need, or retries are unusable), this ADR is withdrawn and the fallback is a small prod-side adapter that watches Rollouts and relays facts. That keeps the same architecture with one more component.
Build order
| Phase | What | Verifiable by |
|---|---|---|
| 0 | Spikes S1 to S5 | A written findings note. Stop if S2 fails. |
| 1 | release.id in release.yaml; chart renders hangar.io/release-id and the tracked label; prod notification config and the prune CronJob in the prod gitops repo; relay /facts/<cluster> that emits today’s CDEvents; hooks left running on the same app (S6) |
Both paths emit equal CDEvents for one real release; no spurious event from a scale or self-heal |
| 2 | Retained ReleaseRecord, reducer state machine, idempotency, stall alert, drift fact | Replay of the S3 captures reaches the expected states |
| 3 | Cut over the five apps; delete the three hook Jobs, airframe-identity ServiceAccount, Role and ExternalSecret, relayHostAliasIP, argocd-outcome-hook.sh, and the configJsonB64 and baked tracking fields |
A release with the hooks gone; the PostSync wait is gone |
| 4 | Rollback: the PR backend, the eligibility check, the advisory gate mode, service.rolledback |
A live rollback on a throwaway app |
| 5 | Per-step spans, Tower canary timeline, cloud-target facts |
Phases 1 and 3 change airframe (a tag and three pin bumps) and the cluster gitops repos
(PRs). Phase 2 and 4 change glidepath, and any schema change there needs the toolbox
rebuild and toolboxImage bump in all three values files.
Not decided here
Whether Tower offers abortDecided 2026-10-09: Tower offers every Argo Rollouts action (abort, pause, resume, promote, promote-full, retry, restart) through Argo CD’s built-in Rollout actions, owner-only; on Flight, promote and promote-full are audited and announced as a bypass of canary analysis (backstagetowerPermissions.ts, tower#55).- Auto-rollback on an SLO burn. The Rollout already aborts on AnalysisRun failure; whether Glidepath should also open the rollback PR automatically is a separate decision.
- Several Flight environments in sequence, and several clusters per environment: the
release-idshape allows it, the reducer’s per-environment ordering is not designed. - The retention period for ReleaseRecords.
Where the ReleaseRecord lives (proposed 2026-10-05, owner raised Tekton Results)
The record has two lives and they want different stores.
| Life | Properties | Store |
|---|---|---|
| Live (open to terminal, plus TTL) | Small, updated on every fact, read by the reducer on each event | A ConfigMap per release-id on dev, as release-tracking-<chain-id> is today. A CRD is a later option if we need watches or status. |
| Terminal (immutable, kept long) | Written once, queried across apps and time | The existing release log (OTLP to Loki, release-log-emit) for search, plus the git-committed record in Tower’s Release Record design for the audit trail. |
Tekton Results is not the store, but it is evidence the record links to. Results archives Tekton objects (PipelineRun, TaskRun, step logs) keyed by the Tekton CR. A ReleaseRecord is not a Tekton object: it is a state machine fed by facts from another cluster, and most of its lifetime is mutation. Specifically:
- Its API is TLS-only and not exposed (ADR-0016); access today is
kubectl port-forwardortkn-results. A reducer and Tower would both depend on that path. - Its retention and cleanup are tied to the watcher’s 1 h grace period for CRs it archives, which is the wrong clock for a record that must outlive them.
- It is operator-managed internal Postgres on a platform that has hit node-capacity ceilings; a record store should not share fate with that.
What Results is good for here is the evidence: the build, gate and
release-outcome-notify PipelineRuns behind a release. The terminal record should
store their Results record names so Tower can open the logs after the CRs are pruned.
Phase 4: rollback (design, 2026-10-10)
Decisions 7 and 8 above, made concrete. A rollback is an ordinary release whose image is an earlier one; everything below is what makes it recognisable and what that changes.
- Starting one. Tower’s Roll back on a Kubernetes Flight environment starts the app’s
promote-releaserun (the path Promote already takes) with three more params:release-kind: rollback,rollback-of(the release-id being replaced, normally the environment’s current one) androllback-reason. Image and git revision are the target release’s. The run is the normalreleasePipeline: same PR, gates, merge and facts. - Config comes from main, not the old commit. A fourth param,
config-revision, makes the run readcicd.yamlat that revision (Tower passes the app repo’smain) whilegit-revisionstays the target’s commit, so provenance and commit-signature checks still look at the commit that built the image. Found live 2026-10-10: a release at an old sky-marshall commit was rejected by preflight because that commit’scicd.yamlstill used the pre-ADR-0019 keys. - The record and the PR say what it is.
open-release-prwriteskind=rollback,rollbackOfandrollbackReasonon the release record, andimageon every record. The commit carriesX-Glidepath-Release-Kind: rollbackandX-Glidepath-Rollback-Of:trailers, the branch isrelease-rollback-<env>-<sha8>(it must startrelease-, which is what every gate’s PaC trigger matches) and the PR title startsRollback:. None of this is trusted for gate decisions (item 4). - Eligibility is computed, not claimed. The promoted image is eligible when one of the
newest five release records for the same app, environment and cluster reached
healthy(healthyAtset, whatever its state is now) with that image. Every content gate works it out itself from the records (resolve-rollback-eligibility), so a PR that says it is a rollback but targets anything else gets the full gate set, and a hand-made PR that pins an eligible image gets the advisory treatment too. The sweeper keeps those five records past the 14-day retention. - Gates on an eligible target. Content gates (
sast,image-scan,sbom) run, report what they found in the check output and the PR comment, and pass. Integrity gates (provenance,values, commit signing) block as always; “the image still exists” is part ofprovenance, which fetches the image’s signature and attestation from the registry, so no separate gate was added. Process and approval gates are unchanged (image-promotionwas retired by ADR-0025). - Reducer. A release that reaches
healthyrecordshealthyAt. When a rollback release reacheshealthy, the release named byrollbackOfbecomesrolled-back(rolledBackBy= the rollback’s release-id) and oneservice.rolledbackevent is sent, in the current envelope (dev.cdevents.service.rolledback.0.2.0; ADR-0022’s vocabulary move is separate). A Tower Retry of an aborted release sends a Progressing fact with the abort flag clear; the reducer reopens the release toprogressinginstead of ignoring it. - Downstream.
service.rolledbackgets its own Trigger and a small pipeline: a Backstage notification and a log line. DORA needs nothing new: the aborted release already sentdeployed-failure, and the rollback release’s owndeployed-successis the restore dora-exporter pairs with it. - Unchanged. A hand
git revertis still drift, not a rollback (decision 7).
Verifiable by: a live rollback of sky-marshall on staging after an aborted canary, with the
content gates advisory, the old record rolled-back, and one service.rolledback event.