Skip to content
Go to console
Go to console

A stack may deploy on merge, and the default stays a tick

Decision record 0095

Amended by 0105: one more reason a stack set to on-merge waits for a tick, after a destroy and drift and before a scan no merge started: its change costs more a month than the repo’s cost.threshold, or the threshold is set and the cost could not be estimated. The row says which.

Amends 0003 (the payload key onMerge), 0018 (a deploy that is not a tick), 0025 (a scan may open a deployment record for a stack set to on-merge, not only after a merge from the dashboard), 0054 (the scan’s matrix carries these deploys too), 0056 and 0091 (a queued record carries onMerge), and 0061 (the check warns when the split workflow has no job for the scan’s matrix). Built as slice 5.31.

Every tool Sluiceway is compared with deploys on merge by default: Terrateam, Digger, Spacelift, env0. DORA’s research reads a human approval per change as a cost to lead time and deploy frequency. Sluiceway’s tick is not an approval board, but from outside it looks like one: a gate on every change to every stack. The honest answer is that the gate belongs to the stack. A boring stack, such as a dashboard, an exporter or a test namespace, can go out on merge. A stack that holds the cluster, the database or the network waits for a person. Until now the only ways to get that were to tick everything, or to leave a stack off the dashboard (issue 213).

The owner, 2026-09-23: the default stays as it is, a person ticks, and a stack may choose otherwise. “defaulting to checking but u can still choose per stack”. So this is opt in, per stack, and a repo that does not use it is byte for byte what it was.

Decision

  • stacks[].deploy takes on-tick, the default, or on-merge. A stack no entry sets carries no key at all, in the config, the payload, the rows and the check, so nothing a repo without it writes or reads changes. An entry with a name wins over one without, as for environment. Any other value fails the config: a typo here would change what deploys without a person.
  • Only the scan of a merge deploys on merge. A merge is a push to the default branch, and the scan it starts is the scan of the merge. When that scan finds a stack set to on-merge pending, it opens the stack’s deployment record at its late read, right after it hands on the merges of 0054, and puts it in its matrix. In the one-step workflow the same step deploys it (0077). In the split workflow the second apply job of 0054 takes it. A scheduled scan, a dispatch, the rescan box and the scan after a merge from the dashboard never deploy on merge: a change they find has no merge to deploy on, and its row says it waits for a tick. The one exception is the stack a merge from the dashboard itself handed on: that deploy is the ticker’s, through the merge record, as before.
  • Nothing about the deploy forks. The record is opened by the same openRecord as a tick’s, on the scanned commit, with the diff hash the scan previewed. apply claims it, previews again, compares the hash and deploys, refuses a change that moved, ends the record, swaps the row and writes the trail line exactly as for a tick. The job, the concurrency group, settle, the post step, a timeout and dry-run all apply unchanged. The only new fact is who: the payload’s ticker holds whoever merged, and the added key onMerge: true says it was not a tick. The version stays 1. A reader that does not know the key reads a tick by that person, which deploys the same.
  • Whoever merged is the sender of the push, as GitHub names it: the person who pressed merge, an app that merges such as renovate[bot], or whoever pushed directly. Only a push whose ref is the default branch the payload names counts. An app is named as GitHub names it, and allowed: the repo put the stack on on-merge, and what may land on the default branch is the default branch’s rules. A push the payload names no sender for deploys nothing on merge.
  • A destroy always waits for a tick, whatever the setting says, and the row says so: this stack deploys on merge, and this change waits for a tick: it deletes or replaces a resource. The destroy alert exists because a destroy deserves a look (0024, 0062), and a setting that deploys without anyone looking must not reach it. A replace counts as a destroy, as the glossary already defines one: the old object goes away, with its data, its address or its identity, before or after the new one exists, and a database replaced by a changed name loses the same data as one deleted. A tracking change alone, an import, a forget or a move, is no destroy and goes.
  • A preview failure never deploys, as for every tick: the stack has no diff, so no hash and no record. Its row is a preview failure row, with no note.
  • Drift waits for a tick. A pending row whose hash covers drift is not deployed on merge, and a drifted row with nothing from the code never is: a drifted row is no change of the code, so no merge asked for it. Drift means reality moved, not the code. Someone changed something by hand, perhaps on purpose during an incident, and a deploy on merge would put it back as a side effect of an unrelated change. Repairing it is a decision about the real infrastructure, which is a person’s. The argument for repairing by itself, that the config says the code is the truth for this stack, holds for the code’s own changes and not for what someone did to the resources. The row says: the stack drifted, and a deploy would also put back what changed outside the code.
  • dependsOn and phases decide the order, by the rules of a tick. The decision hands the stacks that would go to the same planDeploys a tick’s judgement uses (0056), as if they were ticked together, with every other pending row as a change nobody ticked. Two stacks set to on-merge in one chain go out one layer per run: the first now, the next under a queued record with behind and onMerge, started by the resolve of the run settle starts. A stack set to on-merge whose dependency is deploying is queued behind it. A stack set to on-merge whose dependency has a change waiting for a tick, because it is on a tick, or because its own change waits, waits too, and its row names it, in the words of a refused tick: it depends on **network:prod**, which has a change waiting. Tick both to deploy them in order, or deploy **network:prod** first. With a phase, the phase is named. A dependency that is in sync, whose preview failed, or whose row this scan did not preview and is not pending holds nothing back, as 0056 says.
  • deploys: false and a read-only dashboard stop everything. With deploys: false nothing goes on merge and the row says this stack deploys on merge, and deploys are turned off in sluiceway.yaml. A read-only dashboard deploys nothing on merge and writes no note: it has no boxes, and the line under the Pending heading already says it is read only (0045).
  • On the dashboard a deploy on merge never looks like a stack nobody ticked, and never like a tick. While it goes out its row says waiting to start on merge or deploying on merge, then merged by alice where a tick says ticked by alice. Queued behind a stack it says queued behind **network:prod** · merged by alice. Its failure line says merged by. On the trail a ticked deploy names the ticker alone and a deploy on merge says merged by alice, with the result word before it as for any line (failed · merged by alice). The summary of apply and its job log line say merged by too.
  • A change that moved tells whoever merged, in one comment as for a tick (0051), in words that do not speak of a tick: @alice merged a change that **app:prod** deploys on merge, and the change moved before the deploy, so nothing was deployed. The row on the dashboard shows the change as it is now. Tick it to deploy that.
  • tickers keeps deciding who may tick the stack, and does not judge the merge. Every change of the stack that waits, a destroy, drift, a dependency, a scan that no merge started, is deployed by a tick, and the tick rule judges that tick as always. The merge itself is the ask, and who may merge is GitHub’s to decide: write access, and the branch protection and required reviews of the default branch. Judging the person who merged against tickers was considered and left out: it would take a permission lookup in every scan of a merge, it would refuse every app that merges, and it would put a gate that acts like branch protection behind a key that reads like a stack setting. A stack only named people may deploy stays on a tick.
  • A GitHub Environment with required reviewers still gates it, and that is the honest answer to who may deploy such a stack (0093). The deploy runs in the job that deploys ticks: in the split workflow the second apply job, a copy of apply that names the same environment. The reviewers approve a deploy on merge as they approve a tick, and until they do the record stays open and the row says waiting to start on merge. Without an environment, whoever may merge may deploy the stack, and the docs say so. In the one-step workflow an environment on the one job holds every run, as 0077 says.
  • The check lists it. A stack set to on-merge says deploys on merge on its line of the check, and the summary’s table gets a column Deploys only when a stack does. A split workflow with a stack set to on-merge and an apply job, but none that takes the scan’s matrix, gets a warning, because that deploy would never start. With merge and deploy on as well, the one warning is merge and deploy’s.

The promise, reworded

The product said: nothing deploys unless a person asks. It now says, everywhere it says it: nothing deploys unless a person asks, or unless the repo’s own config says this stack goes out on merge. The README, SECURITY.md, the security page, the configuration page, using the dashboard, the split workflow page and the glossary say it in those words. The setting lives in sluiceway.yaml and nowhere else, never on the dashboard or in an input, so turning a stack to on-merge is itself a reviewed change on the default branch.

Considered

  • On-merge as the default, as the tools compared with do it. Rejected by the owner, and it would break the promise for every repo that upgrades.
  • A top level deploy key for every stack at once. Left out: the point of the decision is that the gate belongs to the stack. A repo that wants every stack on merge writes an entry per path, which keeps the choice visible stack by stack.
  • Deploying on merge from any scan that finds the stack pending, a schedule included. Rejected: there is no merge to attribute it to, and a change a push scan could not preview, and that a later scan finds, is exactly the change a person should look at. It waits for a tick with a note.
  • Letting a replace through, since some replaces lose nothing. Rejected: the preview cannot tell which, and the glossary already calls a replace a destroy.
  • Repairing drift on merge. Rejected above.
  • A result file field or an output that says on merge. Left for later (docs/later.md): the result file keeps ticker as the name on the record, and its schema is published, so a field is a change of its own.
  • Its own header picture or result dot. Not needed: a deploy on merge is a deploy, and the words on the row and the trail tell it apart.

Consequences

  • The scan of a merge can now open deployment records and hand deploys on, for stacks set to on-merge. It already had deployments: write for the hand-off of 0054. In the split workflow it needs the scan’s outputs:, the second apply job and the settle of 0054’s section, and the docs say so.
  • The deploy runs with the credentials of the job that scanned, in the one-step workflow, as every tick of that workflow already does.
  • The queued-record hand-off carries onMerge by name, the way 0091 made it carry drift, and a test holds it.
  • CONTEXT.md gains Deploy on merge, and the ticker, the queued stack and the trail say how a deploy on merge differs.