--- name: gitea-transfer description: Guide for transferring repositories from GitHub to self-hosted Gitea, including remotes, branches, PR migration, Gitea Actions porting, runner images, and cutover verification. --- # Gitea Transfer ## When To Use Use this skill when moving a repository from GitHub to self-hosted Gitea, converting a Gitea mirror into the canonical source repo, migrating active branches and pull requests, porting GitHub Actions to Gitea Actions, or designing runner job images for CI. ## Operating Principles - Treat Gitea as canonical only after confirming the repo is no longer a mirror and the default branch is pushed. - Keep the GitHub remote as a recovery/reference remote unless the user explicitly asks to remove it. - Prefer `tea` or the Gitea API for Gitea repository, pull request, Actions, and runner operations. - Do not use `gh` for Gitea work. - Do not paste, log, commit, or store registration tokens, API tokens, deploy keys, or package credentials. - Avoid Gitea Actions secret names beginning with `GITEA_` or `GITHUB_`; those prefixes are reserved or confusing in Gitea/GitHub-compatible runners. - Do not modify ignored local config files such as `bunfig.toml` unless the user explicitly requests it. - Do not merge pull requests unless the user explicitly approves the merge. - Do not force-push or rewrite shared branches unless the user explicitly approves it. - Verify every cutover step with API or git status output before moving on. ## Preflight Checks Confirm local state and remotes: ```bash git status --short --branch git remote -v git branch --show-current ``` Confirm the Gitea CLI is authenticated and targeting the expected instance: ```bash tea login list tea repo ls ``` Confirm the Gitea repository exists and inspect settings: ```bash tea api repos/{owner}/{repo} ``` If Gitea is behind Nginx or another reverse proxy, verify dot directories are reachable. A common Forge/Nginx rule blocks paths such as `.github`, `.gitea`, `.codex`, and `.well-known`: ```nginx location ~ /\.(?!well-known).* { deny all; } ``` For Gitea, that rule can break UI/API access to workflow files and agent config paths. Update the reverse proxy deliberately rather than working around it in git. ## Convert Mirror To Source If the Gitea repository was imported as a mirror, convert it to a normal source repository before cutover. Use the Gitea UI or API, then verify: ```bash tea api repos/{owner}/{repo} ``` Check that `mirror` is `false` and the expected default branch is set. Enable repository features as needed: ```bash tea api --method PATCH repos/{owner}/{repo} \ --field has_pull_requests=true \ --field has_actions=true ``` Use the actual supported fields for the installed Gitea version. Re-read the repository after patching. ## Remote Cutover Keep GitHub as a named fallback remote and make Gitea `origin`: ```bash git remote rename origin github git remote add origin ssh://git@git.example.com:30009/{owner}/{repo}.git git remote -v ``` If `origin` already points elsewhere, adjust non-destructively: ```bash git remote set-url origin ssh://git@git.example.com:30009/{owner}/{repo}.git git remote add github git@github.com:{owner-or-org}/{repo}.git ``` Push the default branch first: ```bash git fetch github --prune git push origin {default-branch}:{default-branch} ``` Verify Gitea sees the branch: ```bash tea api repos/{owner}/{repo}/branches/{default-branch} ``` ## Branch Migration List active GitHub branches that need to move: ```bash git branch -r ``` Push only active work branches, not every stale remote branch by default: ```bash git push origin github/{branch-name}:refs/heads/{branch-name} ``` For multiple open PR heads, script carefully from reviewed data. Avoid pushing deleted/stale branches blindly. Verify pushed branches: ```bash tea api repos/{owner}/{repo}/branches ``` ## Pull Request Migration Gitea does not automatically migrate all GitHub PR review history during a basic repo transfer. Recreate open PRs with enough context to continue work: - title - body - base branch - head branch - draft/WIP state in title or body if Gitea draft support is not available - link back to the original GitHub PR Use the Gitea API or `tea`: ```bash tea pr create \ --repo {owner}/{repo} \ --base {base-branch} \ --head {head-branch} \ --title "{title}" \ --description "{body}" ``` After creating each PR, verify it is open, conflict status is correct, and the branch is correct: ```bash tea pr {index} --repo {owner}/{repo} ``` If comments/reviews matter, add a short migration note to the PR body linking to the original GitHub PR rather than attempting to recreate every review event. ## CI Workflow Porting Gitea Actions workflows live in `.gitea/workflows/`. GitHub workflows can remain temporarily in `.github/workflows/` during transition, but do not assume they run on Gitea. Port workflows incrementally: - Start with tests. - Then add build/deploy workflows. - Keep workflow syntax close to GitHub Actions where Gitea supports it. - Validate YAML before pushing. - Prefer small commits so each Actions failure identifies one change. Example validation: ```bash ruby -e "require 'yaml'; YAML.load_file('.gitea/workflows/tests.yml'); puts 'ok'" git diff --check ``` Common changes when porting: - Replace GitHub-only secrets with Gitea Actions secrets. - Use neutral secret names such as `REGISTRY_USERNAME`, `REGISTRY_TOKEN`, `REVIEW_BOT_TOKEN`, and `OPENCODE_GO_TOKEN`. - Remove GitHub Packages auth if the private package dependency is removed or mirrored elsewhere. - Replace GitHub-hosted assumptions with Gitea runner/job container assumptions. - Ensure `actions/checkout` version works with the installed Gitea Actions runner. - Use service hostnames for service containers instead of `127.0.0.1`. - Add explicit service readiness checks for databases. - Do not rely on `permissions:` for security-sensitive sandboxing in Gitea Actions; some Gitea versions ignore it. For Postgres services, use the service name as the host: ```yaml services: postgres: image: postgres:16 env: POSTGRES_DB: app_test POSTGRES_USER: postgres POSTGRES_PASSWORD: password env: DB_HOST: postgres ``` Add a readiness loop before running tests: ```bash until PGPASSWORD=password pg_isready -h postgres -U postgres; do sleep 1 done ``` ## Package Registry Cutover Identify GitHub-specific package auth before enabling Gitea CI: - npm/Bun scopes using `npm.pkg.github.com` - Composer auth for GitHub-hosted private packages - workflow steps writing `.npmrc` or package auth - lockfiles referencing private GitHub package tarballs Options: - Remove the dependency if it can be replaced locally. - Mirror the package to Gitea Packages. - Keep GitHub Packages temporarily and configure Gitea secrets deliberately. Do not leave workflows requiring secrets that no longer exist. If deploy secrets are absent during cutover, prefer a clear skip for deploy-only workflows so the default branch is not red while secrets are being configured. ## Runner Image Strategy Prefer job containers over custom runner labels for language/runtime selection. Use shared CI job images when several repositories need the same runtime stack. The current shared image family is: - `git.bayliss.cloud/harry/gitea-ci-runner:php8.2` - `git.bayliss.cloud/harry/gitea-ci-runner:php8.3` - `git.bayliss.cloud/harry/gitea-ci-runner:php8.4` - `git.bayliss.cloud/harry/gitea-ci-runner:php8.5` - `git.bayliss.cloud/harry/gitea-ci-runner:latest` `latest` points to PHP `8.5`. Repositories should usually hardcode the PHP tag they require instead of using `latest`, so runtime upgrades are explicit per repository. Good pattern: ```yaml jobs: tests: runs-on: ubuntu-latest container: image: git.bayliss.cloud/harry/gitea-ci-runner:php8.2 ``` Avoid creating labels such as `stratbase-php82` just to pick an image. Labels select eligible runners; job containers select the runtime image. This keeps the runner reusable across repositories. Use an instance-level or organization-level runner with generic labels such as: - `ubuntu-latest` - `ubuntu-24.04` - `ubuntu-22.04` If runner labels change, the runner usually needs re-registration because `.runner` pins registration details. App UI fields called "Labels Configuration" may refer to Docker metadata, not Gitea runner labels. Verify actual labels through the API: ```bash tea api admin/actions/runners ``` ## Building A CI Job Image Create or update a shared image when workflows need system dependencies that are expensive or brittle to install per run. Prefer a central image repository over per-application image repositories unless the runtime is truly application-specific. Current shared image repository: ```text ssh://git@git.bayliss.cloud:30009/harry/gitea-ci-runner.git https://git.bayliss.cloud/harry/gitea-ci-runner ``` Current shared PHP/Laravel CI image contents: - base Gitea runner job image - PHP CLI for `8.2`, `8.3`, `8.4`, and `8.5`, selected with `ARG PHP_VERSION` - Composer 2 - Bun - Node.js, `npm`, and `npx` - Go - Python 3, `pip`, and `venv` - `jq` - database and cache client tools such as `postgresql-client` and `redis-tools` - common PHP extensions including `rdkafka`, `redis`, `pcov`, `xdebug`, `imagick`, `imap`, `pgsql`, `mysql`, `sqlite3`, `gmp`, and `gd` Do not add MongoDB to the shared image unless a repository has a real runtime or CI need for it. Shared Dockerfile pattern: ```dockerfile FROM docker.gitea.com/runner-images:ubuntu-latest ARG PHP_VERSION=8.5 ENV DEBIAN_FRONTEND=noninteractive ENV BUN_INSTALL=/root/.bun ENV PATH="/root/.bun/bin:${PATH}" RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ git \ golang-go \ gnupg \ jq \ lsb-release \ postgresql-client \ python3 \ python3-pip \ python3-venv \ redis-tools \ software-properties-common \ unzip \ && install -d -m 0755 /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ && add-apt-repository ppa:ondrej/php -y \ && apt-get update \ && apt-get install -y --no-install-recommends \ nodejs \ php${PHP_VERSION} \ php${PHP_VERSION}-bcmath \ php${PHP_VERSION}-curl \ php${PHP_VERSION}-gd \ php${PHP_VERSION}-imagick \ php${PHP_VERSION}-imap \ php${PHP_VERSION}-intl \ php${PHP_VERSION}-mbstring \ php${PHP_VERSION}-mysql \ php${PHP_VERSION}-pcov \ php${PHP_VERSION}-pgsql \ php${PHP_VERSION}-redis \ php${PHP_VERSION}-rdkafka \ php${PHP_VERSION}-sqlite3 \ php${PHP_VERSION}-xdebug \ php${PHP_VERSION}-xml \ php${PHP_VERSION}-zip \ && update-alternatives --set php /usr/bin/php${PHP_VERSION} \ && curl -fsSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ && curl -fsSL https://bun.sh/install | bash \ && ln -sf /root/.bun/bin/bun /usr/local/bin/bun \ && ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* RUN php -v \ && php -m \ && composer --version \ && go version \ && bun --version \ && node --version \ && python3 --version \ && pg_isready --version ``` Build and push manually only when necessary: ```bash docker build --build-arg PHP_VERSION=8.2 -t git.bayliss.cloud/harry/gitea-ci-runner:php8.2 -f docker/Dockerfile . docker push git.bayliss.cloud/harry/gitea-ci-runner:php8.2 ``` Prefer the image repository workflow for normal updates. Use a matrix over the supported PHP versions and push `latest` only from PHP `8.5`: ```yaml env: REGISTRY: git.bayliss.cloud IMAGE_NAME: harry/gitea-ci-runner jobs: build: strategy: matrix: php-version: - '8.2' - '8.3' - '8.4' - '8.5' steps: - uses: actions/checkout@v4 - name: Check registry credentials env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | if [ -z "$REGISTRY_USERNAME" ] || [ -z "$REGISTRY_TOKEN" ]; then echo "Skipping image publish because REGISTRY_USERNAME or REGISTRY_TOKEN is missing." echo "SKIP_IMAGE_PUBLISH=true" >> "$GITHUB_ENV" fi - name: Login to Gitea registry if: env.SKIP_IMAGE_PUBLISH != 'true' env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - name: Build and push version tag if: env.SKIP_IMAGE_PUBLISH != 'true' run: | docker build \ --build-arg PHP_VERSION="${{ matrix.php-version }}" \ --tag "$REGISTRY/$IMAGE_NAME:php${{ matrix.php-version }}" \ --file docker/Dockerfile \ . docker push "$REGISTRY/$IMAGE_NAME:php${{ matrix.php-version }}" - name: Push latest tag if: env.SKIP_IMAGE_PUBLISH != 'true' && matrix.php-version == '8.5' run: | docker tag "$REGISTRY/$IMAGE_NAME:php${{ matrix.php-version }}" "$REGISTRY/$IMAGE_NAME:latest" docker push "$REGISTRY/$IMAGE_NAME:latest" ``` If the runner host exposes the Gitea registry locally, prefer the runner-local endpoint in workflow job containers for faster pulls: ```yaml container: image: localhost:30008/harry/gitea-ci-runner:php8.2 ``` Do not include `http://` in Docker image references. Configure insecure/local registry trust at the Docker daemon level if needed. ## OpenCode PR Review Workflow Use this pattern when adding a repo-scoped OpenCode reviewer to Gitea Actions. Core behavior: - Trigger only from PR comments containing `/review`. - Do not auto-review on PR open or synchronize events for the first version. - Run OpenCode read-only. - Post or update one aggregate PR comment instead of line comments. - Use the PR head checkout for repository context, but keep checkout shallow. - Do not expose Gitea API tokens to OpenCode. Required secrets: - `REVIEW_BOT_TOKEN`: Gitea token for reading the repository/PR and writing issue comments. - `OPENCODE_GO_TOKEN`: OpenCode Go API token. Recommended token permissions for `REVIEW_BOT_TOKEN`: - `read:repository` - `read:issue` - `write:issue` Recommended workflow shape: ```yaml on: issue_comment: types: - created workflow_dispatch: jobs: review: runs-on: ubuntu-latest container: image: git.bayliss.cloud/harry/gitea-ci-runner:php8.2 ``` In a preparation step: - Read `$GITHUB_EVENT_PATH` with `jq`. - Skip unless the event action is `created`. - Skip unless the issue is a pull request. - Skip unless the comment contains `/review`. - Fetch pull request metadata from `GET /repos/{owner}/{repo}/pulls/{number}`. - Fetch the diff from `GET /repos/{owner}/{repo}/pulls/{number}.diff`. - Export `PR_NUMBER`, `REPO`, `BASE_BRANCH`, `HEAD_BRANCH`, and `HEAD_SHA` to `$GITHUB_ENV`. Checkout the PR head after preparation: ```yaml - name: Checkout PR head if: env.SKIP_OPENCODE_REVIEW != 'true' uses: actions/checkout@v4 with: ref: ${{ env.HEAD_SHA }} fetch-depth: 1 persist-credentials: false ``` Avoid `fetch-depth: 0` unless full history is required; full-history checkout can make review runs slow on Gitea runners. Generate OpenCode auth at runtime from `OPENCODE_GO_TOKEN`: ```bash mkdir -p "$HOME/.local/share/opencode" jq -n --arg token "$OPENCODE_GO_TOKEN" '{"opencode-go": {"type": "api", "key": $token}}' > "$HOME/.local/share/opencode/auth.json" ``` Disable mutation tools and unset repository tokens before invoking OpenCode: ```bash export OPENCODE_CONFIG_CONTENT='{"tools":{"write":false,"edit":false,"bash":false},"permission":{"edit":"deny","bash":"deny"},"share":"disabled","autoupdate":false}' unset OPENCODE_GO_TOKEN REVIEW_BOT_TOKEN GITEA_TOKEN GITHUB_TOKEN opencode run \ --model "${REVIEW_MODEL:-opencode-go/glm-5.1}" \ --file /tmp/opencode-pr.diff \ --title "PR #$PR_NUMBER review" \ "Review this pull request diff for bugs, security issues, behavior regressions, and missing tests. Use the checked-out PR head tree for repository context. Focus on actionable findings. If there are no findings, say so clearly. Do not suggest style-only changes. Output Markdown with concise sections." \ > /tmp/opencode-review.md ``` Use a stable marker in the review comment body so later `/review` runs update the same comment: ```text ``` Gitea Actions logs can be awkward to fetch on versions before `1.26`; if `tea actions runs logs` is unavailable or incomplete, inspect run/task state through the Gitea API. ## Deploy Workflow Porting Deploy workflows often need secrets and remote side effects. Port them after tests pass. Guard required secrets explicitly: ```bash missing=() for required_var in SPARK_EMAIL SPARK_KEY TIPTAP_PRO_KEY DO_SPACES_KEY DO_SPACES_SECRET DO_SPACES_ENDPOINT DO_SPACES_REGION; do if [ -z "${!required_var}" ]; then missing+=("$required_var") fi done if [ "${#missing[@]}" -gt 0 ]; then echo "Skipping deploy because required secrets are missing: ${missing[*]}" echo "SKIP_DEPLOY=true" >> "$GITHUB_ENV" fi ``` Gate deploy steps: ```yaml if: env.SKIP_DEPLOY != 'true' ``` This keeps the default branch green while still making the missing configuration obvious in logs. File follow-up work for the missing secrets. Do not manually dispatch deploy workflows that upload assets or mutate production unless the user explicitly approves it. ## Verification Checklist Before declaring cutover complete: - Gitea repo is not a mirror. - `origin` points to Gitea. - `github` remote exists for recovery/reference if desired. - Default branch is pushed to Gitea. - Active PR branches are pushed to Gitea. - Open PRs are recreated in Gitea. - Repository PRs and Actions are enabled. - Test workflow passes on PR. - Test workflow passes on default branch after merge. - Deploy workflow either succeeds or skips cleanly with a clear missing-secrets message. - Optional `/review` workflow posts or updates the aggregate OpenCode review comment. - Runner is online with generic labels. - Job containers pull the expected image. - Local branch is synchronized with Gitea: ```bash git pull --rebase origin {default-branch} git push origin {default-branch} git rev-list --left-right --count origin/{default-branch}...{default-branch} git status --short --branch ``` Expected commit comparison after sync: ```text 0 0 ``` ## Common Failure Modes ### Dot Paths 403 In Gitea UI Cause: reverse proxy blocks dot directories. Fix: adjust the proxy rule for Gitea. Do not rename workflow directories. ### Postgres Connection Refused Cause: CI app connects to `127.0.0.1` while Postgres runs as a service container. Fix: use service hostname such as `postgres` and add readiness checks. ### Runner Job Never Starts Cause: workflow `runs-on` label does not match registered runner labels. Fix: use generic labels and verify with `tea api admin/actions/runners`. ### Image Pull Fails Cause: wrong registry hostname, missing Docker daemon trust, or private package auth issue. Fix: verify image reference by pulling on the runner host, then use the same host/port in the workflow. ### Deploy Fails For Missing Secrets Cause: Gitea Actions secrets not configured yet. Fix: add a preflight skip or configure secrets. File follow-up work instead of leaving the default branch red. ### OpenCode Review Has No Context Cause: the workflow passed only a diff to OpenCode and did not checkout the PR head tree. Fix: fetch the PR diff through the Gitea API, then shallow-checkout the PR head SHA with `fetch-depth: 1` and `persist-credentials: false`. ### OpenCode Review Checkout Is Slow Cause: `actions/checkout` fetched full history with `fetch-depth: 0`. Fix: checkout the PR head SHA with `fetch-depth: 1` unless full git history is required. ### Branch Tracks Old GitHub Remote Cause: local branch upstream still points to `github/{branch}` after remote cutover. Fix only if requested or useful: ```bash git branch --set-upstream-to=origin/{branch} {branch} ``` ## Follow-Up Issues File explicit follow-up issues for work intentionally deferred during cutover, such as: - configuring deploy secrets in Gitea - moving remaining Composer VCS repositories from GitHub to Gitea - removing obsolete GitHub workflows after Gitea Actions are trusted - deleting or archiving the GitHub repository after a retention period - rotating any tokens that were exposed during setup