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)
-
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>.
-
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.
-
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
-
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.
-
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.
-
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.
-
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]).
-
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).
-
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
Extra empty commit for CI triggering is pushed as
github-actions[bot], soGH_AW_CI_TRIGGER_TOKEN/github-token-for-extra-empty-commitdoes not trigger CISummary
The "extra empty commit" CI-trigger mechanism — configured via the
GH_AW_CI_TRIGGER_TOKENmagic secret orgithub-token-for-extra-empty-commitoncreate-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]/ theGITHUB_TOKEN) rather than the configured PAT/App, so per GitHub's event-cascade rule the resultingpush/pull_request(synchronize) event does not start CI workflow runs.github-actions[bot], the runs are created but stuck atconclusion: action_required("N workflows awaiting approval") until a maintainer manually approves.The push logs
Extra empty commit pushed ... successfully, so the failure is silent.This contradicts
docs/src/content/docs/reference/triggering-ci.mdx, which presentsGH_AW_CI_TRIGGER_TOKENas "the easiest way to fix this problem" and states the empty commit "will triggerpushandpull_requestevents normally" — with no mention of the persisted-credential caveat.Analysis (root cause)
The
safe_outputsjob checks out withactions/checkoutusingpersist-credentials: trueand the checkout token${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}— which resolves toGITHUB_TOKEN(identitygithub-actions[bot]) unless a master-token override is configured.persist-credentials: truewriteshttp.<serverUrl>/.extraheader(anAuthorization: basic <checkout-token>header) into.git/config, which authenticates every request to<serverUrl>.actions/setup/js/extra_empty_commit.cjsauthenticates the trigger push only by embedding the token in the remote URL:It never removes or overrides the persisted
http.<serverUrl>/.extraheader. Because the push targets the same host, git still applies that persisted extraheader; itsAuthorizationheader (the checkout token) takes precedence over the URL-embedded credential, so the push authenticates asgithub-actions[bot]and the PAT is effectively ignored.The codebase already documents and handles this exact hazard in
actions/setup/js/dynamic_checkout.cjsviacheckoutHasPersistedExtraheader(), noting: "git treats the key as multi-valued and would send two Authorization headers, which GitHub rejects with 'Duplicate header: Authorization'".extra_empty_commit.cjslacks 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:
Observed:
actorandtriggering_actoraregithub-actions[bot](not the PAT/App user), and the github-actions check-suite for that SHA hasconclusion: 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
Fix
actions/setup/js/extra_empty_commit.cjsso the trigger push authenticates asGH_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-allso any existing/multi-valued header is collapsed to one, avoiding the duplicate-Authorization400 described indynamic_checkout.cjs):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 === 1guard, 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.
Extract a shared helper (preferred, follows the focused-utilities pattern):
checkoutHasPersistedExtraheader()and add anoverridePersistedExtraheader(serverUrl, token)helper into a shared module (e.g. newactions/setup/js/git_auth_helpers.cjs), and have bothdynamic_checkout.cjsandextra_empty_commit.cjsconsume it, so the extraheader handling lives in one place.Tests —
actions/setup/js/extra_empty_commit.test.cjs:GH_AW_CI_TRIGGER_TOKENset, assert that agit config --replace-all http.<serverUrl>/.extraheader "Authorization: basic <base64('x-access-token:'+token)>"call is issued beforegit push ci-trigger, and that the push uses a credential-free remote URL. (Use the existingmockExec.execcall-sequence assertion style.)newCommitCount !== 1, cycle limit reached) are unchanged.Docs —
docs/src/content/docs/reference/triggering-ci.mdx:pushandpull_requestevents normally" claim now holds regardless of the checkout token.actoris the PAT/App identity (notgithub-actions[bot]).Changeset: add a
patchchangeset under.changeset/describing the fix (extra-empty-commit trigger push now authenticates as the CI-trigger token even when the checkout persistedGITHUB_TOKENcredentials).Follow guidelines: run
make agent-finish(build, test, recompile, format, lint) andmake recompileso bundled action / lock outputs regenerate.Suggested labels
bug,documentationEnvironment
actions/setup/js/extra_empty_commit.cjs(extra-empty-commit safe-output path)create-pull-requestwith the defaultpersist-credentials: truecheckout andGITHUB_TOKEN(no mastergithub-tokenoverride)mainat time of filing