apply refuses as moved when the branch moved under the stack after its checkout
Decision record 0111
Amends 0008 and 0052 (a merge after the tick is refused as moved even when the fresh preview cannot see it), 0035 (one more step in the order of an
applyrun, andapplystarts a full scan) and 0051 (one more comment to the ticker, written without a row swap).
Issue 270, found by the release verification of the examples repo on 2026-09-25. A person ticked a stack on commit c57d9b0. While the apply job waited for its runner, a push added a resource to a file the stack claims, and the scan of that push ran and ended while the row still said deploying. Then apply ran. It had checked out c57d9b0, the commit of the run, so its fresh preview gave the approved hash, the stack deployed as ticked, and the row turned in sync while the default branch held a change nobody had previewed or deployed.
Nothing unsafe went out: what deployed is what was approved. But three places promise more. Record 0008 describes a merge “before the apply job starts” whose change stops the deploy, record 0052 says a merge that moves a shown value after the tick “gives another hash, so apply refuses it as moved”, and the acceptance checklist says: tick a row, merge a change to that stack before the apply job starts, and nothing deploys, the job is red, and the row shows the new diff with its failure line. In the split workflow, and in the one-step workflow whenever a push lands during the step, the fresh preview runs on the commit the run checked out, so a merge after that never reaches it.
Decision
Before its fresh preview, apply compares the commit its run checked out with the head of the branch the run is on, and refuses as moved when a newer commit is one a scan would preview the stack for.
- What is compared.
GITHUB_SHA, the commit the job checked out and the fresh preview would run on, with the branch ofGITHUB_WORKFLOW_REF, by its short name. Anissuesevent and the schedule run on the default branch. A dispatch runs on the ref it names, which is the refresolveandsettleran on when they dispatched. So it is always the branch the dashboard’s rows come from. One request,GET /repos/{owner}/{repo}/compare/{sha}...{branch}, which the job’scontents: readallows. - When it moved. The claim rule decides, exactly as for a narrowed scan (record 0010): the stack claims a file the newer commits change, or they change a file no stack claims, which makes a scan of the head a full one. A file
scan.unrelatednames, or that no program reads by default, moves nothing. A comparison that is not a straight line on from the checked-out commit, as after a force push, moved. So does one that lists the 300 files GitHub gives at most, because it cannot be read whole: a refusal is the safe direction. A push that changes only other stacks moves nothing, and the tick deploys as before. - How it ends. Like a moved change (0051, part 4), with the same fixed reason,
the change moved since the tick. The record ends aserror, the job is red, theoutcomeoutput isrefused, the summary says nothing was deployed. The tool never runs: the check sits after config,deploys: falseand discovery, which it needs for the claim rule, and before the version check, so a refusal costs one request and no credentials. A rehearsal is refused the same way, as every refusal before its end is (0051, part 5). - The row. No fresh preview of the head exists in this job, and a preview of the checked-out commit would bring back the diff that was ticked, which a new tick would approve and a later
applywould refuse again. Soapplywrites no row, as for every way out before the fresh preview, and starts a full scan by dispatching this same workflow on this same ref, the waysettledoes after it ended a record (0035, slice 2.6). That scan meets a deploying row with no open deployment, previews the stack on the new head, and writes it pending with the newer change and the failure line. The row is never in sync while the branch holds a change of the stack. A dispatch that fails is one more line of the red job, namingactions: write, which the example workflows give every job already. - The comment. One comment tells the ticker, as for a moved change, without a row swap first: it says a newer commit reached the branch before the deploy started, that the next scan shows the change as it is now on its row, and to tick again. It names nothing of the diff. It is written whenever the dashboard is found.
- Nothing known, nothing deployed. A comparison that fails, and a job that does not know its branch, deploy nothing: the record ends as
failurewiththe deploy stopped before the tool ran, the job is red, and the log says why and namescontents: read. The tick rule fails closed the same way (0018).
Why refuse, and not deploy the ticked commit and show the head as pending
The issue offered a second answer: deploy the ticked commit, which is exactly what the person approved, and then write the row from the head so the newer change shows as pending. It was rejected.
- It breaks what records 0008 and 0052 and the acceptance checklist already promise: a merge before the
applyjob starts stops the deploy. Changing three documents to match the code would weaken the promise to fit a gap. applycannot write the head’s row. It checked out the older commit and has no preview of the head, so it would need the same dispatched scan anyway, and until that scan ended the row would say in sync, which is the exact claim the issue is about.- The cost is small and bounded. The window is the time between the checkout of the run and the start of
apply: a runner queue or an environment’s reviewer, not the hours between a scan and a tick that record 0008 weighed when it kept the commit out of the hash. And only a push that a scan would preview this stack for refuses it. The refused tick is one click away from a deploy of the newer change.
Consequences
- The order of an
applyrun (0035, slice 2.5) gains one step: the record’s status, the record,in_progress, config anddeploys: false, discovery, the comparison with the branch, the version check, the fresh preview, and the rest as before. applymakes one more request on every run, and needsactions: writefor the refusal’s scan, which the example workflows give already.ApplyContexttakes the workflow and its ref, assettle’s does.- The fake GitHub knows the head of a branch, and a comparison may name the branch where it names a commit, as GitHub’s does.
- The two checks split the time between them. A push after the scan and before the run checks out its commit reaches the fresh preview, and the diff hash and the value fingerprint (0102) judge it. A push after the checkout is judged by this comparison.
- Several refused deploys in one run each start a scan. The runs queue in their concurrency group and the extra ones rescan nothing new; merging them was left out as not worth a rule.