Skip to content

Extra empty commit for CI triggering is pushed as github-actions[bot], so GH_AW_CI_TRIGGER_TOKEN / github-token-for-extra-empty-commit does not trigger CI #44073

Description

@sg650

Extra empty commit for CI triggering is pushed as github-actions[bot], so GH_AW_CI_TRIGGER_TOKEN / github-token-for-extra-empty-commit does not trigger CI

Summary

The "extra empty commit" CI-trigger mechanism — configured via the GH_AW_CI_TRIGGER_TOKEN magic secret or github-token-for-extra-empty-commit on create-pull-request / push-to-pull-request-branch — does not actually trigger CI in the default configuration. The empty commit is pushed authenticated as the persisted checkout credential (github-actions[bot] / the GITHUB_TOKEN) rather than the configured PAT/App, so per GitHub's event-cascade rule the resulting push / pull_request (synchronize) event does not start CI workflow runs.

  • Where the repo requires approval for github-actions[bot], the runs are created but stuck at conclusion: action_required ("N workflows awaiting approval") until a maintainer manually approves.
  • Otherwise no downstream runs are created at all.

The push logs Extra empty commit pushed ... successfully, so the failure is silent.

This contradicts docs/src/content/docs/reference/triggering-ci.mdx, which presents GH_AW_CI_TRIGGER_TOKEN as "the easiest way to fix this problem" and states the empty commit "will trigger push and pull_request events normally" — with no mention of the persisted-credential caveat.

Analysis (root cause)

  1. The safe_outputs job checks out with actions/checkout using persist-credentials: true and the checkout token ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} — which resolves to GITHUB_TOKEN (identity github-actions[bot]) unless a master-token override is configured. persist-credentials: true writes http.<serverUrl>/.extraheader (an Authorization: basic <checkout-token> header) into .git/config, which authenticates every request to <serverUrl>.

  2. actions/setup/js/extra_empty_commit.cjs authenticates the trigger push only by embedding the token in the remote URL:

    const remoteUrl = `https://x-access-token:${token}@${serverHostStripped}/${repoOwner}/${repoName}.git`;
    await exec.exec("git", ["remote", "add", "ci-trigger", remoteUrl]);
    // ...
    await exec.exec("git", ["push", "ci-trigger", branchName]);

    It never removes or overrides the persisted http.<serverUrl>/.extraheader. Because the push targets the same host, git still applies that persisted extraheader; its Authorization header (the checkout token) takes precedence over the URL-embedded credential, so the push authenticates as github-actions[bot] and the PAT is effectively ignored.

  3. The codebase already documents and handles this exact hazard in actions/setup/js/dynamic_checkout.cjs via checkoutHasPersistedExtraheader(), noting: "git treats the key as multi-valued and would send two Authorization headers, which GitHub rejects with 'Duplicate header: Authorization'". extra_empty_commit.cjs lacks the equivalent handling, so it silently pushes with the wrong identity instead.

How to verify

For a PR created by such a workflow, inspect the workflow run(s) for the empty-commit head SHA:

GET /repos/{owner}/{repo}/actions/runs?head_sha={empty_commit_sha}

Observed: actor and triggering_actor are github-actions[bot] (not the PAT/App user), and the github-actions check-suite for that SHA has conclusion: action_required. For contrast, a PR opened in the same repo by a human, or by a bot App that pushes under its own identity (e.g. a dependency-update bot), runs CI without the gate — confirming the deciding factor is the pushing identity, not the repo's fork/approval policy.

Implementation Plan

  1. Fix actions/setup/js/extra_empty_commit.cjs so the trigger push authenticates as GH_AW_CI_TRIGGER_TOKEN, not the persisted checkout credential:

    • Before git push ci-trigger, override the persisted auth to a single value carrying the trigger token (using --replace-all so any existing/multi-valued header is collapsed to one, avoiding the duplicate-Authorization 400 described in dynamic_checkout.cjs):

      const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com";
      const tokenB64 = Buffer.from(`x-access-token:${token}`).toString("base64");
      await exec.exec("git", [
        "config", "--replace-all",
        `http.${serverUrl}/.extraheader`,
        `Authorization: basic ${tokenB64}`,
      ]);
    • With the extraheader now carrying the trigger token, use a clean remote URL without embedded credentials (${serverUrl}/${repoOwner}/${repoName}.git) to avoid mixing credential sources.

    • Preserve the existing behavior: cycle-prevention check, cross-repo guard, newCommitCount === 1 guard, fetch/reset sync-to-remote, and temp-remote cleanup.

    • On cleanup (success and failure paths), restore or unset the extraheader override you added, so the trigger token does not leak into later steps of the job.

  2. Extract a shared helper (preferred, follows the focused-utilities pattern):

    • Move checkoutHasPersistedExtraheader() and add an overridePersistedExtraheader(serverUrl, token) helper into a shared module (e.g. new actions/setup/js/git_auth_helpers.cjs), and have both dynamic_checkout.cjs and extra_empty_commit.cjs consume it, so the extraheader handling lives in one place.
  3. Tests — actions/setup/js/extra_empty_commit.test.cjs:

    • New case: with GH_AW_CI_TRIGGER_TOKEN set, assert that a git config --replace-all http.<serverUrl>/.extraheader "Authorization: basic <base64('x-access-token:'+token)>" call is issued before git push ci-trigger, and that the push uses a credential-free remote URL. (Use the existing mockExec.exec call-sequence assertion style.)
    • Regression case: ensure the no-op/skip paths (no token, cross-repo target, newCommitCount !== 1, cycle limit reached) are unchanged.
  4. Docs — docs/src/content/docs/reference/triggering-ci.mdx:

    • Confirm the "will trigger push and pull_request events normally" claim now holds regardless of the checkout token.
    • Add a short troubleshooting note: to confirm the trigger worked, verify the empty-commit run's actor is the PAT/App identity (not github-actions[bot]).
  5. Changeset: add a patch changeset under .changeset/ describing the fix (extra-empty-commit trigger push now authenticates as the CI-trigger token even when the checkout persisted GITHUB_TOKEN credentials).

  6. Follow guidelines: run make agent-finish (build, test, recompile, format, lint) and make recompile so bundled action / lock outputs regenerate.

Suggested labels

bug, documentation

Environment

  • Component: actions/setup/js/extra_empty_commit.cjs (extra-empty-commit safe-output path)
  • Config: create-pull-request with the default persist-credentials: true checkout and GITHUB_TOKEN (no master github-token override)
  • Same-repo (non-fork) PR branch
  • Source referenced from main at time of filing

Metadata

Metadata

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions