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
5 changes: 5 additions & 0 deletions .changeset/patch-use-signed-commit-pushes.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const fs = require("fs");
/** @type {typeof import("crypto")} */
const crypto = require("crypto");
const { updateActivationComment } = require("./update_activation_comment.cjs");
const { pushSignedCommits } = require("./push_signed_commits.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
Expand Down Expand Up @@ -81,6 +82,7 @@ function enforcePullRequestLimits(patchContent) {
throw new Error(`E003: Cannot create pull request with more than ${MAX_FILES} files (received ${fileCount})`);
}
}

/**
* Generate a patch preview with max 500 lines and 2000 chars for issue body
* @param {string} patchContent - The full patch content
Expand Down Expand Up @@ -739,7 +741,14 @@ async function main(config = {}) {
core.info(`Renamed branch to ${branchName}`);
}

await exec.exec(`git push origin ${branchName}`);
await pushSignedCommits({
githubClient,
owner: repoParts.owner,
repo: repoParts.repo,
branch: branchName,
baseRef: `origin/${baseBranch}`,
cwd: process.cwd(),
});
Comment on lines +744 to +751
core.info("Changes pushed to branch");

// Count new commits on PR branch relative to base, used to restrict
Expand Down Expand Up @@ -900,7 +909,14 @@ ${patchPreview}`;
core.info(`Renamed branch to ${branchName}`);
}

await exec.exec(`git push origin ${branchName}`);
await pushSignedCommits({
githubClient,
owner: repoParts.owner,
repo: repoParts.repo,
branch: branchName,
baseRef: `origin/${baseBranch}`,
cwd: process.cwd(),
});
core.info("Empty branch pushed successfully");

// Count new commits (will be 1 from the Initialize commit)
Expand Down
115 changes: 115 additions & 0 deletions actions/setup/js/push_signed_commits.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @ts-check
/// <reference types="@actions/github-script" />

/** @type {typeof import("fs")} */
const fs = require("fs");
/** @type {typeof import("path")} */
const path = require("path");

/**
* @fileoverview Signed Commit Push Helper
*
* Pushes local git commits to a remote branch using the GitHub GraphQL
* `createCommitOnBranch` mutation, so commits are cryptographically signed
* (verified) by GitHub. Falls back to a plain `git push` when the GraphQL
* approach is unavailable (e.g. GitHub Enterprise Server instances that do
* not support the mutation, or when branch-protection policies reject it).
*
* 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.
*/

/**
* Pushes local commits to a remote branch using the GitHub GraphQL
* `createCommitOnBranch` mutation so commits are cryptographically signed.
* Falls back to `git push` if the GraphQL approach fails (e.g. on GHES).
*
* @param {object} opts
* @param {any} opts.githubClient - Authenticated Octokit client with .graphql()
* @param {string} opts.owner - Repository owner
* @param {string} opts.repo - Repository name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exec module is used here but doesn't appear to be declared with a require at the top of the file. Consider adding const exec = require("@actions/exec"); to the imports section to make the dependency explicit.

* @param {string} opts.branch - Target branch name
* @param {string} opts.baseRef - Git ref of the remote head before commits were applied (used for rev-list)
* @param {string} opts.cwd - Working directory of the local git checkout
* @param {object} [opts.gitAuthEnv] - Environment variables for git push fallback auth
* @returns {Promise<void>}
*/
async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv }) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function signature destructures all options inline — consider defining a JSDoc typedef for the options object to improve IDE autocomplete and make it clearer what callers must provide.

// Collect the commits introduced (oldest-first)
const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--reverse", `${baseRef}..HEAD`], { cwd });
const shas = revListOut.trim().split("\n").filter(Boolean);

if (shas.length === 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good early-exit guard. The shas.length === 0 check prevents unnecessary GraphQL calls when there are no new commits to push.

core.info("pushSignedCommits: no new commits to push via GraphQL");
return;
}

core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch`);

try {
for (const sha of shas) {
// Get the current remote HEAD OID (updated each iteration)
const { stdout: oidOut } = await exec.getExecOutput("git", ["ls-remote", "origin", `refs/heads/${branch}`], { cwd });
const expectedHeadOid = oidOut.trim().split(/\s+/)[0];
if (!expectedHeadOid) {
throw new Error(`Could not resolve remote HEAD OID for branch ${branch}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ls-remote call on every iteration of the loop (for (const sha of shas)) makes an extra network round-trip per commit. For repos with many new commits, this could be slow. Consider fetching the initial remote HEAD OID once before the loop and updating it locally after each successful GraphQL mutation.

Comment on lines +51 to +56

// Full commit message (subject + body)
const { stdout: msgOut } = await exec.getExecOutput("git", ["log", "-1", "--format=%B", sha], { cwd });
const message = msgOut.trim();
const headline = message.split("\n")[0];
const body = message.split("\n").slice(1).join("\n").trim();

// File changes for this commit (supports Add/Modify/Delete/Rename/Copy)
const { stdout: nameStatusOut } = await exec.getExecOutput("git", ["diff", "--name-status", `${sha}^`, sha], { cwd });
/** @type {Array<{path: string, contents: string}>} */
const additions = [];
/** @type {Array<{path: string}>} */
const deletions = [];

for (const line of nameStatusOut.trim().split("\n").filter(Boolean)) {
const parts = line.split("\t");
const status = parts[0];
if (status === "D") {
deletions.push({ path: parts[1] });
} else if (status.startsWith("R") || status.startsWith("C")) {
// Rename or Copy: parts[1] = old path, parts[2] = new path
deletions.push({ path: parts[1] });
const content = fs.readFileSync(path.join(cwd, parts[2]));
additions.push({ path: parts[2], contents: content.toString("base64") });
} else {
// Added or Modified
const content = fs.readFileSync(path.join(cwd, parts[1]));
additions.push({ path: parts[1], contents: content.toString("base64") });
}
Comment on lines +76 to +85
}

/** @type {any} */
const input = {
branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch },
message: { headline, ...(body ? { body } : {}) },
fileChanges: { additions, deletions },
expectedHeadOid,
};

const result = await githubClient.graphql(
`mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`,
{ input }
);
const oid = result?.createCommitOnBranch?.commit?.oid;
core.info(`pushSignedCommits: signed commit created: ${oid}`);
}
core.info(`pushSignedCommits: all ${shas.length} commit(s) pushed as signed commits`);
} catch (graphqlError) {
core.warning(`pushSignedCommits: GraphQL signed push failed, falling back to git push: ${graphqlError instanceof Error ? graphqlError.message : String(graphqlError)}`);
await exec.exec("git", ["push", "origin", branch], {
cwd,
env: { ...process.env, ...(gitAuthEnv || {}) },
});
}
Comment on lines +96 to +112
}

module.exports = { pushSignedCommits };
Loading
Loading