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/73757572930 — engineering-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:
- Walks
git rev-list <baseRef>..HEAD to enumerate the new commits.
- Builds an
additions[] / deletions[] payload from git diff-tree --raw + git cat-file blob (base64-encoded contents).
- Calls the GitHub GraphQL
createCommitOnBranch mutation per commit.
- The server creates each commit under GitHub's own signing key — the result is Verified automatically and passes signed-commit rulesets.
- 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
- Apply a repository ruleset to the consumer repo with "Require signed commits" enabled, targeting all branches (the typical enterprise baseline).
- Run any workflow that uses
repo-memory: (e.g. the catalogue's engineering-coach).
- 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)
-
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.
-
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).
-
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:
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.
repo-memorypush fails with "Commits must have verified signatures" —push_repo_memory.cjsshould usepushSignedCommitsSummary
The
repo-memorysafe-output mechanism uses an unsignedgit commit+git pushto 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:The retry loop in
push_repo_memory.cjsthen 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 bycreate_pull_request.cjsandpush_to_pull_request_branch.cjsto push commits via the GitHub GraphQLcreateCommitOnBranchmutation, which produces server-signed (verified) commits.push_repo_memory.cjswas 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/73757572930 —
engineering-coachworkflow,push_repo_memorystep, four consecutiveGH013rejections.Analysis
Root Cause
actions/setup/js/push_repo_memory.cjslines ~430–470 (currentmain, SHA6c8d6d3):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-requestdoesn't have this problemactions/setup/js/create_pull_request.cjs(andpush_to_pull_request_branch.cjs) delegate the push to the shared helperactions/setup/js/push_signed_commits.cjs, which:git rev-list <baseRef>..HEADto enumerate the new commits.additions[] / deletions[]payload fromgit diff-tree --raw+git cat-file blob(base64-encoded contents).createCommitOnBranchmutation per commit.git pushonly 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
@fileoverviewcomment says:push_repo_memory.cjsis the third caller that should be using it but isn't.Affected Files
actions/setup/js/push_repo_memory.cjs— thecommit+pull -X ours+pushblock (~lines 430–470)actions/setup/js/push_repo_memory.test.cjs— needs new cases for the signed-push path and ruleset-rejection fallbackactions/setup/js/push_signed_commits.cjs— already exists; consumed as-is, no API change neededdocs/src/content/docs/reference/repo-memory.md— should mention that commits are pushed as VerifiedReproduction
Workflow frontmatter
Steps
repo-memory:(e.g. the catalogue'sengineering-coach).push_repo_memorystep in the safe-outputs job: the agent stage and artifact upload succeed, then the push fails 4 times withGH013and the job is marked failed.Environment
main(verified at SHAa5a0353/push_repo_memory.cjsSHA6c8d6d3).memory/*(or all branches).Workarounds (none viable for our org)
memory/**— not allowed; the signed-commit rule is mandated org-wide and cannot be bypassed by repo admins.create-pull-requestagainstmain— works (because that path usespushSignedCommits), but defeats the design goal ofrepo-memory(no PR noise onmain, no human merge required).Implementation Plan
Option A: Port
push_repo_memory.cjsontopushSignedCommits(Recommended)actions/setup/js/push_repo_memory.cjs:git commit(the helper consumes the local commit; the actual push is via GraphQL).pull -X ours+ retry-git pushblock with:pushSignedCommitswill fail with a stale-expectedHeadOiderror if another run pushed in between. Wrap the call in the existingMAX_RETRIESloop, re-runningls-remoteon each retry to refreshbaseRef. Keep thepull -X oursto merge any concurrent updates locally before re-attempting.pushSignedCommitsfalls back togit 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.actions/setup/js/push_repo_memory.test.cjs:createCommitOnBranchis called with the expectedadditions[]andexpectedHeadOid, returns a verified OID, function resolves successfully.rest.git.createRefis called first, then GraphQL mutation, then resolution.409 Reference does not match expected object→ the function retries with a refreshedbaseRef.pushSignedCommitsfalls back togit pushand the underlying push fails withGH013→ repo-memory step fails with a clear error (regression guard).docs/src/content/docs/reference/repo-memory.md:repo-memorypushes via the same mutation ascreate-pull-request, so memory branches work in repos that require verified signatures.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 defaultallowed-extensions).Option B: Document the bypass workaround (not recommended as primary fix)
If shipping Option A is non-trivial, at minimum:
docs/src/content/docs/reference/repo-memory.md:memory/**to the bypass list, or the workflow will fail withGH013.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
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).make agent-finishbefore completing.