#39301·gitea

Gitea Actions token prevents cloning **public** composite actions across owners (404 Not Found)

Author: philipp-horstenkampCreated Sep 12, 2026Updated Sep 12, 2026
Labelstype/bug

Gitea Version

1.27.3

What happened?

Summary

When a Gitea Actions workflow attempts to use a public composite action hosted on the same Gitea instance under a different user or organization (e.g. uses: https://git.example.com/shared-org/my-action.git@v1), the runner (act_runner) fails during the action download phase with:

Unable to clone https://git.example.com/shared-org/my-action.git refs/heads/v1: repository not found: Repository not found
Error: repository not found: Repository not found

Note: This behavior was tested on a single Gitea instance (across different users/organizations). I have not tested accessing actions from one Gitea instance to another.

The Authentication Paradox

  • The repository shared-org/my-action is 100% public.
  • An anonymous user or container running git clone https://git.example.com/shared-org/my-action.git succeeds without credentials (HTTP 200).
  • However, when executed inside a workflow via uses:, act_runner automatically attaches the calling job's GITEA_TOKEN to the Git clone request.
  • Because the job is running in a different repository (e.g. calling-org/my-app) and DEFAULT_ACTIONS_TOKEN_PERMISSION = restricted is configured, Gitea restricts the token strictly to the calling repository.
  • When Gitea's Smart HTTP handler evaluates the request with the attached token, it detects that the token does not have scope for shared-org/my-action, and responds with 404 Not Found (to avoid leaking private repo existence).
  • act_runner (go-git) maps that 404 directly to repository not found: Repository not found.

Result: Presenting a valid Gitea Actions token gives less access to a public repository than presenting no credentials at all.


This Affects Two Key Scenarios

  1. Scenario A — Public Instance with DEFAULT_ACTIONS_TOKEN_PERMISSION = restricted:
    • REQUIRE_SIGNIN_VIEW = false
    • The action repo is public, but cross-owner actions cannot be consumed via standard uses: because the restricted token is rejected with 404.
  2. Scenario B — Private Instance with REQUIRE_SIGNIN_VIEW = true (re: #28187):
    • Even when an internal action repository is meant to be shared across the entire instance, workflows in different organizations cannot clone it because the automatic job token is scoped only to its own repository/organization.

Steps to Reproduce

  1. Configure Gitea with DEFAULT_ACTIONS_TOKEN_PERMISSION = restricted in app.ini / environment:
    [actions]
    DEFAULT_ACTIONS_TOKEN_PERMISSION = restricted
    
  2. Create a public repository containing a composite action under User A (e.g. user-a/hello-action).
  3. In a repository under Org B (e.g. org-b/my-app), create a workflow referencing User A's action:
    name: Test Action
    on: [push]
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: https://git.example.com/user-a/hello-action.git@main
    
  4. Trigger the workflow.
  5. act_runner will fail to download the action, reporting repository not found: Repository not found.

Technical Analysis & Root Cause

  1. In act_runner (pkg/runner/step_action_remote.go): When act_runner prepares to download a remote action, it unconditionally injects the job token:

    gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
        URL:   sar.remoteAction.CloneURL(),
        Token: github.Token, // Automatically injects GITEA_TOKEN
    })
    

    There is no workflow syntax option in uses: to specify an anonymous clone.

  2. In Gitea HTTP Git Backend (routers/web/repo/githttp.go & services/context/permission.go): When GET /<owner>/<repo>.git/info/refs?service=git-upload-pack arrives:

    • If unauthenticated, canAnonymousPull evaluates to true for public repositories, and access is granted (200 OK).
    • If authenticated (ctx.IsSigned == true), Gitea invokes context.CheckRepoScopedToken(ctx, repo, ...) to enforce token scopes.
    • Under restricted mode, the Actions token only has permissions for the calling repo, failing CheckRepoScopedToken.
    • Gitea returns 404 Not Found, breaking go-git's clone.

Working Workaround

Instead of letting act_runner attempt to clone the remote action with its injected GITEA_TOKEN, fetch the action repository into a local subfolder in a preceding step, then reference it as a local composite action (uses: ./<path>):

    - name: Fetch Shared Action
      run: |
        rm -rf .gitea/actions/shared-action
        # For public instances (Scenario A): anonymous git clone works without credentials
        git clone --depth 1 --branch v1 https://git.example.com/user-a/shared-action.git .gitea/actions/shared-action

        # For private instances (Scenario B): authenticate via deploy key or secret PAT
        # git clone --depth 1 --branch v1 https://token:${{ secrets.SHARED_ACTION_PAT }}@git.example.com/user-a/shared-action.git .gitea/actions/shared-action

    - name: Run Action
      uses: ./.gitea/actions/shared-action
      with:
        # action inputs...

(Add .gitea/actions/ to .gitignore to keep cloned actions untracked).

Why this works:

  • git clone inside a run: step bypasses act_runner's automatic token injection and clones anonymously.
  • act_runner reads local actions (uses: ./.gitea/actions/...) dynamically at step execution time without calling the Gitea API.

Proposed Fix

  1. Gitea Side: In githttp.go (and CheckRepoScopedToken), if a repository is public and the requested action is a read-only pull (git-upload-pack), Gitea should not deny access simply because the supplied GITEA_TOKEN is scoped to a different repository. An authenticated user/token should never have fewer permissions than an anonymous guest.
  2. act_runner Side: Support either attempting an unauthenticated clone fallback if an authenticated clone returns 404/401 on an action URL, or checking if the host is a public Git remote before injecting github.Token.

How are you running Gitea?

Dockerrized behind a traefik reverse proxy. Full setup with Postgres and Redis.