Skip to content

repo-memory push fails with "Commits must have verified signatures" — push_repo_memory.cjs should use pushSignedCommits #29301

@mason-tim

Description

@mason-tim

repo-memory push fails with "Commits must have verified signatures" — push_repo_memory.cjs should use pushSignedCommits

Summary

The repo-memory safe-output mechanism uses an unsigned git commit + git push to write the memory branch. Repositories (and orgs) that enforce a ruleset requiring "Commits must have verified signatures" on all branches reject every push from this code path with:

remote: error: GH013: Repository rule violations found for refs/heads/memory/<id>.
remote: - Commits must have verified signatures.
remote:   Found 1 violation:
remote:   <sha>

The retry loop in push_repo_memory.cjs then exhausts its 4 attempts and the workflow fails. This is a hard blocker for any consumer in an org where the signed-commit rule is mandatory and cannot be bypassed (e.g. enterprise security baselines).

The framework already has the right helper (actions/setup/js/push_signed_commits.cjs) — it's used by create_pull_request.cjs and push_to_pull_request_branch.cjs to push commits via the GitHub GraphQL createCommitOnBranch mutation, which produces server-signed (verified) commits. push_repo_memory.cjs was never wired into this helper, so its pushes go up unsigned.

Real-world failure: https://github.com/DigitalInnovation/food-resource-tracker-dashboard/actions/runs/25161512489/job/73757572930engineering-coach workflow, push_repo_memory step, four consecutive GH013 rejections.

Analysis

Root Cause

actions/setup/js/push_repo_memory.cjs lines ~430–470 (current main, SHA 6c8d6d3):

execGitSync(["commit", "-m", `Update repo memory from workflow run ${githubRunId}`], { stdio: "inherit" });

const repoUrl = `https://x-access-token:${ghToken}@${serverHost}/${targetRepo}.git`;

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
  
  execGitSync(["pull", "--no-rebase", "-X", "ours", repoUrl, branchName], );
  execGitSync(["push", repoUrl, `HEAD:${branchName}`], );
  
}

The commit is made under the runner's default git identity (github-actions[bot]) with no signing key configured, so it lands on the remote as Unverified and any "Commits must have verified signatures" ruleset rejects it. There is no API fallback — the only retry mechanism is "try the same unsigned push again with exponential backoff", which can never succeed against this rule.

Compare: create-pull-request doesn't have this problem

actions/setup/js/create_pull_request.cjs (and push_to_pull_request_branch.cjs) delegate the push to the shared helper actions/setup/js/push_signed_commits.cjs, which:

  1. Walks git rev-list <baseRef>..HEAD to enumerate the new commits.
  2. Builds an additions[] / deletions[] payload from git diff-tree --raw + git cat-file blob (base64-encoded contents).
  3. Calls the GitHub GraphQL createCommitOnBranch mutation per commit.
  4. The server creates each commit under GitHub's own signing key — the result is Verified automatically and passes signed-commit rulesets.
  5. Falls back to plain git push only when GraphQL can't handle the commit (merge commits, symlinks, executable bits, gitlinks/submodules, GHES instances that don't support the mutation).

The helper's own @fileoverview comment says:

Both create_pull_request.cjs and push_to_pull_request_branch.cjs use this helper so the signed-commit logic lives in exactly one place.

push_repo_memory.cjs is the third caller that should be using it but isn't.

Affected Files

  • actions/setup/js/push_repo_memory.cjs — the commit + pull -X ours + push block (~lines 430–470)
  • actions/setup/js/push_repo_memory.test.cjs — needs new cases for the signed-push path and ruleset-rejection fallback
  • actions/setup/js/push_signed_commits.cjs — already exists; consumed as-is, no API change needed
  • docs/src/content/docs/reference/repo-memory.md — should mention that commits are pushed as Verified

Reproduction

Workflow frontmatter

---
on:
  schedule:
    - cron: "0 9 * * 1"

permissions:
  contents: read
  issues: write

tools:
  github:
    toolsets: [repos, issues, pull_requests, actions]
  repo-memory:
    - id: engineering-coach
      description: "Persistent metrics history"
      file-glob: ["*.json"]
      allowed-extensions: [".json"]

safe-outputs:
  create-issue:
    labels: [engineering-coach]
    max: 1
---

