fix: Forgejo 12 compatibility - use separate auth fields for private repos (#102)

## Problem
Forgejo 12.0+ rejects migration API calls with credentials embedded in URLs,
causing HTTP 422 errors when mirroring private GitHub repositories.

## Root Cause
Breaking security change in Forgejo 12.0 (July 2025) enforces credential
separation to prevent accidental exposure in logs/errors. Previous versions
(Forgejo 11.x, Gitea 1.x) accepted embedded credentials.

## Solution
- Use separate `auth_username` and `auth_token` fields instead of embedding
  credentials in clone URLs
- Set `auth_username` to "oauth2" for GitHub token authentication
- Pass GitHub token via `auth_token` field

## Changes
- src/lib/gitea.ts:
  - mirrorGithubRepoToGitea(): Use separate auth fields for private repos
  - mirrorGitHubRepoToGiteaOrg(): Use separate auth fields for private repos

- .github/workflows/docker-build.yml:
  - Enable PR image building and pushing to GHCR
  - Tag PR images as pr-<number> for easy testing
  - Add automated PR comment with image details and testing instructions
  - Separate load step for security scanning

## Backward Compatibility
 Works with Forgejo 12.0+
 Works with Forgejo 11.x and earlier
 Works with Gitea 1.x

## Testing
Public repos:  Working (no auth needed)
Private repos:  Fixed (separate auth fields)

Fixes #102
This commit is contained in:
Arunavo Ray
2025-09-30 23:12:33 +05:30
parent 5add8766a4
commit 34f741beef
2 changed files with 129 additions and 63 deletions

View File

@@ -48,7 +48,6 @@ jobs:
- name: Log into registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -89,6 +88,7 @@ jobs:
type=sha,prefix=,suffix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
type=ref,event=pr,prefix=pr-
# Build and push Docker image
- name: Build and push Docker image
@@ -97,20 +97,77 @@ jobs:
with:
context: .
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Load image locally for security scanning (PRs only)
- name: Load image for scanning
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
load: true
tags: gitea-mirror:scan
cache-from: type=gha
# Wait for image to be available in registry
- name: Wait for image availability
if: github.event_name != 'pull_request'
run: |
echo "Waiting for image to be available in registry..."
sleep 5
# Add comment to PR with image details
- name: Comment PR with image tag
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prNumber = context.payload.pull_request.number;
const imageTag = `pr-${prNumber}`;
const imagePath = `${{ env.REGISTRY }}/${{ env.IMAGE }}:${imageTag}`;
const comment = `## 🐳 Docker Image Built Successfully
Your PR image is available for testing:
**Image Tag:** \`${imageTag}\`
**Full Image Path:** \`${imagePath}\`
### Pull and Test
\`\`\`bash
docker pull ${imagePath}
docker run -d -p 3000:3000 --name gitea-mirror-test ${imagePath}
\`\`\`
### Docker Compose Testing
\`\`\`yaml
services:
gitea-mirror:
image: ${imagePath}
ports:
- "3000:3000"
environment:
- BETTER_AUTH_SECRET=your-secret-here
\`\`\`
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and only built for \`linux/amd64\` to speed up CI.
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`).
---
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;
github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
# Docker Scout comprehensive security analysis
- name: Docker Scout - Vulnerability Analysis & Recommendations
uses: docker/scout-action@v1

View File

@@ -356,21 +356,8 @@ export const mirrorGithubRepoToGitea = async ({
status: "mirroring",
});
let cloneAddress = repository.cloneUrl;
// If the repository is private, inject the GitHub token into the clone URL
if (repository.isPrivate) {
if (!config.githubConfig.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${decryptedConfig.githubConfig.token}@`
);
}
// Use clean clone URL without embedded credentials (Forgejo 12+ security requirement)
const cloneAddress = repository.cloneUrl;
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
@@ -433,20 +420,37 @@ export const mirrorGithubRepoToGitea = async ({
console.log(`Proceeding with mirror creation for ${targetRepoName}`);
}
// Prepare migration payload
// For private repos, use separate auth fields instead of embedding credentials in URL
// This is required for Forgejo 12+ which rejects URLs with embedded credentials
const migratePayload: any = {
clone_addr: cloneAddress,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
wiki: config.giteaConfig?.wiki || false,
lfs: config.giteaConfig?.lfs || false,
private: repository.isPrivate,
repo_owner: repoOwner,
description: "",
service: "git",
};
// Add authentication for private repositories
if (repository.isPrivate) {
if (!config.githubConfig.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
// Use separate auth fields (required for Forgejo 12+ compatibility)
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
const response = await httpPost(
apiUrl,
{
clone_addr: cloneAddress,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
private: repository.isPrivate,
repo_owner: repoOwner,
description: "",
service: "git",
},
migratePayload,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -800,20 +804,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
`Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}`
);
let cloneAddress = repository.cloneUrl;
if (repository.isPrivate) {
if (!config.githubConfig?.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${decryptedConfig.githubConfig.token}@`
);
}
// Use clean clone URL without embedded credentials (Forgejo 12+ security requirement)
const cloneAddress = repository.cloneUrl;
// Mark repos as "mirroring" in DB
await db
@@ -829,18 +821,35 @@ export async function mirrorGitHubRepoToGiteaOrg({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
// Prepare migration payload
// For private repos, use separate auth fields instead of embedding credentials in URL
// This is required for Forgejo 12+ which rejects URLs with embedded credentials
const migratePayload: any = {
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
wiki: config.giteaConfig?.wiki || false,
lfs: config.giteaConfig?.lfs || false,
private: repository.isPrivate,
};
// Add authentication for private repositories
if (repository.isPrivate) {
if (!config.githubConfig?.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
// Use separate auth fields (required for Forgejo 12+ compatibility)
migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username
migratePayload.auth_token = decryptedConfig.githubConfig.token;
}
const migrateRes = await httpPost(
apiUrl,
{
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval
wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists
lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured
private: repository.isPrivate,
},
migratePayload,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}