Module 5: Apply On Merge The Cd Half
2. The merge trigger: `push` to `main`
Description
This lesson dissects the first field you're going to write in apply.yml: on: push: branches: [main]. You already know push from Module 2 (lesson 3) — what's new here isn't the syntax, it's the decision it represents: why this specific trigger, with this specific filter, is the only door apply.yml can have, and why Module 2's finding —act doesn't evaluate branches:— matters far more in this file than in any other you've written so far.
Connection to the module
Lesson 1 gave you the complete map. This lesson builds apply.yml's first real line: its on block. Lesson 3 continues with jobs and needs; lesson 4 puts it all together in the complete file, run with act push. Everything you read here becomes, literally, the first three lines of the file you're going to build.
Analogy: the door that only opens from the inside
A pull_request is someone ringing the doorbell from outside —anyone can propose a change, without that meaning they got in—. A push to main, on the other hand, is someone who's already inside the building, walking toward the vault door: they already passed access control (the Pull Request review, Module 3), they were already authorized to be in that zone (the merge). apply.yml, by listening only for push to main, is the vault door that only responds to someone who already crossed every earlier checkpoint — never to someone who just rang the bell.
apply.yml's on block
on:
push:
branches: [main]
Three pieces, each with a precise role:
push— the correct trigger for "something already landed on a branch," which you already saw in Module 2 (lesson 3). Unlikepull_request, there's no ambiguity about whether the code "is already there" — apushevent only exists after the commits have actually reached the branch.branches: [main]— the filter that narrows that trigger from "any branch" to, exclusively,main. Without this filter, apushtofeature/whateverwould triggerapply.ymlwith the same force as apushtomain— something this file, by design, must never allow.- The absence of
pull_request— as important as what's actually written.apply.ymldoesn't have, and never will have, apull_request:block in itson. It's the exact structural separation Module 3 (lesson 2) explained with the two-doors analogy:ci.ymllistens forpull_requestand never applies;apply.ymllistens forpushtomainand never runs on an unreviewed proposal. There's noif:condition in between deciding this — it's a property of the file itself.
Why main, not any other branch
The branch name matters for a very concrete reason specific to this guide: git init -b main (Module 1, lesson 8) fixed main as the default branch exactly so this filter would make sense. If your repository used master, or any other name, this branches: [main] would simply never trigger — it wouldn't fail with an error, it would just sit waiting for an event that never arrives. It's worth confirming, not assuming: your real repository's branch has to be named exactly main for everything that follows in this lesson to make sense.
cd andes-cargo-infra
git branch --show-current
What to expect (literal, if you followed Module 1 without deviating):
main
Module 2's finding, now with real consequences
In Module 2 (lesson 3) you discovered, with a real test, that act doesn't evaluate branches:/paths: before running a job — that filter is a server-side GitHub mechanism, and act, having no server involved at all, simply ignores it. At the time, the consequence was abstract: "this is going to matter for apply.yml." Now that you're writing that file, the consequence is direct.
Confirm it again, this time on apply.yml's real trigger. A minimal workflow, with the exact same on you're going to use in lesson 4:
name: apply-trigger-test
on:
push:
branches: [main]
jobs:
would-apply:
runs-on: ubuntu-latest
steps:
- run: echo "This step would run terraform apply, on ref ${{ github.ref }}"
A hand-written push event, describing a push to a feature branch — exactly the same naming pattern you've already used in pr-event.json since Module 2:
{
"ref": "refs/heads/feature/add-shipment-tags",
"repository": { "default_branch": "main" }
}
act push -e push-feature-event.json -j would-apply
What to expect (literal output, executed to write this lesson):
[apply-trigger-test/would-apply] ⭐ Run Main echo "This step would run terraform apply, on ref refs/heads/feature/add-shipment-tags"
[apply-trigger-test/would-apply] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/0] user= workdir=
[apply-trigger-test/would-apply] | This step would run terraform apply, on ref refs/heads/feature/add-shipment-tags
[apply-trigger-test/would-apply] ✅ Success - Main echo "This step would run terraform apply, on ref refs/heads/feature/add-shipment-tags" [89.622542ms]
[apply-trigger-test/would-apply] ⭐ Run Complete job
[apply-trigger-test/would-apply] Cleaning up container for job would-apply
[apply-trigger-test/would-apply] ✅ Success - Complete job
[apply-trigger-test/would-apply] 🏁 Job succeeded
The job ran. github.ref is refs/heads/feature/add-shipment-tags — exactly the branch branches: [main] should have blocked. If this were the real apply.yml, with a terraform apply step instead of an echo, this command would have just applied infrastructure from an unreviewed feature branch, under act, with nothing warning you about the problem.
Contrast it with act's default event, which —as you confirmed in Module 2 (lesson 2)— derives the ref from your actual Git branch:
act push -j would-apply
What to expect (literal, run inside a repository whose current branch is main):
[apply-trigger-test/would-apply] ⭐ Run Main echo "This step would run terraform apply, on ref refs/heads/main"
[apply-trigger-test/would-apply] | This step would run terraform apply, on ref refs/heads/main
[apply-trigger-test/would-apply] ✅ Success - Main echo "This step would run terraform apply, on ref refs/heads/main" [102.859958ms]
[apply-trigger-test/would-apply] 🏁 Job succeeded
There's no mystery here: act push, without -e, builds a synthetic event from your current local branch —if you're standing on main, the event says main—. The difference between this run and the previous one isn't that act "learned" to respect the filter; it's that, this time, the synthetic event happened to match what the filter allows. The filter still isn't evaluated in either case — it's just that, when you work from main (which is exactly how you're going to run apply.yml in lesson 4), the difference doesn't show up.
The practical consequence, without exaggerating or minimizing
branches: [main] in apply.yml is still the correct, mandatory protection in a real GitHub repository — it works exactly as you'd expect there, blocking any push that isn't to main before the job even starts. What this lesson confirms, again, is that act can't prove that filter works — it can confirm the YAML is valid, that the job does what the rest of the file says it does, but it can't simulate the server-side rejection. It's the exact same distinction Module 2 already established, now applied to the file where it matters most: if you ever need to truly verify that apply.yml rejects a push to a feature branch, the only reliable way is a real GitHub repository — a topic Module 8 (lesson 5) picks back up.
Common mistakes
Trusting act to validate that apply.yml is properly protected (this lesson's central mistake). What happens: someone runs act push on apply.yml from a feature branch, sees the job run, and concludes the branches: [main] filter "doesn't work" or that an extra if: condition needs to be added. Why it happens: it's reasonable, though incorrect, to expect a tool that simulates GitHub Actions to replicate every GitHub Actions mechanism. How to spot it: if your conclusion after running act on a feature branch is "the YAML is wrong," instead of "this is exactly the simulation limit I already know from Module 2." How to fix it: the filter is written correctly. Trust branches: [main] in apply.yml the same way you trust any piece of GitHub Actions act can't run in full —documented, correct, simply not provable locally—.
Forgetting that main has to match your branch's real name exactly (configuration-based). What happens: someone clones or creates a repository where the main branch is named master (old Git versions' default, or some global configurations), and apply.yml simply never runs, with no error message at all. How to spot it: git branch --show-current shows something other than main, and no real push triggers apply.yml in a real GitHub repository. How to fix it: review Module 1 (lesson 8) — git init -b main fixes this from the start; if your repository already exists under a different name, rename the branch (git branch -m master main) before continuing, or adjust branches: to match the real name, though this guide assumes main throughout its examples.
Adding pull_request to apply.yml's on "just in case" (design-based, revisit Module 3). What happens: someone, thinking more triggers give more flexibility, adds pull_request: alongside push: in apply.yml's on. Why it happens: it feels convenient to be able to "test" the apply from a Pull Request before merging. How to fix it: this breaks exactly the structural separation Module 3 (lesson 2) explained as a security decision, not a convenience one — it reintroduces the risk of an unreviewed Pull Request triggering a real apply. If you need to "test" the apply before merging, the right tool is reviewing the plan ci.yml already publishes, not giving apply.yml the ability to run on a proposal.
Exercises
Exercise 1 — Predict the outcome of three different events. Without running anything yet, for an apply.yml with on: push: branches: [main], predict whether the job would run under act (not on real GitHub) for: (a) act push from the local main branch; (b) act push -e event.json with "ref": "refs/heads/main"; (c) act push -e event.json with "ref": "refs/heads/hotfix/urgent".
See solution
All three would run under act — in all three cases, the event type (push) matches what's declared in on, and act doesn't evaluate branches:'s content in any of them. The difference between (a)/(b) and (c) would only matter on real GitHub, where (c) would be rejected before a runner is even assigned.
Exercise 2 — Explain the absence of pull_request to a colleague. A colleague, looking at apply.yml, asks you why it doesn't have a pull_request: block like ci.yml, since "it would be useful to test the apply before merging." Answer them with the exact security reason.
See solution
A complete answer sounds, roughly, like this: "If apply.yml listened for pull_request, any unreviewed Pull Request could trigger a real terraform apply, exactly the 'pwn request' scenario Module 3 explained — the separation between 'calculate and show' (ci.yml, triggered by pull_request) and 'apply' (apply.yml, triggered only by push to main) is what guarantees no unreviewed change can touch real infrastructure. It's not a technical limitation, it's the central security decision of this entire module."
Exercise 3 — Diagnose an apply.yml that never runs on real GitHub. A colleague tells you: "on my real GitHub repository, I push to my main branch and apply.yml never triggers, even though the file exists and the YAML is valid." What's the first question you'd ask them, based on this lesson?
See solution
The first question would be: "is your main branch named exactly main?". If the repository uses master (or any other name) as its default branch, branches: [main] will never match, and GitHub simply won't trigger the workflow — with no visible error, because from GitHub's perspective, the real event (push to master) never satisfied the filter's condition. The fix is renaming the branch to main or adjusting the filter to match the real name.
Summary and next step
In this lesson you wrote apply.yml's on block —push to main, no pull_request— and confirmed, with a real test, that act still doesn't evaluate branches: even in this specific file: a synthetic push to a feature branch ran the job anyway, exactly as Module 2 predicted. You also saw that act push's default event derives its ref from your local Git branch, so working from main —as you're going to do in lesson 4— makes this limitation invisible in practice, even though it still exists.
Before moving on you should be able to: write on: push: branches: [main] from memory; explain why apply.yml should never have pull_request in its on; and say, without hesitation, that truly verifying that filter requires a real GitHub repository, not act.
Lesson 3 continues with apply.yml's second piece: how to structure its jobs so apply depends on an earlier step's success, and how to pass it the exact plan ci.yml already calculated.
Resources
- GitHub Docs — Events that trigger workflows:
push— official documentation for thepushtrigger and thebranches:filter. - nektosact.com — User Guide — official documentation for
act -eand the default synthetic event. - This guide's Module 2 (
03-triggers-push-pull-request-and-workflow-dispatch.md) — the original finding aboutbranches:/paths:, revisited here with direct consequences. - This guide's Module 3 (
02-the-hashicorp-github-pattern.md) — the complete security reason behind separatingci.ymlfromapply.ymlinto two files.