Module 5: Apply On Merge The Cd Half

5. Concurrency control: avoiding the double `apply`

Description

apply.yml already works end-to-end, but it has a gap that doesn't show up until two people merge two Pull Requests around the same time: nothing stops it from running twice in parallel, each run with its own terraform apply, against the same state. This lesson closes that gap with concurrency:, the block that turns "any push to main triggers a new run" into "only one apply.yml run at a time, never two writing simultaneously."

Connection to the module

This lesson extends lesson 4's apply.yml with a single new piece, without touching any existing job. Lesson 6 changes topics —drift detection—, but the file you build here remains, forever, the apply.yml the rest of this guide uses.


Analogy: one cashier per register

Imagine a cash register with a single money drawer. If two cashiers tried to operate the same register at the same time —one collecting payment, another giving change, both opening the drawer at once— the final money count would end up wrong, without either one having made an individual mistake: the problem is that two people touched the same shared resource at the same time. The fix isn't "each cashier counts more carefully" — it's that only one cashier at a time has access to that specific register, no matter how many cashiers exist in the store. concurrency: is exactly that rule, applied to Terraform's state: no matter how many pushes to main happen, only one apply.yml run can be writing to Andes Cargo's state at a time.


Why this is a real risk, not a theoretical one

You already know the root cause from terraform-and-iac-guide: state is Terraform's source of truth, and two processes writing it at the same time can corrupt it or leave it inconsistent with real infrastructure —the same lock risk that guide already taught in its Module 4—. There, that risk was hypothetical: you were the only person running terraform apply, from your own laptop, one at a time, by definition. With apply.yml running automatically on every push to main, the risk becomes real for the first time in this guide:

   WITHOUT concurrency:                       WITH concurrency:

   push #1 to main ──► apply.yml run A        push #1 to main ──► apply.yml run A (runs)
   push #2 to main ──► apply.yml run B            │
        (almost simultaneous)                      │  push #2 to main ──► apply.yml run B
                                                     │       (queued, waits for A to finish)
   A and B both try to write                        ▼
   the same state.tfstate                     B starts only once A finishes
   AT THE SAME TIME                           (or gets canceled, per cancel-in-progress)

   real risk of state lock /                 a single run writing
   corruption / inconsistent result          the state at a time, always

Two different Pull Requests, approved and merged a few minutes apart, trigger two independent apply.yml runs — each one, without concurrency:, would start running immediately, with no idea the other exists.


The concurrency: block

name: apply

on:
  push:
    branches: [main]

concurrency:
  group: apply-andes-cargo-infra
  cancel-in-progress: false

Two fields, each with a decision behind it:

  • group: — a free-text identifier that groups related runs. GitHub Actions guarantees that, among all workflow runs sharing the same group, only one can be "in progress" at a time —the rest stay queued, waiting—. Here, the value is fixed (apply-andes-cargo-infra) because every push to main in this repository should compete for the same slot — it makes no sense for two apply.yml runs over the same state to run in parallel, no matter which Pull Request originated them.
  • cancel-in-progress: false — the most important decision in this block, and the one most worth reasoning through. true would cancel the run in progress as soon as a new one from the same group arrives; false —the value apply.yml uses— says, instead, "let the current run finish, and only then start the next one, in order." For an application deployment (a container image, a static site) canceling an old run in favor of the newest one is usually the right call: the intermediate version doesn't matter, only the latest one does. For a terraform apply halfway through, canceling would be worse than waiting: an apply interrupted midway can leave the state reflecting only some of the resources, with real infrastructure sitting at an intermediate point not represented in any plan — exactly the kind of inconsistency this whole lesson is trying to avoid. That's why apply.yml chooses false: every run completes its work before the next one starts, no exceptions.

Running it: the YAML is valid, the job runs the same

concurrency: doesn't change anything about how an individual job runs under act — it only confirms the parsing is correct and the job still works with the block added:

act -l -W .github/workflows/apply.yml

What to expect (literal) — the same table as always, with no new column related to concurrency::

Stage  Job ID                Job name              Workflow name  Workflow file  Events
0      fetch-reviewed-plan   fetch-reviewed-plan   apply          apply.yml      push
1      terraform-apply       terraform-apply        apply          apply.yml      push
act push -W .github/workflows/apply.yml --artifact-server-path ./.artifacts --artifact-server-addr "$ARTIFACT_ADDR"

The result is identical to lesson 4's: fetch-reviewed-plan runs and succeeds, terraform-apply runs and fails at the exact same point (connection refused without LocalStack) — concurrency: doesn't change an individual run's behavior at all, because its effect only exists when more than one run is competing for the same group, something a single act invocation never produces.


Why act can't prove the lock (representative, with the exact reason)

Here comes the honesty you'd already expect from this guide. concurrency: is a mechanism GitHub Actions applies between workflow runs, coordinated centrally by GitHub's infrastructure —a shared queue that knows, at every moment, how many runs of a specific group exist and what state they're in—. Every act invocation on your machine is a completely isolated process: there's no central server, no shared queue, no way for two act invocations, run separately, to know about each other. Even if you opened two terminals and ran act push -W apply.yml in both at the same time, each would run completely independently, with no lock between them —act simply doesn't implement the group concept at all—.

This isn't a gap specific to this module: it's the same family of limitation you already saw with environment: and required reviewers in Module 4 (lesson 6) — a real, correct mechanism, necessary in production, that depends on centralized coordination that only exists on real GitHub.com. concurrency: joins that list: it works exactly as described here on a real repository, and there's no honest way to prove the lock with local act.


