Your release tag will not start the workflow waiting for it
Shipped
0.4.1 changed one trigger. A fan-out workflow that pushes generated design tokens into consumer repositories listened on push: tags: ["press-v*"], which reads as the obvious way to react to a release. The release tag is created by another workflow in the same repository, using the built-in GITHUB_TOKEN, and GitHub does not start workflow runs for events created with that token. The trigger was syntactically perfect and had executed zero times.
That rule is worth knowing before you write any workflow that reacts to what another workflow did, because it fails silently: no error, no skipped run, no annotation. This guide builds the trap on purpose, shows the one command that proves a trigger is dead, and replaces it with a call chain that runs in the same job graph.
The loop-prevention rule you are working against
Every workflow run gets an automatic GITHUB_TOKEN for authenticating to the repository. GitHub’s documentation on triggering a workflow states it plainly: “When you use the repository’s GITHUB_TOKEN to perform tasks, events triggered by the GITHUB_TOKEN will not create a new workflow run, with the following exceptions”. The exceptions are workflow_dispatch and repository_dispatch, which always fire.
This is deliberate loop prevention. A workflow that commits, whose commit triggers itself, is a billable infinite loop, and the same docs warn to “ensure that you don’t create recursive or unintended workflow runs”. The cost is that a perfectly reasonable pipeline, tag from job A and react in workflow B, quietly does nothing.
Set up: a release workflow that creates a tag
Put this in .github/workflows/release.yml. It reads a version from a file and tags the commit. Nothing exotic: contents: write plus the default token is all a tag needs.
name: release
on:
push:
branches: [main]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- name: Cut the tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=$(jq -r .version package.json)
if git ls-remote --exit-code --tags origin "v$version" >/dev/null 2>&1; then
echo "v$version already tagged"
exit 0
fi
git config user.name "release[bot]"
git config user.email "[email protected]"
git tag "v$version"
git push origin "v$version"
The trigger that looks right and never fires
Now the downstream workflow. It is meant to run after every release: publish docs, notify a channel, push generated files into other repositories, whatever your fan-out is.
name: fan-out
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
fan-out:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "fanning out $GITHUB_REF_NAME"
Merge both, let release.yml cut a tag, and look at the Actions tab. You will see the release run. You will not see a fan-out run, and there is nothing on the page that says why.
Prove the trigger is dead, do not reason about it
The check is one command, and it is worth running against every workflow you believe is event-driven. gh run list takes a --workflow filter and, with --json, will report the event that started each run:
gh run list --workflow=fan-out.yml --limit 20 \
--json createdAt,event,conclusion \
--jq '.[] | [.createdAt, .event, .conclusion] | @tsv'
Two outcomes matter. Empty output means the workflow has never run at all. Output where every row says workflow_dispatch means it only ever runs when a human clicks it, which is the shape a token-blocked trigger leaves behind. On the release this was found on, that command returned exactly one row, and its event was workflow_dispatch.
Compare that against the tag actually existing:
git log -1 --format='%ci' v1.4.0
gh run list --workflow=fan-out.yml --json event --jq 'length'
A tag with a timestamp and a run count of zero is the whole diagnosis.
Chain the workflows instead of triggering one from the other
The fix is to stop relying on an event and put the second workflow in the first one’s job graph. GitHub’s reusable workflows do exactly this: a workflow whose on includes workflow_call can be invoked by another workflow’s job.
Give the downstream workflow a workflow_call trigger, and keep the manual and scheduled entry points, which were never affected:
name: fan-out
# NOTE ON THE TRIGGER. Deliberately not `push: tags: ["v*"]`. The release tag is
# created by release.yml with the repository's GITHUB_TOKEN, and GitHub does not
# start workflow runs for events created with that token, so a tag trigger here
# would look correct and never once execute.
on:
workflow_call:
inputs:
dry-run:
description: "Report what would change without pushing"
type: boolean
default: false
secrets:
FAN_OUT_TOKEN:
description: "PAT with repo scope on the downstream repositories"
required: false
schedule:
- cron: "0 13 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
fan-out:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- env:
DRY_RUN: ${{ inputs.dry-run }}
GH_TOKEN: ${{ secrets.FAN_OUT_TOKEN || secrets.GITHUB_TOKEN }}
run: |
echo "fanning out (dry-run=${DRY_RUN:-false})"
Then append this job to release.yml’s jobs: map. A reusable workflow is referenced at the job level with uses:, never from inside a step:
fan-out:
needs: release
uses: ./.github/workflows/fan-out.yml
secrets:
FAN_OUT_TOKEN: ${{ secrets.FAN_OUT_TOKEN }}
That is the entire change. The fan-out now runs as part of the release run, because it is a job in that run rather than a reaction to an event the token was not allowed to raise.
Use it, then verify it
Push a version bump to main and watch the release run. The chained job appears inside it:
gh run list --workflow=release.yml --limit 1 \
--json databaseId --jq '.[0].databaseId'
gh run view <id> --json jobs --jq '.jobs[].name'
You should see the release job and the called workflow’s job listed under the same run id. The distinction to internalize is that a called workflow’s jobs belong to the caller’s run, so gh run list --workflow=fan-out.yml will still show nothing new. That is expected now, and it is why the “is this workflow alive?” check has to look at the caller once you chain.
For the dry-run path, dispatch it by hand:
gh workflow run fan-out.yml -f dry-run=true
gh run watch
Gotchas
Manual dispatch always works, so hand-testing hides the bug. workflow_dispatch is one of the documented exceptions to the token rule, which means the exact way you would sanity-check a new workflow is the one path that cannot reproduce the failure. Symptom: the workflow runs perfectly every time you click it, and never once on its own. Escape: after any release, check the run list and read the event column, not the conclusion column.
A scheduled fallback turns “dead” into “late”, which is harder to see. This workflow also had a weekly schedule, so the fan-out did happen, up to seven days after each release. Nothing was ever missing, so nothing looked broken; releases were just landing downstream a week later. Symptom: the automation appears to work but always at the wrong time. Escape: if a workflow has both an event trigger and a schedule, confirm the event trigger separately, because the schedule will paper over it.
Secrets do not cross into a called workflow on their own. A workflow_call workflow sees only the secrets the caller passes explicitly or with secrets: inherit, and the docs note that environment secrets cannot be passed at all, because on.workflow_call has no environment keyword. Symptom: the chained job runs but authenticates as nobody. Escape: declare each secret under on.workflow_call.secrets and pass it in the calling job.
Permissions only shrink down a call chain. GitHub allows up to ten levels of nesting and states permissions “can only be maintained or reduced, not elevated” through it. Symptom: a called workflow that works standalone fails to write when chained. Escape: grant the permission in the caller job, not only in the reusable workflow.
Leave a comment where the dead trigger was. push: tags is the first thing anyone reaches for, so a bare deletion invites its return in three months. The replacement here carries a comment naming the rule and the evidence, which costs six lines and survives the next person’s reasonable instinct.
If you genuinely need the downstream event, you need a different identity. Chaining works when you control both workflows. When you need a real event, for example a bot-authored pull request that must run the normal PR checks, the token rule still applies, and the create-pull-request action’s guidance is the clearest catalogue of the options: a personal access token, a GitHub App token, an SSH deploy key which only triggers on: push, or a machine-account fork. Each trades security surface for the ability to raise events, and none of them is free.
Sources
- Trigger a workflow — the rule that events created with
GITHUB_TOKENdo not create new workflow runs, and its exceptions. - Reuse workflows —
workflow_call, calling at job level, secret passing, nesting depth and the permissions rule. - create-pull-request: concepts and guidelines — the catalogue of alternative identities when you need the downstream event to fire.
- gh run list — the
--workflowand--jsonflags used for the liveness check.
Changelog
- fix(press): the propagate workflow could never fire on a release (0.4.1) (#123) (a2282c6)