Merge pull request 'feat: add dedicated claude-bot user for Forgejo (PRD #5)' (#8) from feature/prd-5-dedicated-forgejo-user into main

This commit is contained in:
Vlad Mocanu committed 2026-02-25 19:07:49 +00:00
commit 40731a0f5d
18 files changed
+161 -21

No files matched your search

+32 -6
View File
@@ -13,6 +13,11 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Pin Bun version across all jobs. Bun 1.3.x broke spyOn(fs, ...) in tests.
# Bump this when upstream tests are updated for newer Bun versions.
env:
BUN_VERSION: "1.2.21"
jobs:
test:
runs-on: grunt
@@ -25,9 +30,16 @@ jobs:
- name: Install Bun
run: |
if command -v bun &> /dev/null; then
echo "Bun already installed: $(bun --version)"
INSTALLED=$(bun --version)
if [ "$INSTALLED" = "$BUN_VERSION" ]; then
echo "Bun $BUN_VERSION already installed"
else
curl -fsSL https://bun.sh/install | bash
echo "Bun $INSTALLED installed, need $BUN_VERSION — reinstalling"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
else
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
@@ -56,9 +68,16 @@ jobs:
- name: Install Bun
run: |
if command -v bun &> /dev/null; then
echo "Bun already installed: $(bun --version)"
INSTALLED=$(bun --version)
if [ "$INSTALLED" = "$BUN_VERSION" ]; then
echo "Bun $BUN_VERSION already installed"
else
curl -fsSL https://bun.sh/install | bash
echo "Bun $INSTALLED installed, need $BUN_VERSION — reinstalling"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
else
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
@@ -79,9 +98,16 @@ jobs:
- name: Install Bun
run: |
if command -v bun &> /dev/null; then
echo "Bun already installed: $(bun --version)"
INSTALLED=$(bun --version)
if [ "$INSTALLED" = "$BUN_VERSION" ]; then
echo "Bun $BUN_VERSION already installed"
else
curl -fsSL https://bun.sh/install | bash
echo "Bun $INSTALLED installed, need $BUN_VERSION — reinstalling"
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
else
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
fi
+1
View File
@@ -40,6 +40,7 @@ jobs:
github_token: ${{ secrets.CLAUDE_BOT_TOKEN }}
bot_id: "48"
bot_name: "claude-bot"
bot_email: "claude@wxs.ro"
platform: "forgejo"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+2
View File
@@ -47,6 +47,8 @@ This fork adds Forgejo support via a lean adapter pattern — only 4 genuinely d
All other `src/github/` modules work on Forgejo via Octokit REST as-is (permissions, comments, branches, git config, context, formatter).
**Bot identity on Forgejo**: A dedicated `claude-bot` user provides bot identity (like GitHub's `claude[bot]`). The `bot_id`, `bot_name`, and `bot_email` action inputs control git commit attribution. When `bot_email` is set, it overrides the default noreply email format — set it to the bot user's registered email so Forgejo links commits to the profile. See `docs/forgejo.md` for setup.
**Key integration points** (these files have platform branches and are merge-conflict surface):
- `src/entrypoints/run.ts` — Platform branch at top for token setup
+5
View File
@@ -105,6 +105,10 @@ inputs:
description: "GitHub username to use for git operations (defaults to Claude's bot name)"
required: false
default: "claude[bot]"
bot_email:
description: "Email to use for git commits. When set, overrides the default noreply email format. Useful for Forgejo where matching the user's registered email links commits to the profile."
required: false
default: ""
track_progress:
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
required: false
@@ -245,6 +249,7 @@ runs:
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
BOT_ID: ${{ inputs.bot_id }}
BOT_NAME: ${{ inputs.bot_name }}
BOT_EMAIL: ${{ inputs.bot_email }}
TRACK_PROGRESS: ${{ inputs.track_progress }}
INCLUDE_FIX_LINKS: ${{ inputs.include_fix_links }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
+30 -9
View File
@@ -3,6 +3,7 @@
**Issue**: [#5](https://git.wxs.ro/actions/claude-code-action/issues/5)
**Priority**: High
**Created**: 2026-02-25
**Status**: ~85% complete — code and infra done, pending E2E validation after merge
---
@@ -25,15 +26,16 @@ Create a dedicated `claude-bot` user on the Forgejo instance (git.wxs.ro), gener
### Phase 1: Forgejo Admin Setup
- [ ] **M1: Create dedicated `claude-bot` user on git.wxs.ro**
- [x] **M1: Create dedicated `claude-bot` user on git.wxs.ro**
- Create user via Forgejo admin panel or API (`POST /admin/users`)
- Set appropriate display name (e.g., "Claude Bot")
- Set avatar to distinguish from human users
- User should NOT have admin privileges
- **Validation**: User exists and can log in
- **Evidence**: User ID 48, `is_admin: false`, avatar set to Claude logo
- [ ] **M2: Configure bot user permissions and token**
- [x] **M2: Configure bot user permissions and token**
- Add `claude-bot` to the `actions` org (and any other orgs where the action runs)
- Grant `write` permission on repos where Claude needs to operate
- Generate a scoped API token for the bot user with minimal required permissions:
@@ -41,30 +43,35 @@ Create a dedicated `claude-bot` user on the Forgejo instance (git.wxs.ro), gener
- `packages` (if needed for container operations)
- NO admin scope, NO user scope beyond what's needed
- **Validation**: Token can create comments and push branches on a test repo
- **Evidence**: `Claude` team with `write` permission created in all 12 orgs, token validated (comment create/delete on issue #5, `push: true` on k8s/flux, crossplane/harbor-resources)
### Phase 2: Action Configuration
- [ ] **M3: Update workflow to use bot token**
- [x] **M3: Update workflow to use bot token**
- Store the bot token as a Forgejo Actions secret (e.g., `CLAUDE_BOT_TOKEN`)
- Update `claude.yml` workflow to pass `github_token: ${{ secrets.CLAUDE_BOT_TOKEN }}` instead of the human token
- Update `bot_id` and `bot_name` inputs to match the new Forgejo user's ID and username
- Document the token rotation procedure
- **Validation**: Action runs with bot token, comments appear as `claude-bot`
- **Evidence**: `CLAUDE_BOT_TOKEN` org secret in all 12 orgs, `claude.yml` updated with `bot_id: "48"`, `bot_name: "claude-bot"`, `bot_email: "claude@wxs.ro"`. New `bot_email` input added to `action.yml`.
- [ ] **Pending E2E**: Trigger a real `@claude` mention to verify comments appear as `claude-bot`
- [ ] **M4: Verify git commit attribution**
- [x] **M4: Verify git commit attribution**
- Ensure `git config user.name` and `user.email` use the bot user's identity
- Verify that `src/github/operations/git-config.ts` correctly picks up the bot user info from the token
- Test that pushed commits show the bot user as author in Forgejo UI
- **Validation**: A tag-mode response that creates a branch shows `claude-bot` as commit author
- **Evidence**: `git-config.ts` uses `user.email` when provided (falls back to noreply), `context.ts` reads `BOT_EMAIL` env var, all call sites pass `botEmail`. 794 tests pass.
- [ ] **Pending E2E**: Trigger a tag-mode response that creates a branch to verify commit authorship
### Phase 3: Documentation & Hardening
- [ ] **M5: Update documentation and test across repos**
- Update `docs/forgejo.md` with bot user setup instructions
- Document token rotation procedure (tokens expire, need renewal plan)
- Test the bot user setup on at least one additional repo beyond `claude-code-action`
- Update `CLAUDE.md` if any workflow changes affect development
- [~] **M5: Update documentation and test across repos**
- [x] Update `docs/forgejo.md` with bot user setup instructions (full 6-step guide added)
- [x] Document token rotation procedure (tokens expire, need renewal plan)
- [ ] Test the bot user setup on at least one additional repo beyond `claude-code-action`
- [x] Update `CLAUDE.md` if any workflow changes affect development
- **Validation**: Another developer can follow the docs to set up the bot user on a new Forgejo instance
## Dependencies
@@ -86,3 +93,17 @@ Create a dedicated `claude-bot` user on the Forgejo instance (git.wxs.ro), gener
- All Claude comments and commits on Forgejo repos appear as `claude-bot`, not the human user
- The bot token has minimal scoped permissions (no admin access)
- Documentation allows reproducing the setup on a fresh Forgejo instance
## Implementation Notes
### New `bot_email` Action Input
Added `bot_email` input to `action.yml` — when set, overrides the default noreply email format (`{id}+{name}@users.noreply.{host}`). On Forgejo, setting this to the bot user's registered email links commits to the bot's profile in the UI.
### Org-Level Secrets
`CLAUDE_BOT_TOKEN` is stored as an org-level secret (not repo-level) in all 12 orgs with `visibility: "all"`. This means any new repo in any org will automatically have access.
### Team Structure
Each org has a `Claude` team with `write` permission on all repositories (`includes_all_repositories: true`). New repos are automatically covered.
+12 -2
View File
@@ -20,6 +20,8 @@ import {
import type { ParsedGitHubContext } from "../github/context";
import type { CommonFields, PreparedContext, EventData } from "./types";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { detectPlatform } from "../platform/detector";
import { getForgejoRunIndex } from "../forgejo/comment-logic";
import { extractUserRequest } from "../utils/extract-user-request";
export type { CommonFields, PreparedContext } from "./types";
@@ -525,7 +527,11 @@ Images from comments have been saved to disk. Paths are in the formatted content
: "No description provided";
const entityType = eventData.isPR ? "pull request" : "issue";
const jobUrl = `${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const runIdentifier =
detectPlatform() === "forgejo"
? getForgejoRunIndex()
: process.env.GITHUB_RUN_ID;
const jobUrl = `${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${runIdentifier}`;
let promptContent = `You were tagged on a GitHub ${entityType} via "${context.triggerPhrase}". Read the request and decide how to help.
@@ -624,6 +630,10 @@ export function generateDefaultPrompt(
const { eventData } = context;
const { eventType, triggerContext } = getEventTypeAndContext(context);
const runIdentifier =
detectPlatform() === "forgejo"
? getForgejoRunIndex()
: process.env.GITHUB_RUN_ID;
const formattedContext = formatContext(contextData, eventData.isPR);
const formattedComments = formatComments(comments, imageUrlMap);
@@ -831,7 +841,7 @@ ${
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID})" at the bottom of your response (branch link if there is one should also be included there).
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${runIdentifier})" at the bottom of your response (branch link if there is one should also be included there).
CAPABILITIES AND LIMITATIONS:
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
+4 -1
View File
@@ -10,6 +10,7 @@ import {
import {
updateForgejoCommentBody,
createForgejoPrCompareUrl,
getForgejoRunIndex,
} from "../forgejo/comment-logic";
import { detectPlatform } from "../platform/detector";
import {
@@ -54,7 +55,9 @@ export async function updateCommentLink(
const platform = detectPlatform();
const serverUrl = GITHUB_SERVER_URL;
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const runIdentifier =
platform === "forgejo" ? getForgejoRunIndex() : process.env.GITHUB_RUN_ID;
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${runIdentifier}`;
let comment;
let isPRReviewComment = false;
+18
View File
@@ -34,6 +34,24 @@ function getServerUrl(): string {
return process.env.GITHUB_SERVER_URL || "https://github.com";
}
// ---------------------------------------------------------------------------
// Run index helper
// ---------------------------------------------------------------------------
/**
* Get the run identifier for Forgejo URLs.
*
* Forgejo uses the per-repo sequential run index in URLs (`/actions/runs/{index}`),
* not the internal database run ID. The Forgejo runner sets:
* - GITHUB_RUN_ID = internal DB primary key (e.g. 11943)
* - GITHUB_RUN_NUMBER = per-repo sequential index (e.g. 661)
*
* GitHub uses GITHUB_RUN_ID in URLs, but Forgejo uses GITHUB_RUN_NUMBER.
*/
export function getForgejoRunIndex(): string {
return process.env.GITHUB_RUN_NUMBER || process.env.GITHUB_RUN_ID || "0";
}
// ---------------------------------------------------------------------------
// Spinner
// ---------------------------------------------------------------------------
+2
View File
@@ -94,6 +94,7 @@ type BaseContext = {
sshSigningKey: string;
botId: string;
botName: string;
botEmail: string;
allowedBots: string;
allowedNonWriteUsers: string;
trackProgress: boolean;
@@ -154,6 +155,7 @@ export function parseGitHubContext(): GitHubContext {
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
botEmail: process.env.BOT_EMAIL ?? "",
allowedBots: process.env.ALLOWED_BOTS ?? "",
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
trackProgress: process.env.TRACK_PROGRESS === "true",
@@ -10,6 +10,7 @@ import { createJobRunLink, createCommentBody } from "./common";
import {
createForgejoJobRunLink,
createForgejoCommentBody,
getForgejoRunIndex,
} from "../../../forgejo/comment-logic";
import { detectPlatform } from "../../../platform/detector";
import {
@@ -30,7 +31,7 @@ export async function createInitialComment(
const jobRunLink =
platform === "forgejo"
? createForgejoJobRunLink(owner, repo, context.runId)
? createForgejoJobRunLink(owner, repo, getForgejoRunIndex())
: createJobRunLink(owner, repo, context.runId);
const initialBody =
platform === "forgejo"
+4 -2
View File
@@ -17,6 +17,7 @@ const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
type GitUser = {
login: string;
id: number;
email?: string;
};
export async function configureGitAuth(
@@ -37,10 +38,11 @@ export async function configureGitAuth(
console.log("Configuring git user...");
const botName = user.login;
const botId = user.id;
const botEmail = user.email || `${botId}+${botName}@${noreplyDomain}`;
console.log(`Setting git user as ${botName}...`);
await $`git config user.name "${botName}"`;
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
console.log(`✓ Set git user as ${botName}`);
await $`git config user.email "${botEmail}"`;
console.log(`✓ Set git user as ${botName} <${botEmail}>`);
// Remove the authorization header that actions/checkout sets
console.log("Removing existing git authentication headers...");
+2
View File
@@ -41,6 +41,7 @@ export async function prepareAgentMode({
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
email: context.inputs.botEmail || undefined,
};
try {
await configureGitAuth(githubToken, context, user);
@@ -53,6 +54,7 @@ export async function prepareAgentMode({
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
email: context.inputs.botEmail || undefined,
};
try {
+2
View File
@@ -86,6 +86,7 @@ export async function prepareTagMode({
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
email: context.inputs.botEmail || undefined,
};
try {
await configureGitAuth(githubToken, context, user);
@@ -98,6 +99,7 @@ export async function prepareTagMode({
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
email: context.inputs.botEmail || undefined,
};
try {
+41
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
FORGEJO_SPINNER_HTML,
getForgejoRunIndex,
createForgejoJobRunLink,
createForgejoBranchLink,
createForgejoPrCompareUrl,
@@ -37,6 +38,46 @@ describe("FORGEJO_SPINNER_HTML", () => {
});
});
// ---------------------------------------------------------------------------
// getForgejoRunIndex
// ---------------------------------------------------------------------------
describe("getForgejoRunIndex", () => {
const origRunNumber = process.env.GITHUB_RUN_NUMBER;
const origRunId = process.env.GITHUB_RUN_ID;
afterEach(() => {
if (origRunNumber !== undefined) {
process.env.GITHUB_RUN_NUMBER = origRunNumber;
} else {
delete process.env.GITHUB_RUN_NUMBER;
}
if (origRunId !== undefined) {
process.env.GITHUB_RUN_ID = origRunId;
} else {
delete process.env.GITHUB_RUN_ID;
}
});
it("prefers GITHUB_RUN_NUMBER over GITHUB_RUN_ID", () => {
process.env.GITHUB_RUN_NUMBER = "661";
process.env.GITHUB_RUN_ID = "11943";
expect(getForgejoRunIndex()).toBe("661");
});
it("falls back to GITHUB_RUN_ID when GITHUB_RUN_NUMBER is not set", () => {
delete process.env.GITHUB_RUN_NUMBER;
process.env.GITHUB_RUN_ID = "11943";
expect(getForgejoRunIndex()).toBe("11943");
});
it("returns '0' when neither env var is set", () => {
delete process.env.GITHUB_RUN_NUMBER;
delete process.env.GITHUB_RUN_ID;
expect(getForgejoRunIndex()).toBe("0");
});
});
// ---------------------------------------------------------------------------
// createForgejoJobRunLink
// ---------------------------------------------------------------------------
+1
View File
@@ -36,6 +36,7 @@ describe("prepareMcpConfig", () => {
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
botEmail: "",
allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false,
+1
View File
@@ -23,6 +23,7 @@ const defaultInputs = {
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
botEmail: "",
allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false,
+1
View File
@@ -23,6 +23,7 @@ describe("detectMode with enhanced routing", () => {
sshSigningKey: "",
botId: "123456",
botName: "claude-bot",
botEmail: "",
allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false,
+1
View File
@@ -71,6 +71,7 @@ describe("checkWritePermissions", () => {
sshSigningKey: "",
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
botEmail: "",
allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false,