Skip to content

push_repo_memory broken on signed-commit rulesets: ls-remote missing gitAuthEnv (regression from #31478) #33084

@mason-tim

Description

@mason-tim

Summary

actions/setup/js/push_signed_commits.cjs makes a network-touching git ls-remote origin call (line 363) without passing the gitAuthEnv credentials the same function already accepts and uses for its sibling git push call (line 140). Since gh aw fix's codemod started rewriting every actions/checkout to persist-credentials: false (PR #31478, merged 2026-05-11), .git/config no longer contains an http.extraheader token. The ls-remote call therefore runs without any credentials, git prompts for a username, fails with fatal: could not read Username for 'https://github.com', and the catch handler falls back to a plain unsigned git push — which is rejected by Require signed commits rulesets with GH013.

Net result: every push_repo_memory run is broken on any repository with a "Require signed commits" ruleset, regardless of whether the target memory branch already exists. The previously-suggested "manually seed the orphan branch" workaround does not help, because the failure happens before any branch-existence check matters.

This is the same root cause noted in #31489. Filing as a dedicated issue with a concrete one-line fix so it can be tracked and merged independently of the orphan-branch discussion.

Symptoms

In the push_repo_memory job log, four failed retry cycles each show the same two lines:

fatal: could not read Username for 'https://github.com': No such device or address
...
remote: error: GH013: Repository rule violations found for refs/heads/memory/<id>.
 ! [remote rejected] memory/<id> -> memory/<id> (push declined due to repository rule violations)
##[error]Failed to push changes after 4 attempts: The process '/usr/bin/git' failed with exit code 1

The first line is the real failure (the ls-remote auth prompt). The second is a downstream consequence: when ls-remote fails, the catch handler falls through to a plain unsigned git push (the fallback path), and the ruleset correctly rejects that.

Root Cause

actions/setup/js/push_signed_commits.cjs has two network-touching git calls:

Line 137-141pushBranchAndResolveHead for the fallback git push:

async function pushBranchAndResolveHead({ branch, cwd, gitAuthEnv }) {
  ...
  await exec.getExecOutput("git", ["push", ...], {
    cwd,
    env: { ...process.env, ...(gitAuthEnv || {}) },   // ← auth provided
  });

Line 363ls-remote to read the remote branch HEAD OID:

const { stdout: oidOut } = await exec.getExecOutput(
  "git",
  ["ls-remote", "origin", `refs/heads/${branch}`],
  { cwd },                                            // ← no auth provided
);

Every other git call in the file is a local operation (rev-parse, rev-list, diff-tree, log) and correctly doesn't need credentials. Line 363 is the only network call without auth — a clear inconsistency with line 140.

The caller (actions/setup/js/push_repo_memory.cjs) constructs gitAuthEnv via getGitAuthEnv(token) in git_helpers.cjs, which produces three env vars:

{
  GIT_CONFIG_COUNT: "1",
  GIT_CONFIG_KEY_0: `http.${serverUrl}/.extraheader`,
  GIT_CONFIG_VALUE_0: `Authorization: basic <base64 of x-access-token:TOKEN>`,
}

When passed to a child git process, these are interpreted as ephemeral config — the equivalent of the http.extraheader line actions/checkout used to write into .git/config, but scoped to a single process instead of persisted to disk. Line 140 already proves this channel works.

Regression Timeline

  • 2026-04-30 — PR fix: repo-memory push uses GraphQL signed commits to satisfy "Require signed commits" rulesets #29330 ("fix: repo-memory push uses GraphQL signed commits to satisfy 'Require signed commits' rulesets") introduces push_signed_commits.cjs and adds the in-code comment in push_repo_memory.cjs:

    pushSignedCommits authenticates via the git extraheader set by actions/checkout (and the gitAuthEnv fallback for the git-push path).

    So the design explicitly relied on actions/checkout's default behaviour of writing the extraheader to .git/config. The ls-remote call inherited that auth implicitly. Working as intended at the time.

  • 2026-05-11 11:41 UTC — PR Add codemod to enforce persist-credentials: false on actions/checkout steps #31478 ("Add codemod to enforce persist-credentials: false on actions/checkout steps") merges. The codemod mechanically rewrites every actions/checkout step in compiled workflows to persist-credentials: false — the explicit toggle that stops checkout from writing the extraheader. The premise fix: repo-memory push uses GraphQL signed commits to satisfy "Require signed commits" rulesets #29330's design rested on is silently removed.

  • 2026-05-11 onward — anyone who recompiles a workflow picks up the codemod. Their push_repo_memory job's checkout no longer leaves an extraheader in .git/config. The ls-remote call at line 363, having no gitAuthEnv, has no auth source at all → fatal: could not read Username → catch handler → unsigned push → GH013.

So a true regression: #29330 alone was fine, #31478 alone is fine, but the pair together breaks push_repo_memory for any repo with a signed-commits ruleset.

Reproduction

  1. Use any workflow with safe-outputs.repo-memory: configured.
  2. Compile with a gh aw version that includes the persist-credentials: false codemod from Add codemod to enforce persist-credentials: false on actions/checkout steps #31478 (any release after 2026-05-11).
  3. Target a repository that has a ruleset requiring signed commits on the memory branch glob (e.g. memory/*).
  4. Trigger the workflow so the push_repo_memory job runs.
  5. Observe the Failed to push changes after 4 attempts failure, with the could not read Username line as the first symptom in each retry.

Pre-seeding the memory branch (e.g. creating memory/<id> via the GitHub web UI to bypass the orphan-first-commit signing problem) does NOT mask this bug — the ls-remote failure happens regardless of whether the branch exists.

Proposed Fix (one line)

actions/setup/js/push_signed_commits.cjs, line 363 — change:

const { stdout: oidOut } = await exec.getExecOutput(
  "git",
  ["ls-remote", "origin", `refs/heads/${branch}`],
  { cwd },
);

to:

const { stdout: oidOut } = await exec.getExecOutput(
  "git",
  ["ls-remote", "origin", `refs/heads/${branch}`],
  { cwd, env: { ...process.env, ...(gitAuthEnv || {}) } },
);

This is structurally identical to what pushBranchAndResolveHead does at line 140 (same function family, same file). No new auth mechanism, no change to the security posture of persist-credentials: falsegitAuthEnv is per-process ephemeral config, not persisted to disk.

Why This Is a Fix, Not a Workaround

Test Coverage to Add

actions/setup/js/push_signed_commits.test.cjs:

  • New test: GraphQL signed-commit path succeeds when invoked from a checkout configured with persist-credentials: false. Mock actions/checkout env state (no extraheader in .git/config), provide gitAuthEnv to pushSignedCommits, mock exec.getExecOutput to assert that the ls-remote origin call receives an env argument containing GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0.

  • Regression guard: a parameterised test that walks every network-touching exec.getExecOutput("git", …) call in the file and asserts each is invoked with a gitAuthEnv-merged env, so a future contributor can't reintroduce the same omission for another network call.

Related

Metadata

Metadata

Type

No type
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