DevOps & CI/CD Bug Report

Release workflow never runs after a bot pushes a tag with GITHUB_TOKEN

Our versioning workflow created a tag and pushed it, and the release workflow that listens for tags never started. Events created with the default GITHUB_TOKEN do not trigger new workflow runs. We switched to a GitHub App token for the push and made the release explicit with workflow_call.

Ryan Pham Ryan Pham @ryan_bugs · November 05, 2025 · 6 min read · 7 Views
Banner: GitHub Actions release workflow not triggered by a tag pushed with GITHUB_TOKEN

We automated releases for an internal SDK: on merge to main, a version workflow computes the next semantic version from commit messages, updates the changelog, commits it, creates a tag like v2.14.0 and pushes. A separate release workflow runs on: push: tags: ['v*'], builds artifacts and publishes them.

The version workflow ran green. The tag existed on GitHub. The release workflow never started. No failed run, no skipped run, nothing in the Actions tab.

$ git ls-remote --tags origin | tail -1
9f3c1e2a...  refs/tags/v2.14.0

Reproducing it

Pushing the same tag by hand from a laptop triggered the release workflow immediately. Pushing it from the version workflow did not. The difference was the credential:

- uses: actions/checkout@v4          # persists GITHUB_TOKEN for git push
- run: |
    git tag "v${{ steps.semver.outputs.version }}"
    git push origin --tags

What we ruled out

  • Tag filter mismatch. The pattern v* matched v2.14.0, and a manual push proved the trigger worked.
  • Workflow disabled. It was enabled and ran on manual pushes.
  • Branch protection or tag protection rules. The push succeeded; the tag was there.

Root cause

GitHub does not start new workflow runs for events caused by the repository's GITHUB_TOKEN, with the exceptions of workflow_dispatch and repository_dispatch. This is deliberate, to stop workflows from triggering each other in infinite loops. Our tag push was authenticated with GITHUB_TOKEN (persisted by actions/checkout), so the push event for the tag was created but no workflows were triggered by it.

What is happening under the hood

Each workflow job gets an installation token for the GitHub Actions app, exposed as GITHUB_TOKEN. Actions it performs (pushes, tags, issue comments, PRs) are attributed to github-actions[bot], and events from that identity are filtered out of workflow triggers. Events caused by other identities, a personal access token, or a token from your own GitHub App, do trigger workflows normally.

actions/checkout stores the token in the local git config by default (persist-credentials: true), so later git push commands use it without any visible authentication step, which makes the cause easy to miss.

Environment

Component Version / value
CI GitHub Actions, GitHub-hosted runners
Actions actions/checkout@v5
Workflows version.yml on push to main, release.yml on tag push v*
Repository Private, organisation-owned

Minimal reproduction

# .github/workflows/tagger.yml
on: workflow_dispatch
permissions: {contents: write}
jobs:
  tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: git tag demo-$(date +%s) && git push origin --tags

# .github/workflows/on-tag.yml
on: {push: {tags: ['demo-*']}}
jobs:
  echo: {runs-on: ubuntu-latest, steps: [{run: echo triggered}]}

on-tag.yml never runs from the tagger.

The fix

We considered two fixes and used both for different reasons.

1. Push with a GitHub App installation token. We created an internal GitHub App with contents: write on the SDK repository, stored its app id and private key as secrets, and generated a short-lived token in the workflow:

- uses: actions/create-github-app-token@v2
  id: app-token
  with:
    app-id: ${{ vars.RELEASE_BOT_APP_ID }}
    private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}

- uses: actions/checkout@v5
  with:
    token: ${{ steps.app-token.outputs.token }}

- run: |
    git config user.name  "release-bot[bot]"
    git config user.email "${{ vars.RELEASE_BOT_USER_ID }}+release-bot[bot]@users.noreply.github.com"
    git tag "v${VERSION}"
    git push origin "v${VERSION}"

Tags pushed this way trigger release.yml like a human push would, and the tag shows the app as the actor.

2. Make the release an explicit call. For the SDK we also refactored release.yml into a reusable workflow and call it from the version workflow after tagging. The release no longer depends on event propagation at all, and the whole chain shows up as one run:

# release.yml
on:
  push:
    tags: ['v*']            # still works for manual tags
  workflow_call:
    inputs:
      version: {type: string, required: true}

# version.yml
jobs:
  version:
    outputs:
      version: ${{ steps.semver.outputs.version }}
    # ... computes, commits, tags with the app token
  release:
    needs: version
    uses: ./.github/workflows/release.yml
    with:
      version: ${{ needs.version.outputs.version }}
    secrets: inherit

Alternatives and trade-offs

  • Personal access token. Works, but ties automation to a person, uses a long-lived credential, and breaks when they leave. We do not use PATs for automation.
  • workflow_dispatch from the first workflow via the API with GITHUB_TOKEN. Allowed by the exception and simple, but the release then runs with the default branch ref unless you pass inputs carefully.
  • Deploy keys. SSH keys per repository; they trigger workflows but are long-lived and cannot be scoped as narrowly as app permissions.

How to test the fix

We ran the version workflow on a test repository with the same setup. The tag push from the app token started the tag-triggered workflow, and the workflow_call path ran the release job in the same run. We also confirmed that the app's commit to main (the changelog) did not create an infinite loop: the version workflow skips commits with [skip release] in the message and checks the actor.

Confirming in production

  • The next three merges to main produced tags and published releases automatically.
  • The audit log shows the app as the actor for tags and changelog commits, separate from humans.

Regression guard

  • The release workflow posts to the team channel on success; a missing post after a version bump is noticed quickly.
  • A shared workflow template for releases uses the app token pattern, so new repositories do not repeat the mistake.

Security implications

Bypassing the recursion protection means you must prevent loops yourself, which we did with the skip marker and actor check. The app's private key is a powerful secret: it is stored only in this repository's environment, the app is installed only on the repositories that need it, and its permissions are limited to contents: write. Tokens it issues expire after an hour.

Prevention checklist

  • Know that GITHUB_TOKEN-created events do not trigger workflows, except dispatch events.
  • Use a GitHub App token when automation must trigger other workflows.
  • Prefer workflow_call for chained steps you own.
  • Guard against self-triggering loops when using non-default tokens.
  • Avoid personal access tokens for automation.

References

Add a label

#github-actions #github-token #releases #github-apps #automation
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.