GitHub Actions GITHUB_TOKEN: The Push That Never Started the Next Workflow
The version job printed v1.4.0 and pushed the tag. The run was green. release.yml listens for push tags and is supposed to cut the GitHub Release and publish the package. No release run appeared. Not cancelled, not queued, not failed. Nothing started. The tag is on the repo. The workflow that should have reacted to it was never created.
That is not a flake, and it is not the concurrency group eating a neighbour. Concurrency cancels a run that existed. This is GitHub refusing to create a run because the event was produced with GITHUB_TOKEN.
What GitHub actually does
GITHUB_TOKEN is the installation token for this workflow run. It can checkout, it can open issues, it can push a tag, it can call gh release create, if you granted permissions. Events those calls would normally raise — push, create, release, pull_request — do not start other workflows.
Two exceptions: workflow_dispatch and repository_dispatch. An API call that fires those, made with GITHUB_TOKEN, does start a run. A git push does not.
The rule is anti-recursion. A workflow that bumps a version and pushes with GITHUB_TOKEN would otherwise match its own on: push, start again, bump again, and fill the Actions tab until you hit the repo. GitHub cut that loop at the source: token-made events are silent to Actions, except the two dispatch kinds you have to call on purpose.
The job that pushed is telling the truth when it says success. git ls-remote --tags shows v1.4.0. The gap is the missing second run.
The push you think you made
actions/checkout persists credentials by default. Later git commands use GITHUB_TOKEN unless you passed a different token:. This is enough to reproduce the incident:
# version.yml — on: push to main
permissions:
contents: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "v${VERSION}"
git push origin "v${VERSION}"
contents: write is required for the push. It is not a trigger grant. The tag lands. release.yml with on: push: tags: ['v*'] does not start.
The same silence applies to:
What the job did with GITHUB_TOKEN | Event you hoped would fire | Does a new run start? |
|---|---|---|
git push a commit | push | No |
git push a tag | push (tags) / create | No |
gh release create | release | No |
gh pr create | pull_request | No |
gh workflow run / dispatch API | workflow_dispatch | Yes |
repository_dispatch API | repository_dispatch | Yes |
A cancelled deploy at least leaves a row in the Actions tab. A token-silenced event leaves no row. People then "debug" release.yml filters, environments, and OIDC subjects for a workflow that never saw an event.
Prefer one workflow
If tagging and releasing are the same change, they are the same run. A second file that listens for the tag is the design that hits this rule.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
release:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- run: |
gh release create "v${VERSION}" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
gh release create in this job publishes the release. You do not need on: release somewhere else. Put OIDC login on release if the publish target is Azure; keep it off test.
The merge queue still serializes what reaches main. Concurrency on release still serializes the publish. Neither of those starts a second workflow, so neither collides with the token rule.
If it must be another workflow, dispatch it
You want release.yml reusable, or a different repo, or a human-gated environment that should not sit on every main push. Call it. Do not hope the tag will.
# version.yml — after the tag push (which still will not wake anyone)
permissions:
contents: write
actions: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "v${VERSION}"
git push origin "v${VERSION}"
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run release.yml --ref "v${VERSION}"
actions: write is the permission that lets GITHUB_TOKEN create a dispatch. --ref is the git ref GitHub reads release.yml from and the ref that run executes on — use the tag you just pushed, not main, if the release job keys off github.ref. release.yml needs on: workflow_dispatch. You can keep on: push: tags for humans who push a tag from a laptop; that push is not GITHUB_TOKEN, so it still fires.
repository_dispatch is the same exception for a callee that should not share a file. Use it when you own both workflows, not as a substitute for a GitHub App talking to a repo you do not.
If the push itself must be visible, do not use GITHUB_TOKEN
Some jobs have to create a commit or tag that should run CI: a scheduled dependency bump, a generated spec, a changelog on main. Then the silent push is the bug. Use a GitHub App installation token for that git remote.
jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# … edit files …
git commit -m "chore: bump version"
git push
Checkout's persisted credentials now belong to the app. git push is not a GITHUB_TOKEN event. on: push workflows start.
The app needs contents: write on the installation. permissions: contents: write on the job only affects GITHUB_TOKEN. It does not enlarge the app, and it does not make GITHUB_TOKEN pushes visible.
A fine-grained PAT stored as a secret also works and is how a lot of repos first unblocked this. It is a user. When that user loses access, the secret keeps working until it expires or someone revokes it, which is the same class of leftover as a client secret in Actions. Prefer an app whose installation you can see on the repo.
Visible pushes recurse unless you stop them
The anti-recursion rule was doing you a favour. An app-token commit on main with on: push will start this workflow again. Guard it.
on:
push:
branches: [main]
jobs:
bump:
if: github.actor != 'github-actions[bot]'
# …
Or put [skip ci] in the commit message. GitHub will not start push or pull_request workflows for a commit whose message contains [skip ci], [ci skip], [no ci], [skip actions], or [actions skip]. Scheduled, workflow_dispatch, and repository_dispatch runs still start. Do not expect [skip ci] to silence a dispatch you just fired.
github-actions[bot] is also the actor when the job used GITHUB_TOKEN. Filtering on the bot is right for "do not bump in a loop." It is wrong for "skip every Actions commit" if a dispatch is how you meant to continue. If a required check lives only in the dispatched workflow, a human push that never calls gh workflow run looks as green as a test stage that never ran. Put the required job in the workflow the human push actually starts.
What this is not for
A missing permissions block. Repos that leave the default at read-only will fail git push in the first job. You would see a red step, not a missing second workflow. Add contents: write for the push. Then deal with the silence.
pull_request checks on a fork. Fork PRs get a read-only token and do not receive the base repo's secrets. That is a different restriction. It is not why a tag on main did not publish.
workflow_run as a clever push. workflow_run fires when a workflow completes, not when a token pushes a tag. It can chain "tests finished → release," and that chain does not depend on the tag event. It also runs on failure unless you filter types: [completed] and then check conclusion. Do not use it to paper over a tag listener you still expect to fire.
Cancelling a duplicate publish. If two visible pushes both start release.yml, that is concurrency. Set a group on the publish job. Do not "fix" it by switching back to GITHUB_TOKEN so the second run never exists.
The version job in the first paragraph did what you asked: it pushed a tag with the run's token. GitHub stored the tag and declined to tell anyone. Put the release in that same job, or gh workflow run the file that does it, or push with an app token and stop the loop on the actor. Then v1.4.0 has a release run, not just a ref.
Keep reading
Kubernetes SIGTERM: The Rolling Update That Cut In-Flight Requests
The kubelet sends SIGTERM while the Service may still route here. preStop counts against the grace period. Keep liveness green while you drain.
GitHub Actions Concurrency: The Group That Cancelled the Production Deploy
A shared concurrency group plus cancel-in-progress will cancel a production deploy when CI starts. Split the groups. Pending runs still get dropped.
GitHub Actions OIDC: Azure Login Without a Client Secret
Federated credentials replace AZURE_CLIENT_SECRET. Match issuer, audience, and the subject GitHub actually emits — environment jobs do not use the branch claim.
Kubernetes Liveness Probes That Restart a Process That Was Fine
Liveness restarts the container. Readiness pulls it out of the Service. A startup probe holds both off until the process has booted. Most outages come from using the wrong one.
GitHub Rewrote the Copilot Runtime in Rust With Agents. The Playbook Is the Story.
832,378 lines of production Rust, 128 pull requests, 135 releases in fourteen and a half weeks, about $120,000 in tokens. What GitHub's TypeScript-to-Rust port of the Copilot agent runtime teaches about shipping a rewrite without a cutover.
The Pipeline Was Green Because the Test Stage Never Ran
How an Azure DevOps condition and a skipped stage produce a green build that did not test anything, and the check to add so that cannot ship.
Newsletter
New posts, straight to your inbox
One email when a new post goes up. Double opt-in, unsubscribe anytime. Not a daily digest.
Comments
- No comments yet. Be the first.