Going deeper: what would happen on a real repository

So the theory doesn't stay abstract, it's worth precisely describing what you'd see on real GitHub.com, even though you can't reproduce it here. If you merge Pull Request A and, thirty seconds later, someone else merges Pull Request B:

  1. apply.yml's run for A starts immediately, with status "In progress."
  2. apply.yml's run for B also triggers —the push event happened, GitHub queues it— but instead of starting to run, its status in the Actions tab shows "Queued," with an explicit note indicating it's waiting for an earlier run from the same group to finish.
  3. When A's run finishes —success or failure, doesn't matter which— B's run starts automatically, with nobody having to retry it by hand.

No run gets lost, none gets canceled (because of cancel-in-progress: false), and at no point are there two terraform applys writing to the same state simultaneously.


Common mistakes

Setting cancel-in-progress: true "because it sounds more efficient" (this lesson's central mistake). What happens: someone, familiar with application-code CI patterns —where canceling an old build in favor of a new one is normal and desirable—, copies that same value to apply.yml. Why it happens: in most CI/CD pipelines people know (tests, builds), canceling the old for the new is the norm. How to spot it: if your apply.yml has cancel-in-progress: true. How to fix it: for infrastructure, an apply canceled halfway through can leave resources half-created and the state reflecting only part of what really exists — a much worse problem than waiting a few extra minutes in the queue. false is the correct choice specifically for this file.

Using a different group: per Pull Request (design-based). What happens: someone writes group: apply-${{ github.event.pull_request.number }} or something similar, thinking each change needs its own group. How to spot it: if your group: includes any variable that changes between different Pull Requests. How to fix it: concurrency:'s purpose here is protecting the shared state, not isolating each Pull Request — a group that varies per PR would let two applys from different PRs run in parallel anyway, exactly the problem this lesson solves. apply.yml's group must be fixed, a single one, shared by every push to main in this repository.

Expecting to see the lock proven under act (expectation-based). What happens: someone tries running two act push instances in parallel, expecting to see one queued. How to fix it: as this lesson explained, act doesn't implement group at all — every invocation runs completely isolated. Trust that the mechanism works on real GitHub, without trying to prove it locally.


Exercises

Exercise 1 — Explain cancel-in-progress: false with the cashier analogy. Without looking at this lesson, explain why "the cashier finishes the current transaction before the next customer can start" is a better analogy for apply.yml than "the most recent customer goes first."

See solution

A complete answer sounds, roughly, like this: "If the second customer 'cut into' the first one's transaction halfway through, the register would end up with an incomplete charge — neither is the first customer's money properly counted, nor can the second customer start on a consistent register. With terraform apply, canceling a run halfway through leaves the state reflecting only some of the resources that were being created, with no clarity on which ones — much worse than making the second run wait a few minutes until the first one finishes cleanly."

Exercise 2 — Decide the right group for a second project. If Andes Cargo had a second, completely independent Terraform project —for example, andes-cargo-analytics/, with its own state, in a different repository—, should it share the same group: as andes-cargo-infra/? Justify your answer.

See solution

No. group:'s purpose is preventing two runs that write to the same state from running in parallel — if andes-cargo-analytics/ has its own, completely separate state, there's no risk of conflict between an apply from that project and one from andes-cargo-infra/; forcing them into the same group would only make one wait on the other unnecessarily, with no security benefit. Each independent state should have its own group, typically named so it identifies the specific project.

Exercise 3 — Predict the status of a queued run. On a real GitHub repository, if you merge two Pull Requests thirty seconds apart, what would you see in the Actions tab for apply.yml's second run while the first one is still applying?

See solution

You'd see the second run with status "Queued," with an indication that it's waiting because another run from the same group (apply-andes-cargo-infra) is still in progress. It wouldn't start, wouldn't get canceled, wouldn't fail — it would simply wait, and would start automatically as soon as the first run finishes, whatever its result.


Summary and next step

In this lesson you added concurrency: { group: apply-andes-cargo-infra, cancel-in-progress: false } to apply.yml, understood why two simultaneous terraform applys against the same state are a real risk —the same lock risk terraform-and-iac-guide already taught, now possible for the first time because apply runs automatically—, and confirmed the YAML is valid and the job still runs the same under act. You also saw, with the honesty you'd already expect from this guide, why act can't prove the real lock: it's a centralized GitHub coordination mechanism, with no equivalent in an isolated local simulation.

Before moving on you should be able to: write the concurrency: block from memory; explain why cancel-in-progress: false is the right choice for infrastructure, different from what you'd choose for an application pipeline; and describe, without being able to prove it with act, exactly what you'd see on real GitHub when two runs compete for the same group.

apply.yml is complete with this lesson: correct trigger (lesson 2), jobs chained with the exact plan (lessons 3 and 4), and protected against simultaneous runs (this lesson). Lesson 6 changes topics: detecting changes that happen outside this pipeline entirely.

Resources

  1. GitHub Docs — Using concurrency — complete official documentation for concurrency:, group:, and cancel-in-progress:.
  2. terraform-and-iac-guide, Module 4 (NIEVA) — the original explanation of Terraform's state lock risk, revisited here in the context of an automated pipeline.
  3. This guide's Module 4 (06-github-environments-dev-and-prod.md) — the same family of act limitation (centralized GitHub coordination mechanisms, not provable locally).