When a GitHub Actions workflow runs and commits back to the repo, that commit can trigger other workflows listening on push. If any of those workflows also commit, the recursion continues. This is the publishing loop problem, and it is not hypothetical — this repo runs five push-triggered pipelines that all commit as part of their work.
GitHub's documented safety net: workflows triggered by pushes using the built-in GITHUB_TOKEN don't recursively trigger other GITHUB_TOKEN-based workflows. That's a useful default. But it only covers one failure mode, and only when the default token behavior holds. Here are four patterns I use to handle the rest.
Pattern 1: Self-marking commits with [skip publish-articles]
The article publish workflow runs on push. When it finishes, it commits OG image updates back to the repo:
chore(og): regenerate OG + summary images [skip publish-articles]
The workflow's push trigger has this job-level condition:
if: "!contains(github.event.head_commit.message, '[skip publish-articles]')"
If that chore commit somehow triggered the publish workflow again — via a workflow_dispatch being invoked by something else, or a future change to how GITHUB_TOKEN propagates — the [skip publish-articles] tag short-circuits it. The workflow skips before starting any real work.
The tag is belt-and-suspenders. GitHub's default behavior should prevent the recursion without it. But the failure mode it prevents — the publish workflow spending API calls and platform rate limits processing a commit that isn't a new article — is easy to understand and hard to diagnose retroactively.
Pattern 2: Bluesky queue commits that skip themselves
The bluesky-queue workflow posts queued content to Bluesky and then records the posted item:
chore(bluesky): mark queued post as posted (+ QC rejects) [skip bluesky-queue]
The workflow's push trigger checks:
if: "!contains(github.event.head_commit.message, '[skip bluesky-queue]')"
Without that tag, a push from any pipeline could become the head commit that bluesky-queue sees. If the queue has something pending, the workflow fires and posts it — potentially ahead of schedule or out of order. The skip tag makes the Bluesky queue workflow's own commits invisible to its own trigger.
This matters because the Bluesky queue, the article publisher, and the VoC collection pipeline run on overlapping cron schedules. Multiple commits can stack in a short window, and the head commit at push time may belong to a different workflow than the one being guarded.
Pattern 3: Social-graph commits isolated from content pipelines
The Bluesky follow and unfollow workflows log their work to JSONL files and commit:
chore(bluesky): record follow batch [skip bluesky-follow]
chore(bluesky): record unfollow batch [skip bluesky-unfollow]
These commits have nothing to do with articles or YouTube content. But they are still commits to main, which is what content pipelines watch on push. The skip tags tell each social-graph workflow: if you see a commit you generated, do not run again.
The namespace property is worth noting: [skip bluesky-follow] doesn't affect the article publisher; [skip publish-articles] doesn't affect the follow workflow. Each pipeline owns its own tag. This prevents one pipeline's skip logic from accidentally silencing another.
Pattern 4: Manual dispatch overrides stay alive
The yt-publish workflow has a comment worth quoting:
Make the
[skip yt-publish]tag real: the bot's own queue-move/expire commits carry it, so even if a push ever re-triggers this workflow (e.g. not via GITHUB_TOKEN's no-recursion path), it won't double-publish. Manualworkflow_dispatchhas nohead_commit, so this stays true and still runs.
The contains(github.event.head_commit.message, ...) check is safe for workflow_dispatch triggers because head_commit is null on manual dispatch. The condition evaluates to false — and since the full condition is !contains(...), false negates to true, and the workflow runs. Manual interventions bypass the skip logic automatically, without needing any special handling.
This is the right default: skip tags suppress automated push cascades but not deliberate human triggers.
The failure mode this prevents
None of these patterns are defensive against a sophisticated attack or a complex race. They solve one specific problem: an automated commit that triggers the workflow that generated it.
The cost of not having them is concrete. Scan collision failures on a headless Pi are an embedded-hardware version of the same shape: a process generates output, a second concurrent run fires because of that output, and the two runs corrupt shared state. The commit-message skip tag is the CI equivalent of a lock file — it prevents the specific failure where a pipeline triggers itself through its own output.
The broader lesson: automated pipelines that commit back to the repo need an identity for their commits. The [skip X] tag is one form of that identity. It lets every workflow answer the question "was this commit caused by me, or by something I should act on?" at job-start time, before any API calls or platform resources are consumed.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)