mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04: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 type { Octokits } from "../api/client";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
|
import { resignCommits } from "./resign-commits";
|
||||||
|
import { parseGitHubContext } from "../context";
|
||||||
|
|
||||||
export async function checkAndCommitOrDeleteBranch(
|
export async function checkAndCommitOrDeleteBranch(
|
||||||
octokit: Octokits,
|
octokit: Octokits,
|
||||||
@@ -101,7 +103,48 @@ export async function checkAndCommitOrDeleteBranch(
|
|||||||
shouldDeleteBranch = true;
|
shouldDeleteBranch = true;
|
||||||
}
|
}
|
||||||
} else {
|
} 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}`;
|
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||||
branchLink = `\n[View branch](${branchUrl})`;
|
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import { constants } from "fs";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { retryWithBackoff } from "../utils/retry";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
@@ -162,6 +163,35 @@ async function getOrCreateBranchRef(
|
|||||||
return baseSha;
|
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
|
// Commit files tool
|
||||||
server.tool(
|
server.tool(
|
||||||
"commit_files",
|
"commit_files",
|
||||||
@@ -223,6 +253,9 @@ server.tool(
|
|||||||
? filePath
|
? filePath
|
||||||
: join(REPO_DIR, 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.)
|
// Check if file is binary (images, etc.)
|
||||||
const isBinaryFile =
|
const isBinaryFile =
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
/\.(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 tree entry with blob SHA
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
sha: blobData.sha,
|
sha: blobData.sha,
|
||||||
};
|
};
|
||||||
@@ -270,7 +303,7 @@ server.tool(
|
|||||||
const content = await readFile(fullPath, "utf-8");
|
const content = await readFile(fullPath, "utf-8");
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: "100644",
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: content,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user