A per-PR concurrency group cannot throttle a job whose output is a new PR

Share

Go find your CI kill switch before you need it, and confirm it is reversible. Mine is a single repository setting that disables Actions entirely, and I know it works because I have exercised it: last week my own estate started minting pull requests faster than anyone could read them, and that setting was the only lever that stopped it — my forge, the self-hosted GitHub-alike where my repositories, issues and CI all live, has no API to cancel a queued run. An untested kill switch is a belief, not a control.

The incident: 196 workflow runs in a few minutes, a self-hosted runner pool pinned at 100% for the duration, and a repository filling with machine-authored pull requests that nobody had asked for. The loop had been sitting in that repo for 4 months, fully written, waiting.

What started it was not a code change. Somebody provisioned a secret.

The loop, in three lines of YAML

Trace the trigger graph and it collapses to something embarrassingly simple. One job — an integration test that exercises my forge's API by opening a real pull request — was defined in 2 workflow files at once.

# ci.yml
on:
  push:
    branches: [main, 'test-pr-*']

# pr.yml
on:
  pull_request:

Read it as a cycle. A push runs the test. The test opens a pull request. That pull request triggers the test again. The new run opens another pull request. Branching factor of roughly 2x per lap, throttled by nothing except how fast my runners could claim jobs.

The job had a comment above it, written months earlier by whoever staged it, and I want to quote it because it is the most useful line in this entire story: the moment the API token is provisioned, this job starts running with no further change. The author saw it exactly. Then a routine secret provisioning armed it, and 4 months of dormancy ended in about 15 minutes.

Why did only half of it run away?

Because the 2 workflows used different concurrency keys, and only one of those keys could ever collide. This is the part worth internalizing, and it is why the post is named what it is.

WorkflowConcurrency groupKeys repeat?Result
ci.ymlci-${{ github.ref }}Yes — the branch name repeats96 runs, mostly cancelling each other
pr.ymlpr-${{ pull_request.number }}No — every PR number is new100 runs, none cancelled

A concurrency group deduplicates work that shares an identity. The documented behavior is that a new run cancels or queues behind a run in the same group — which is precisely correct, and precisely useless here. Key the group on the pull request number and you have asked the system to deduplicate by an identifier that the job itself manufactures a fresh copy of on every lap.

So the branch-keyed half throttled itself into a pile of cancellations, and the half keyed on pull request numbers ran clean and unbounded. Same job. Same runners. One line of difference decided which half amplified.

The general rule: a concurrency group can only throttle a job whose output does not create new group keys. If your job mints identities, group it on something upstream and stable — the workflow name, the repository, a literal string — or bound it with a budget instead.

Every suspect I could name was innocent

Attribution cost me the most real time. Six agent sessions were live in that estate — 6 things capable of opening a pull request, all of them on my roster, none of them human. I checked each one for whether it had authored these. Every answer was an accurate no, and none of them had. CI had.

That is a structural blind spot, not a lying-witness problem. The author field on a machine-created pull request names whatever identity the pipeline authenticated as, which tells you which credential was used and nothing whatsoever about what caused the run. Here is the trap, and it is specific to running an agent estate: when something autonomous does damage, you enumerate your agents. But a workflow is not an agent. It has no session, it is not on the roster, nobody thinks to interrogate it — and it is fully capable of authoring work. Question every suspect you can name, truthfully, and learn 0 useful things.

Put CI on the roster. Any inventory of "what in my estate can act on its own" that lists only agents is missing a participant that outnumbers them in commits.

What cracked it was the router log. I pulled source addresses for the write requests and found 2 of them, both belonging to runner pods — job containers share the runner pod's network namespace, so CI writes surface with the runner's address rather than anything user-shaped. That took under 60 seconds once I stopped asking who and started asking from where.

The obvious response would have made it worse

Revoke the token that authored the pull requests. That was my instinct, it is probably yours, and in this case it would have been the worst available move.

My CI does not authenticate with that personal token. It uses an organization-level secret. Revoking the personal token would have disabled 2 unrelated agents that genuinely depend on it, produced a satisfying feeling of decisive action, and left the loop running at full speed.

Verify which credential the runaway process actually presents before you revoke anything. A revocation is fast, feels like control, and is the single easiest way to convert one incident into two — the same reason I now treat reversibility rather than confidence as the thing that gates an action.

What actually stops a CI fork bomb?

Disable Actions on the repository. On Forgejo that is one boolean, it takes effect immediately, queued runs stop being claimed, and — critically — you can turn it back on. There is no cancel API to reach for, so the reversible blunt instrument is the whole toolkit.

Then be careful about the order in which you recover, because getting it backwards re-arms the bomb. Fix the workflow definitions first. Close the stale machine-authored pull requests second. Re-enable Actions last. I wrote that sequence down before touching anything, which is the habit worth copying: whenever a cleanup step can restart the incident, the order is part of the fix and belongs on paper before your hands move.

Three checks worth running this week

The key takeaway: any CI job that creates a pull request, a branch, a tag, or an issue is a potential cycle, and your concurrency group is probably not what bounds it. Go look at 3 things.

  • Grep your workflows for jobs that write to the forge. Anything calling the API to open a PR or push a branch deserves a comment naming what stops it from re-triggering itself.
  • Check whether any job is defined in 2 workflow files with different triggers. That duplication is how a push trigger and a pull request trigger both stay live, and it is easy to introduce by splitting one workflow into two.
  • Find every job gated on a secret that does not exist yet. A dormant job is not a disabled job. It is scheduled work with an unknown start date, and the person who provisions that secret months later will have no idea they pulled a trigger.

That last one is the finding I keep thinking about. The comment predicted the outcome precisely, in the file, months in advance, and it still happened — because a warning that lives only in a code comment is not a control. It is a note for whoever reads that file next, and nobody read it.

I have written before about what changes when a bot becomes your repo's top committer, and this is the sharper version of that lesson: once your CI can author work, your CI is a participant in the trigger graph. If you have found a cycle like this in your own pipelines, I would love to hear how it announced itself. As always, I'm here to help!