Steps

  1. Apply a repository ruleset to the consumer repo with "Require signed commits" enabled, targeting all branches (the typical enterprise baseline).
  2. Run any workflow that uses repo-memory: (e.g. the catalogue's engineering-coach).
  3. Observe the push_repo_memory step in the safe-outputs job: the agent stage and artifact upload succeed, then the push fails 4 times with GH013 and the job is marked failed.

Environment

Workarounds (none viable for our org)

  • Add a ruleset bypass for memory/** — not allowed; the signed-commit rule is mandated org-wide and cannot be bypassed by repo admins.
  • Revert the workflow to create-pull-request against main — works (because that path uses pushSignedCommits), but defeats the design goal of repo-memory (no PR noise on main, no human merge required).
  • Hand-roll a final job step that calls the Contents REST API — possible but moves logic out of the safe-outputs envelope and isn't reusable across consumers.

Implementation Plan

Option A: Port push_repo_memory.cjs onto pushSignedCommits (Recommended)

  1. actions/setup/js/push_repo_memory.cjs:

    • Capture the remote head SHA before committing:
      const { stdout: lsRemote } = await exec.getExecOutput(
        "git", ["ls-remote", repoUrl, `refs/heads/${branchName}`]
      );
      const baseRef = lsRemote.trim().split(/\s+/)[0]; // empty string if branch doesn't exist yet
    • Keep the existing local git commit (the helper consumes the local commit; the actual push is via GraphQL).
    • Replace the pull -X ours + retry-git push block with:
      const { pushSignedCommits } = require("./push_signed_commits.cjs");
      const [owner, repo] = targetRepo.split("/");
      await pushSignedCommits({
        githubClient: github,
        owner, repo,
        branch: branchName,
        baseRef: baseRef || `${headSha}^`, // helper handles the new-branch case via rest.git.createRef
        cwd: workspaceDir,
        gitAuthEnv: { GIT_AUTH_HEADER: `Authorization: Bearer ${ghToken}` },
      });
    • Concurrency: pushSignedCommits will fail with a stale-expectedHeadOid error if another run pushed in between. Wrap the call in the existing MAX_RETRIES loop, re-running ls-remote on each retry to refresh baseRef. Keep the pull -X ours to merge any concurrent updates locally before re-attempting.
    • When the GraphQL fallback inside pushSignedCommits falls back to git push (merge commits, symlinks, etc.), the org's signed-commit rule will still reject it. That's the correct behaviour — those file modes can't be expressed via the mutation, so the consumer needs to either remove them from memory artifacts or accept the failure. Surface this clearly in the failure message.
  2. actions/setup/js/push_repo_memory.test.cjs:

    • New test: GraphQL createCommitOnBranch is called with the expected additions[] and expectedHeadOid, returns a verified OID, function resolves successfully.
    • New test: branch-doesn't-exist path — rest.git.createRef is called first, then GraphQL mutation, then resolution.
    • New test: GraphQL throws with 409 Reference does not match expected object → the function retries with a refreshed baseRef.
    • New test: pushSignedCommits falls back to git push and the underlying push fails with GH013 → repo-memory step fails with a clear error (regression guard).
  3. docs/src/content/docs/reference/repo-memory.md:

    • Add a "Commits are signed by GitHub" section noting that repo-memory pushes via the same mutation as create-pull-request, so memory branches work in repos that require verified signatures.
    • Document the one remaining gotcha: if a memory artifact contains symlinks, executable files, or submodule entries, the helper falls back to plain git push, which will be rejected by signed-commit rulesets. Recommend keeping memory artifacts as plain regular files (.json, .jsonl, .txt, .md, .csv — already the default allowed-extensions).

Option B: Document the bypass workaround (not recommended as primary fix)

If shipping Option A is non-trivial, at minimum:

  1. docs/src/content/docs/reference/repo-memory.md:
    • Add a prominent "Branch protection / rulesets" warning explaining that consumer repos with "Require signed commits" rules must add memory/** to the bypass list, or the workflow will fail with GH013.
    • Provide the exact ruleset configuration snippet.

Option A is strongly preferred because the bypass workaround is unavailable to consumers in orgs (like ours) that enforce signed commits as a non-bypassable security baseline.

Follow Guidelines

  • Use error message format: "[what's wrong]. [what's expected]. [example]"
    • e.g. repo-memory: signed-commit push failed because commit <sha> contains a symlink at <path>. The GitHub createCommitOnBranch mutation does not support symlinks. Remove the symlink from your memory artifact or use a regular file (.json/.txt/.md).
  • Run make agent-finish before completing.

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