mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
Fix file mode permissions in commit signing operations
- Add getFileMode() function to detect proper file permissions - Update commit_files tool to preserve execute permissions - Support all Git file modes: 100644, 100755, 040000, 120000 - Prevent executable files from losing execute permissions - Add resign-commits.ts and branch cleanup logic for commit signing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { $ } from "bun";
|
||||
import { resignCommits } from "./resign-commits";
|
||||
import { parseGitHubContext } from "../context";
|
||||
|
||||
export async function checkAndCommitOrDeleteBranch(
|
||||
octokit: Octokits,
|
||||
@@ -101,7 +103,48 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
// Branch has commits
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has ${comparison.total_commits} commits`,
|
||||
);
|
||||
|
||||
// First, check for any uncommitted changes
|
||||
try {
|
||||
const gitStatus = await $`git status --porcelain`.quiet();
|
||||
const hasUncommittedChanges =
|
||||
gitStatus.stdout.toString().trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
console.log(
|
||||
"Found uncommitted changes after commits, committing them...",
|
||||
);
|
||||
|
||||
// Add all changes
|
||||
await $`git add -A`;
|
||||
|
||||
// Commit any remaining changes
|
||||
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||
const commitMessage = `Auto-commit: Save remaining changes from Claude\n\nRun ID: ${runId}`;
|
||||
await $`git commit -m ${commitMessage}`;
|
||||
|
||||
// Push the changes
|
||||
await $`git push origin ${claudeBranch}`;
|
||||
|
||||
console.log(
|
||||
"✅ Successfully committed and pushed remaining changes",
|
||||
);
|
||||
}
|
||||
} catch (gitError) {
|
||||
console.error("Error checking for remaining changes:", gitError);
|
||||
}
|
||||
|
||||
// Re-sign all commits made by Claude if commit signing is enabled
|
||||
if (useCommitSigning) {
|
||||
const context = parseGitHubContext();
|
||||
await resignCommits(claudeBranch, baseBranch, octokit, context);
|
||||
}
|
||||
|
||||
// Add branch link since there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
159
src/github/operations/resign-commits.ts
Normal file
159
src/github/operations/resign-commits.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { GitHubContext } from "../context";
|
||||
import { $ } from "bun";
|
||||
|
||||
interface CommitInfo {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
};
|
||||
files: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all commits made by claude[bot] on the current branch that aren't on the base branch
|
||||
*/
|
||||
async function getClaudeCommits(baseBranch: string): Promise<CommitInfo[]> {
|
||||
try {
|
||||
// Get commits that are on current branch but not on base branch
|
||||
const output =
|
||||
await $`git log ${baseBranch}..HEAD --pretty=format:"%H|%an|%ae|%aI|%B%x00" --name-only`.quiet();
|
||||
const rawCommits = output.stdout
|
||||
.toString()
|
||||
.trim()
|
||||
.split("\x00")
|
||||
.filter((c) => c);
|
||||
|
||||
const commits: CommitInfo[] = [];
|
||||
|
||||
for (const rawCommit of rawCommits) {
|
||||
const lines = rawCommit.trim().split("\n");
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
const firstLine = lines[0];
|
||||
if (!firstLine) continue;
|
||||
|
||||
const parts = firstLine.split("|");
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const [sha, authorName, authorEmail, date, ...rest] = parts;
|
||||
|
||||
// Find where the file list starts (after empty line)
|
||||
let messageEndIndex = rest.findIndex((line) => line === "");
|
||||
if (messageEndIndex === -1) messageEndIndex = rest.length;
|
||||
|
||||
const message = rest.slice(0, messageEndIndex).join("\n");
|
||||
const files = rest.slice(messageEndIndex + 1).filter((f) => f);
|
||||
|
||||
// Only include commits by claude[bot]
|
||||
if (
|
||||
authorName &&
|
||||
authorEmail &&
|
||||
(authorName === "claude[bot]" || authorEmail.includes("claude"))
|
||||
) {
|
||||
commits.push({
|
||||
sha: sha || "",
|
||||
message,
|
||||
author: {
|
||||
name: authorName,
|
||||
email: authorEmail,
|
||||
date: date || new Date().toISOString(),
|
||||
},
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return commits.reverse(); // Return in chronological order
|
||||
} catch (error) {
|
||||
console.error("Error getting Claude commits:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create commits using GitHub API to get them signed
|
||||
*/
|
||||
export async function resignCommits(
|
||||
branch: string,
|
||||
baseBranch: string,
|
||||
client: Octokits,
|
||||
context: GitHubContext,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log(
|
||||
`Checking for unsigned commits by Claude on branch ${branch}...`,
|
||||
);
|
||||
|
||||
// Get all commits made by Claude
|
||||
const claudeCommits = await getClaudeCommits(baseBranch);
|
||||
|
||||
if (claudeCommits.length === 0) {
|
||||
console.log("No commits by Claude found to re-sign");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`Found ${claudeCommits.length} commits by Claude to re-sign`);
|
||||
|
||||
// Get the base commit (last commit before Claude's commits)
|
||||
const baseCommitOutput = await $`git rev-parse ${baseBranch}`.quiet();
|
||||
const baseCommitSha = baseCommitOutput.stdout.toString().trim();
|
||||
|
||||
// Create a mapping of old SHA to new SHA
|
||||
const shaMapping = new Map<string, string>();
|
||||
let currentParentSha = baseCommitSha;
|
||||
|
||||
for (const commit of claudeCommits) {
|
||||
console.log(
|
||||
`Re-signing commit: ${commit.sha.substring(0, 7)} - ${commit.message.split("\n")[0]}`,
|
||||
);
|
||||
|
||||
// Get the tree SHA for this commit
|
||||
const treeOutput = await $`git rev-parse ${commit.sha}^{tree}`.quiet();
|
||||
const treeSha = treeOutput.stdout.toString().trim();
|
||||
|
||||
// Create the commit via API (which will sign it)
|
||||
const { data: newCommit } = await client.rest.git.createCommit({
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
message: commit.message,
|
||||
tree: treeSha,
|
||||
parents: [currentParentSha],
|
||||
author: {
|
||||
name: commit.author.name,
|
||||
email: commit.author.email,
|
||||
date: commit.author.date,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Created signed commit: ${newCommit.sha.substring(0, 7)}`);
|
||||
shaMapping.set(commit.sha, newCommit.sha);
|
||||
currentParentSha = newCommit.sha;
|
||||
}
|
||||
|
||||
// Update the branch to point to the new commit
|
||||
console.log(`Updating branch ${branch} to point to signed commits...`);
|
||||
await client.rest.git.updateRef({
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
ref: `heads/${branch}`,
|
||||
sha: currentParentSha,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Pull the updated branch locally
|
||||
console.log("Pulling signed commits locally...");
|
||||
await $`git fetch origin ${branch}`;
|
||||
await $`git reset --hard origin/${branch}`;
|
||||
|
||||
console.log(`✅ Successfully re-signed ${claudeCommits.length} commits`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error re-signing commits:", error);
|
||||
// Don't fail the action if we can't re-sign
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile } from "fs/promises";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { constants } from "fs";
|
||||
import fetch from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
@@ -162,6 +163,35 @@ async function getOrCreateBranchRef(
|
||||
return baseSha;
|
||||
}
|
||||
|
||||
// Get the appropriate Git file mode for a file
|
||||
async function getFileMode(filePath: string): Promise<string> {
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
if (fileStat.isFile()) {
|
||||
// Check if execute bit is set for user
|
||||
if (fileStat.mode & constants.S_IXUSR) {
|
||||
return "100755"; // Executable file
|
||||
} else {
|
||||
return "100644"; // Regular file
|
||||
}
|
||||
} else if (fileStat.isDirectory()) {
|
||||
return "040000"; // Directory (tree)
|
||||
} else if (fileStat.isSymbolicLink()) {
|
||||
return "120000"; // Symbolic link
|
||||
} else {
|
||||
// Fallback for unknown file types
|
||||
return "100644";
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't stat the file, default to regular file
|
||||
console.warn(
|
||||
`Could not determine file mode for ${filePath}, using default: ${error}`,
|
||||
);
|
||||
return "100644";
|
||||
}
|
||||
}
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
@@ -223,6 +253,9 @@ server.tool(
|
||||
? filePath
|
||||
: join(REPO_DIR, filePath);
|
||||
|
||||
// Get the proper file mode based on file permissions
|
||||
const fileMode = await getFileMode(fullPath);
|
||||
|
||||
// Check if file is binary (images, etc.)
|
||||
const isBinaryFile =
|
||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||
@@ -261,7 +294,7 @@ server.tool(
|
||||
// Return tree entry with blob SHA
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
mode: fileMode,
|
||||
type: "blob",
|
||||
sha: blobData.sha,
|
||||
};
|
||||
@@ -270,7 +303,7 @@ server.tool(
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
mode: fileMode,
|
||||
type: "blob",
|
||||
content: content,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user