Skip to content

Regression of #29301: orphan-branch first commit pushes unsigned, fails on "Require signed commits" rulesets #31489

@mason-tim

Description

@mason-tim

Summary

PR #29330 (which fixed #29301) routed push_repo_memory.cjs through pushSignedCommits so memory commits are server-signed via the GraphQL createCommitOnBranch mutation and pass Require signed commits rulesets.

PR #30463 then fixed a separate, real bug — orphan-branch first commits being silently dropped when ${baseRef}..HEAD resolves to zero shas — by adding an early-return guard in actions/setup/js/push_signed_commits.cjs. That guard does the right thing for unsigned environments, but it falls back to plain git push, which bypasses the signed-commit path:

// actions/setup/js/push_signed_commits.cjs (current main)
async function pushSignedCommits({ ..., baseRef, ... }) {
  if (!baseRef) {
    core.info(`pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch ${branch}`);
    await exec.exec("git", ["push", "origin", branch], {
      cwd,
      env: { ...process.env, ...(gitAuthEnv || {}) },
    });
    ...
    return headSha;
  }
  ...
}

Result: on any repo with a Require signed commits ruleset, the first push to a brand-new memory branch fails with GH013 — the exact failure mode #29301 was filed against. Because the branch is never created on origin, every subsequent run takes the same orphan-first-commit path and fails identically. Memory never persists; agents see "first baseline" indefinitely.

Reproduction

A repository with the org-level "Commits must have verified signatures" ruleset applied to all branches, running any workflow that uses safe-outputs.repo-memory for the first time on a fresh memory id (so the memory branch does not yet exist on origin), with gh-aw v0.71.6 (or v0.72.x — same code path).

Sanitized log excerpt from the Push repo-memory changes step:

Branch memory/<id> does not exist, creating orphan branch...
Cleaning working directory for orphan branch...
Created orphan branch: memory/<id>
...
Scan complete: Found 1 file(s) to copy
Copied: metrics-history.json (3750 bytes)
Changes detected, committing and pushing...
[memory/<id> (root-commit) <sha>] Update repo memory from workflow run <run-id>
 1 file changed, 59 insertions(+)
Pushing changes to memory/<id> (attempt 1/4)...
pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch memory/<id>
remote: error: GH013: Repository rule violations found for refs/heads/memory/<id>.
remote: - Commits must have verified signatures.
remote:   Found 1 violation:
remote:   <sha>
 ! [remote rejected] memory/<id> -> memory/<id> (push declined due to repository rule violations)
error: failed to push some refs to 'https://github.com/...'
##[warning]Push failed (attempt 1/4), retrying in 1000ms: The process '/usr/bin/git' failed with exit code 1
fatal: could not read Username for 'https://github.com': No such device or address
ls-remote on retry failed, keeping existing baseRef: The process '/usr/bin/git' failed with exit code 128
Pushing changes to memory/<id> (attempt 2/4)...
pushSignedCommits: empty baseRef detected (orphan branch first push), using git push directly for branch memory/<id>
... [same GH013 rejection × 4] ...
##[error]Failed to push changes after 4 attempts: The process '/usr/bin/git' failed with exit code 1

Analysis

Root cause

pushSignedCommits's orphan-branch first-push guard uses git push to create the branch, bypassing the GraphQL signed-commit path that was specifically introduced (#29330) to satisfy verified-signatures rulesets.

Design constraint to preserve

Memory branches are intentionally orphan — no shared ancestry with source. Any fix should preserve this:

  • git log memory/<id> should show only memory commits, not the source history.
  • A force-push or full reset of memory cannot affect source history.
  • Memory data cannot accidentally be merged into main (no merge base).

So a fix that re-parents memory branches under main's HEAD (the obvious workaround) regresses an architectural property and should be rejected.

Prior art ruled out

  • GraphQL createCommitOnBranchexpectedHeadOid is non-null in the schema, so the mutation cannot be called for the first commit on a branch that doesn't yet exist.
  • REST PUT /repos/{owner}/{repo}/contents/{path} — auto-signs when called by a GitHub App, but requires the target branch to already exist (returns 422 otherwise). Cannot seed an orphan branch.
  • REST POST /git/commits — accepts a caller-supplied signature field but does not auto-sign. The runner has no signing key.
  • git push -S / git commit -S — the runner has no GPG key and shipping one with the action is a non-starter.

Open question for the maintainers

Is there a documented REST/GraphQL combination that produces a server-signed root commit on a brand-new orphan branch? If not, the design may need a different shape — for example:

(a) A one-time API-only seed step: REST create a tree (POST /git/trees), then a low-level commit (POST /git/commits with no parents), then the ref (POST /git/refs), accepting that the seed commit lands as Unverified and relying on a ruleset bypass for the seed only. Subsequent commits on the now-existing branch take the GraphQL signed path.

(b) A non-destructive "anchor" first commit on main: POST /git/refs to point memory/<id> at main's current HEAD, then immediately use GraphQL createCommitOnBranch to overwrite the tree to memory contents. The branch is no longer truly orphan (has main as ancestor), but it's signed end-to-end. (Likely rejected — regresses the orphan property.)

(c) A new server-side signing path — coordinate with the GraphQL team to expose createCommitOnBranch with expectedHeadOid: null for the brand-new-branch case. Out of scope for this repo, but the right long-term fix.

(d) Something the maintainers know about that I don't — please advise.

I'd rather not prescribe an implementation in this issue without knowing which of these (or another option) the team prefers, given that #30463 was closed less than a week ago and clearly considered the constraints carefully.

Implementation plan (once direction is agreed)

Files affected

  • actions/setup/js/push_signed_commits.cjs — replace the unsigned git push in the if (!baseRef) branch with the chosen signed seed strategy.
  • actions/setup/js/push_repo_memory.cjs — only if the chosen strategy needs a different baseRef value or pre-checkout flow.
  • actions/setup/js/push_signed_commits.test.cjs — new tests:
    • Orphan-branch first push lands as a Verified commit (mock GraphQL/REST responses, assert the chosen endpoints are called and unsigned git push is not).
    • Orphan-branch first push retries correctly when the chosen endpoint returns a transient error.
    • Existing non-orphan signed-push tests continue to pass unchanged.
  • actions/setup/js/push_repo_memory.test.cjs — end-to-end test covering "fresh memory id, ruleset-protected repo, first push succeeds".
  • docs/src/content/docs/reference/repo-memory.md — note that memory commits, including the first commit on a new memory branch, are pushed as Verified and satisfy Require signed commits rulesets.

Acceptance criteria

  1. On a repo with Require signed commits enforced for all branches, the first memory push of a fresh memory id succeeds and the resulting commit shows the Verified badge in the GitHub UI.
  2. Memory branches retain orphan property (no shared ancestry with main) — git merge-base memory/<id> main returns no merge base.
  3. The unsigned-fallback path that fix: orphan branch first push silently discarded by empty baseRef in pushSignedCommits #30463 introduced for non-ruleset repos continues to work (no regression of fix: orphan branch first push silently discarded by empty baseRef in pushSignedCommits #30463).
  4. Existing test suite passes; new tests cover the orphan-signed path.

References

Happy to take a stab at the PR once a direction is agreed.

Metadata

Metadata

Assignees

No one assigned

    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