8 min readRishi

GitHub Actions GITHUB_TOKEN: The Push That Never Started the Next Workflow

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_TOKENEvent you hoped would fireDoes a new run start?
git push a commitpushNo
git push a tagpush (tags) / createNo
gh release createreleaseNo
gh pr createpull_requestNo
gh workflow run / dispatch APIworkflow_dispatchYes
repository_dispatch APIrepository_dispatchYes

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

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.