Skip to content

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

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions