Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 82 additions & 16 deletions actions/setup/js/push_repo_memory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ const path = require("path");

const { getErrorMessage } = require("./error_helpers.cjs");
const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");
const { execGitSync } = require("./git_helpers.cjs");
const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs");
const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs");
const { pushSignedCommits } = require("./push_signed_commits.cjs");

/**
* Push repo-memory changes to git branch
Expand Down Expand Up @@ -155,6 +156,13 @@ async function main() {
// involved. The memory branch only holds a handful of small files, so sparse
// checkout does not need to be altered for either case below.
core.info(`Checking out branch: ${branchName}...`);

// baseRef: the remote branch HEAD SHA before we make our local commit.
// Used by pushSignedCommits to compute git rev-list baseRef..HEAD (i.e. only
// our new commit) and as the OCC token for the GraphQL createCommitOnBranch
// mutation. Empty string when the branch is brand new (orphan).
let baseRef = "";

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

Expand All @@ -163,6 +171,10 @@ async function main() {
execGitSync(["fetch", repoUrl, `${branchName}:${branchName}`], { stdio: "pipe", suppressLogs: true });
execGitSync(["checkout", branchName], { stdio: "inherit" });
core.info(`Checked out existing branch: ${branchName}`);
// Capture the remote HEAD SHA so pushSignedCommits can compute which
// local commits are new (rev-list range: baseRef..HEAD).
baseRef = execGitSync(["rev-parse", "HEAD"]).trim();
core.info(`Captured baseRef for signed commit push: ${baseRef}`);
} catch (fetchError) {
// Determine whether the fetch failed because the branch does not exist
// (expected for new memory branches) or because of a network / auth
Expand All @@ -176,6 +188,8 @@ async function main() {
}

// Branch doesn't exist, create orphan branch
// baseRef stays "" — pushSignedCommits will create the branch via
// rest.git.createRef before the first GraphQL mutation.
core.info(`Branch ${branchName} does not exist, creating orphan branch...`);
execGitSync(["checkout", "--orphan", branchName], { stdio: "inherit" });
Comment on lines 160 to 194
// Reset the index to an empty tree. This is O(1) regardless of how many
Expand Down Expand Up @@ -436,33 +450,85 @@ async function main() {
return;
}

const repoUrl = `https://x-access-token:${ghToken}@${serverHost}/${targetRepo}.git`;
// Push using the GraphQL createCommitOnBranch mutation so commits are
// server-signed (verified) by GitHub. This satisfies "Require signed
// commits" branch-protection rules that reject plain git push.
//
// pushSignedCommits falls back to a plain `git push` when the mutation
// cannot be used (merge commits, symlinks, submodule entries). Under a
// strict signed-commits ruleset that fallback will also be rejected —
// that is expected behaviour: remove the unsupported file types and
// re-run.
Comment on lines +458 to +461
const [targetOwner, targetRepoName] = targetRepo.split("/");
// URL with embedded token used for the pull-on-retry merge step only;
// pushSignedCommits authenticates via the git extraheader set by
// actions/checkout (and the gitAuthEnv fallback for the git-push path).
const repoUrlWithToken = `https://x-access-token:${ghToken}@${serverHost}/${targetRepo}.git`;

// Point origin at the memory target repo so pushSignedCommits can resolve
// the remote branch HEAD (ls-remote origin) and the git-push fallback
// pushes to the correct repository.
execGitSync(["remote", "set-url", "origin", `https://${serverHost}/${targetRepo}.git`], { stdio: "pipe" });

const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
let currentBaseRef = baseRef;

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Pull with merge strategy (ours wins on conflicts)
core.info(`Pulling latest changes from ${branchName}...`);
try {
execGitSync(["pull", "--no-rebase", "-X", "ours", repoUrl, branchName], { stdio: "inherit", suppressLogs: true });
} catch (error) {
// Pull might fail if branch doesn't exist yet or on conflicts - this is acceptable
core.info(`Pull failed (this is expected when branch does not exist yet): ${getErrorMessage(error)}`);
}

// Push changes
core.info(`Pushing changes to ${branchName}...`);
core.info(`Pushing changes to ${branchName} (attempt ${attempt + 1}/${MAX_RETRIES + 1})...`);
try {
execGitSync(["push", repoUrl, `HEAD:${branchName}`], { stdio: "inherit" });
await pushSignedCommits({
githubClient: github,
owner: targetOwner,
repo: targetRepoName,
branch: branchName,
baseRef: currentBaseRef,
cwd: workspaceDir,
gitAuthEnv: getGitAuthEnv(ghToken),
});
core.info(`Successfully pushed changes to ${branchName} branch`);
return;
} catch (error) {
const errMsg = getErrorMessage(error);
if (attempt < MAX_RETRIES) {
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
core.warning(`Push failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${delay}ms: ${getErrorMessage(error)}`);
core.warning(`Push failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${delay}ms: ${errMsg}`);
await new Promise(resolve => setTimeout(resolve, delay));

// Refresh currentBaseRef and merge concurrent remote changes before
// retrying, in case another run pushed to the branch in the interim.
try {
const { stdout: lsOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branchName}`], { cwd: workspaceDir });
const remoteHead = lsOut.trim().split(/\s+/)[0] || "";
if (remoteHead && remoteHead !== currentBaseRef) {
currentBaseRef = remoteHead;
core.info(`Refreshed baseRef for retry: ${currentBaseRef}`);
// Merge the concurrent remote changes (ours wins on conflicts).
// Note: this may produce a merge commit; if so, pushSignedCommits
// will fall back to git push for this retry attempt.
try {
execGitSync(["pull", "--no-rebase", "-X", "ours", repoUrlWithToken, branchName], { stdio: "inherit", suppressLogs: true });
} catch (pullError) {
core.info(`Pull on retry failed (may be expected for new branches): ${getErrorMessage(pullError)}`);
}
}
} catch (lsRemoteError) {
// ls-remote failed; proceed with existing currentBaseRef
core.info(`ls-remote on retry failed, keeping existing baseRef: ${getErrorMessage(lsRemoteError)}`);
}
} else {
core.setFailed(`Failed to push changes after ${MAX_RETRIES + 1} attempts: ${getErrorMessage(error)}`);
// Surface a helpful message when the repository's signed-commits
// ruleset rejects the git-push fallback path.
if (/GH013|must have verified signatures|Commits must have verified signatures/i.test(errMsg)) {
core.setFailed(
`repo-memory: push to branch ${branchName} was rejected because the repository requires verified (signed) commits. ` +
`Commits pushed via the GitHub GraphQL API are signed automatically, but the signed-commit path could not be used for this push. ` +
`If your memory files contain symlinks, executable files, or submodule references, remove them and use regular plain-text files (.json, .jsonl, .txt, .md, .csv). ` +
`Original error: ${errMsg}`
);
} else {
core.setFailed(`Failed to push changes after ${MAX_RETRIES + 1} attempts: ${errMsg}`);
}
return;
}
}
Expand Down
108 changes: 106 additions & 2 deletions actions/setup/js/push_repo_memory.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1008,8 +1008,8 @@ describe("push_repo_memory.cjs - shell injection security tests", () => {
const scriptPath = path.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = fs.readFileSync(scriptPath, "utf8");

// Should import execGitSync from git_helpers, not use execSync or spawnSync directly
expect(scriptContent).toContain('const { execGitSync } = require("./git_helpers.cjs")');
// Should import execGitSync (and getGitAuthEnv) from git_helpers, not use execSync or spawnSync directly
expect(scriptContent).toContain('require("./git_helpers.cjs")');
expect(scriptContent).not.toContain('const { execSync } = require("child_process")');
expect(scriptContent).not.toContain('const { spawnSync } = require("child_process")');

Expand Down Expand Up @@ -1445,3 +1445,107 @@ describe("push_repo_memory.cjs - shell injection security tests", () => {
});
});
});

// ──────────────────────────────────────────────────────────────────────────────
// Signed-commit push tests
// Verifies that push_repo_memory delegates to pushSignedCommits (GraphQL-based
// signed commits) instead of plain git push.
// ──────────────────────────────────────────────────────────────────────────────

describe("push_repo_memory.cjs - signed commit push (pushSignedCommits delegation)", () => {
it("should import and call pushSignedCommits instead of plain git push (source check)", () => {
const nodeFs = require("fs");
const nodePath = require("path");

const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = nodeFs.readFileSync(scriptPath, "utf8");

// Must require the shared signed-commit helper
expect(scriptContent).toContain('require("./push_signed_commits.cjs")');
expect(scriptContent).toContain("pushSignedCommits");

// Must capture remote HEAD SHA before making the local commit
expect(scriptContent).toContain("baseRef");
expect(scriptContent).toContain('rev-parse", "HEAD"');

// Must configure origin remote so pushSignedCommits can resolve the remote branch
expect(scriptContent).toContain('"remote", "set-url"');
expect(scriptContent).toContain("getGitAuthEnv");

// Must use GitHub client (global `github`) from @actions/github-script
expect(scriptContent).toContain("githubClient: github");

// Must NOT use a plain git push as the primary push mechanism
// (only the retry-pull helper uses repoUrlWithToken)
expect(scriptContent).not.toContain('"push", repoUrlWithToken');

// Must include a GH013 detection message
expect(scriptContent).toContain("GH013");
expect(scriptContent).toContain("verified (signed) commits");
});

describe("pushSignedCommits integration - source checks", () => {
it("should capture baseRef from rev-parse HEAD after checkout and pass it to pushSignedCommits", () => {
const nodeFs = require("fs");
const nodePath = require("path");
const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = nodeFs.readFileSync(scriptPath, "utf8");

// Must capture remote HEAD SHA after checkout for the rev-list base
expect(scriptContent).toContain('"rev-parse", "HEAD"');
// baseRef variable must be assigned the result and trimmed
expect(scriptContent).toContain("baseRef =");
expect(scriptContent).toContain(".trim()");
// Must be threaded through to pushSignedCommits
expect(scriptContent).toContain("baseRef: currentBaseRef");
});

it("should configure origin remote URL to target repo (without embedded token) before calling pushSignedCommits", () => {
const nodeFs = require("fs");
const nodePath = require("path");
const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = nodeFs.readFileSync(scriptPath, "utf8");

// Must update origin via execGitSync args array (not shell string)
expect(scriptContent).toContain('"remote", "set-url"');
// Must pass owner and repo separately to pushSignedCommits
expect(scriptContent).toContain("owner: targetOwner");
expect(scriptContent).toContain("repo: targetRepoName");
// Must pass the global github client
expect(scriptContent).toContain("githubClient: github");
// Must pass gitAuthEnv for the git-push fallback
expect(scriptContent).toContain("gitAuthEnv: getGitAuthEnv(ghToken)");
});

it("should refresh baseRef via ls-remote on retry when pushSignedCommits fails", () => {
const nodeFs = require("fs");
const nodePath = require("path");
const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = nodeFs.readFileSync(scriptPath, "utf8");

// Must query ls-remote on retry to get the latest remote HEAD
expect(scriptContent).toContain('"ls-remote"');
expect(scriptContent).toContain("currentBaseRef");
// Must assign the fresh remote HEAD to currentBaseRef before retrying
expect(scriptContent).toContain("currentBaseRef = remoteHead");
// Must keep the retry loop with exponential backoff
expect(scriptContent).toContain("BASE_DELAY_MS * Math.pow(2, attempt)");
});

it("should surface a clear GH013 error message when signed-commit push is rejected (regression guard)", () => {
const nodeFs = require("fs");
const nodePath = require("path");
const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs");
const scriptContent = nodeFs.readFileSync(scriptPath, "utf8");

// Must detect GH013 / "Commits must have verified signatures" rejection
expect(scriptContent).toContain("GH013");
expect(scriptContent).toContain("must have verified signatures");
// Must emit an actionable error message that mentions the cause and fix
expect(scriptContent).toContain("verified (signed) commits");
expect(scriptContent).toContain("repo-memory:");
// Must include guidance about unsupported file types in the fallback message
expect(scriptContent).toContain("symlinks");
});
});
});
11 changes: 9 additions & 2 deletions docs/src/content/docs/reference/repo-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ Mounts at `/tmp/gh-aw/repo-memory-{id}/` during workflow execution. Required `id

## Behavior

Branches auto-create as orphans (default) or clone with `--depth 1`. Changes auto-commit after validation (`file-glob`, `max-file-size`, `max-file-count`), pull with `-X ours` (your changes win), and push when changes detected and threat detection passes.
Branches auto-create as orphans (default) or clone with `--depth 1`. Changes auto-commit after validation (`file-glob`, `max-file-size`, `max-file-count`) and push when changes detected and threat detection passes.

Commits are pushed via the [GitHub GraphQL `createCommitOnBranch` mutation](https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch), which signs each commit with GitHub's GPG key. This means repo-memory pushes are automatically **Verified** and satisfy repository rulesets that require signed commits (e.g. enterprise "Commits must have verified signatures" baselines).

:::note[Signed-commit fallback limitation]
The GraphQL mutation does not support symlinks, executable files (`chmod +x`), or submodule entries. If your memory artifact contains any of these, the helper falls back to a plain `git push`, which will be rejected by signed-commit rulesets. Keep memory artifacts as regular plain-text files (`.json`, `.jsonl`, `.txt`, `.md`, `.csv` — the default `allowed-extensions`).
:::
Comment on lines +69 to +73

## Comparison with Cache Memory

Expand All @@ -85,7 +91,8 @@ For fast 7-day caching without version control, see [Cache Memory](/gh-aw/refere
- **Validation failures**: Match `file-glob`, stay under `max-file-size` (10KB default), `max-file-count` (100 default), and `max-patch-size` (10KB default).
- **Patch too large**: If the total diff exceeds `max-patch-size` (default 10KB), the push is rejected. Reduce the number or size of changes, or increase `max-patch-size` in the configuration.
- **Changes not persisting**: Check directory path, workflow completion, push errors in logs.
- **Merge conflicts**: Uses `-X ours` (your changes win). Read before writing to preserve data.
- **Merge conflicts**: Concurrent pushes are handled: if another run has pushed since the branch was checked out, the GraphQL mutation replays your file diff on top of the latest remote state (your changes win).
- **GH013 — Commits must have verified signatures**: Repo-memory uses GraphQL signed commits, so this error should not appear for standard plain-text memory files. If it does, your artifact contains a symlink, executable file, or submodule entry that forced a fallback to `git push`. Remove the unsupported file type and re-run.
Comment on lines +94 to +95

## Security

Expand Down
Loading