Gitea Actions 全攻略:把 GitHub Actions 私有化

Gitea Actions 执行过程

一、为什么是 Gitea Actions

GitHub Actions 改变了大家写 CI 的方式,但它有两个绕不开的痛点:代码要放到 GitHub 上免费额度有限。对于公司内部代码、或者不想把构建过程暴露到公网的场景,需要一个能完全离线、完全自控的替代品。

Gitea Actions 就是答案。它直接复用 GitHub Actions 的生态——同样的 YAML 语法、同样的 uses: 复用机制、甚至能直接拉 GitHub 上的官方 Action(actions/checkoutdocker/build-push-action 等都能用),但跑在你自己的机器上,没有任何外部依赖。

这篇文章我会从零搭一套完整链路:

  1. docker-compose 起一个 Gitea 实例(带 Postgres)
  2. 注册一个 gitea-runner(Gitea 的 CI 执行器)
  3. 写一个最简单的 Go 服务,用 Gitea Actions 把它构建成 amd64 + arm64 多架构镜像,推送到一个私有 Zot registry

所有代码、配置、.runner 文件,全部公开


二、起 Gitea:docker-compose 一把梭

Gitea 是个单体二进制,但生产里一般配 Postgres。这里贴的 docker-compose.yaml 是我本机实际在跑的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
networks:
gitea:
external: false

services:
server:
image: gitea/gitea:1.27
container_name: gitea
environment:
- HTTP_PROXY=http://172.29.96.1:7897
- HTTPS_PROXY=http://172.29.96.1:7897
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=gitea
restart: always
networks:
- gitea
volumes:
- /opt/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "10022:22"
depends_on:
- db

db:
container_name: postgres
image: postgres:16
restart: always
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea
- POSTGRES_DB=gitea
networks:
- gitea
volumes:
- /opt/postgres:/var/libgresql/data

几个要点:

  • GITEA__database__* 这些环境变量:Gitea 支持用 GITEA__<section>__<key> 的形式直接通过环境变量覆盖 app.ini 配置。这里把数据库指向了同一个 compose 网络里的 db:5432,免得装完进 Web UI 手动配。
  • HTTP_PROXY / HTTPS_PROXY:如果你在内网、需要拉取或者迁移 GitHub Action 的话,需要给 Gitea 容器配代理。我这里是走宿主机的 172.29.96.1:7897。如果你的 Gitea 完全离线、所有 Action 都走私有镜像源,这两行可以删掉。
  • 端口 3000:3000 是 Web,10022:22 是 SSH。SSH 端口特意避开 22,是为了不和宿主机的 sshd 打架。
  • 数据持久化到 /opt/gitea/opt/postgres。容器删了重建,数据还在。

docker compose up -d 起来之后,访问 http://<你的IP>:3000,第一次会让你创建管理员账号。这里假设创建好后访问地址是 http://172.29.102.221:3000(下文统一用这个 IP)。

前置依赖:Gitea Actions 需要在 app.ini 里启用。 Gitea 1.27+ 默认开启,如果你用老版本,需要在 /opt/gitea/gitea/conf/app.ini 加:

1
2
[actions]
ENABLED = true

然后重启容器:docker restart gitea


三、装 gitea-runner:CI 的执行器

Gitea 本身只负责调度(什么时候触发、执行哪个 workflow),真正干活的是 gitea-runner(也叫 Gitea Runner)。架构上:

1
push 事件 → Gitea 调度 → gitea-runner 拉到任务 → 在 runner 容器/机器里执行 job

gitea-runner 是个独立的二进制,跟 Gitea server 是解耦的——你可以装在宿主机上、装在 k8s 里、或者装在另一台机器上。本篇就用最简单的:装在跑 Gitea 的同一台宿主机上。

3.1 下载二进制

1
2
3
4
# 拉最新 release
wget https://gitea.com/gitea/runner/releases/download/v2.1.0/gitea-runner-2.1.0-linux-amd64
chmod +x gitea-runner-linux-amd64
mv gitea-runner-linux-amd64 /usr/local/bin/gitea-runner

3.2 注册 runner

注册前,先在 Gitea Web UI 拿 token:站点管理 → Actions → Runners → 创建 Runner,会给你一串 token。

1
2
3
4
5
6
gitea-runner register \
--instance http://172.29.102.221:3000 \
--token <上一步拿到的 token> \
--name gitea-runner \
--no-interactive \
--labels ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04

这里有个关键概念,值得展开讲:--labels 决定了 workflow 里的 runs-on: 到底匹配到什么。

runs-on 与 labels 的对应关系

在 GitHub Actions 里,你写 runs-on: ubuntu-latest,GitHub 把它调度到它云上的 Ubuntu runner。但在 Gitea,没有云上的 runner,全靠你注册时声明的 labels 来匹配。看一下注册成功后生成的 .runner 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"WARNING": "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner.",
"id": 3,
"uuid": "d805536c-d78f-4ec3-ac34-95fa469e9673",
"name": "gitea-runner",
"token": "bb07c7bbfabd3fb3ed13b8040e9176c77e0333a5",
"address": "http://172.29.102.221:3000",
"labels": [
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
],
"ephemeral": false
}

label 的格式是 名字:实现,冒号后面是这个 label 实际跑在什么里

  • ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest 的意思是——当 workflow 写 runs-on: ubuntu-latest 时,runner 会 docker pull docker.gitea.com/runner-images:ubuntu-latest,然后在容器里执行 job
  • 这是 Gitea runner 最常见的模式:每个 job 跑在一个 Docker 容器里,跟你 GitHub Actions 上的体验一致。

⚠️ 大坑:用了 docker:// 这种 label,宿主机必须有 Docker。因为 runner 要调用 Docker daemon 起容器。所以 runner 二进制要么装在宿主机上(能看到 docker 命令),要么用 DinD(Docker-in-Docker)。

注册时这个文件会生成在gitea-runner 的当前工作目录下。我建议给 runner 单独建个目录,比如 /opt/runner/,把 .runner 放里面,别跟别的混。

3.3 生成 config 并后台运行

1
2
3
4
5
6
# 生成默认配置
gitea-runner generate-config > /opt/runner/config.yaml

# 后台跑(生产里建议写成 systemd unit)
cd /opt/runner
nohup gitea-runner daemon --config config.yaml > runner.log 2>&1 &

跑起来后回到 Gitea Web UI 的 Runners 页面,应该能看到 gitea-runner 状态变成 idle(绿色),说明它已经在线等活了。

Runner 在线状态


四、构建项目:一个最小 Go 服务

我们来实战。准备一个最小化的 Go + Fiber 服务,目录结构:

1
2
3
4
5
6
7
8
actions/
├── .gitea/
│ └── workflows/
│ └── actions.yaml ← workflow 定义
├── Dockerfile
├── go.mod
├── go.sum
└── main.go

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"log"

"github.com/gofiber/fiber/v3"
)

func main() {
app := fiber.New()

app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Hello, World!")
})

log.Fatal(app.Listen(":8080"))
}

go.mod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module boer.xyz/actions

go 1.25.5

require github.com/gofiber/fiber/v3 v3.4.0

require (
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/gofiber/schema v1.8.0 // indirect
github.com/gofiber/utils/v2 v2.1.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/mattn/go-colorable v0.0.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
)

Dockerfile(多阶段构建,最终镜像用 distroless):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
FROM 172.29.102.221:5000/golang:1.26 AS builder

WORKDIR /build

ENV CGO_ENABLED=0 \
GOOS=linux \
GOPROXY=https://goproxy.cn,direct

# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -ldflags="-s -w" -o /build/actions .

FROM 172.29.102.221:5000/distroless/static-debian12:nonroot AS runner

# MAINTAINER (deprecated)
LABEL xyz.boer.image.authors="boer0924@gmail.com"

WORKDIR /app

COPY --from=builder /build/actions /app/actions

USER nonroot:nonroot
EXPOSE 8080

ENTRYPOINT ["/app/actions"]

注意两个细节:

  1. 基础镜像都从 172.29.102.221:5000/——这是一个我本地起的私有 registry(就是后文的 Zot),里面缓存了 golang:1.26distroless/static-debian12:nonroot。内网拉镜像不走公网,快且不依赖外网。
  2. CGO_ENABLED=0 + distroless static:得到一个完全静态链接、没有任何 shell 的极小镜像(几 MB 级别),同时方便做多架构(amd64/arm64 都能编出来,不用担心 glibc 版本)。

五、核心:workflow 文件,逐行讲

这是本篇最关键的部分。.gitea/workflows/actions.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
name: release

on:
push:
tags:
- "v*"

jobs:
docker:
runs-on: ubuntu-latest
steps:
# - name: Checkout
# uses: http://172.29.102.221:3000/actions/checkout@v7

- name: Login to Docker Hub
uses: http://172.29.102.221:3000/docker/login-action@v4
with:
registry: 172.29.102.221:5000
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}

- name: Set up QEMU
uses: http://172.29.102.221:3000/docker/setup-qemu-action@v4

- name: Set up Docker Buildx
uses: http://172.29.102.221:3000/docker/setup-buildx-action@v4
with:
buildkitd-config-inline: |
[registry."172.29.102.221:5000"]
http = true
insecure = true

- name: Build and push
uses: http://172.29.102.221:3000/docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm64
push: true
tags: |
172.29.102.221:5000/actions/demo:${{ github.ref_name }}
provenance: false
sbom: false

里面的几个概念,一个个解读。

5.1 on: push: tags: ["v*"] —— 触发条件

这个 workflow 只在打 tag(tag 名以 v 开头,比如 v1.0.0)时触发。对应的命令是:

1
2
git tag v1.0.0
git push origin v1.0.0

push 一个 v 开头的 tag,Gitea 收到事件,匹配到这个 workflow,丢给 runner 执行。普通的 commit push 不会触发,避免每次提交都构建镜像。

5.2 runs-on: ubuntu-latest —— 跑在哪个容器里

回顾第三节讲的 labels:runner 注册时声明了 ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,所以这里写 runs-on: ubuntu-latest,runner 就会去拉 docker.gitea.com/runner-images:ubuntu-latest 这个镜像,把整个 job 关进这个容器里跑。

这正是 Gitea Actions 和 GitHub Actions 体验一致的关键——你写 runs-on: ubuntu-latest,得到的也是一个干净的 Ubuntu 环境,里面预装了 docker、git、curl 这些常用工具。

5.3 uses: http://172.29.102.221:3000/... —— 私有 Action 源前缀(重点)

这是 Gitea Actions 最不一样、也是最容易踩坑的地方。

在 GitHub Actions,你写:

1
uses: actions/checkout@v4

Gitea 默认从是 github.com/actions/checkout 拉的。但是我们已经提前把这些actions全部mirror了一份,所以写全 URL,而且这个 URL 指向的是 Gitea 上你自己存的 mirror

1
2
3
uses: http://172.29.102.221:3000/actions/checkout@v7
# └──────────┬───────────┘ └──┬───┘ └┬┘
# Gitea 实例地址 仓库路径 版本

拆开看:

片段 含义
http://172.29.102.221:3000 你的 Gitea 实例地址(没有协议前缀 Gitea 不认
/actions/checkout Gitea 上一个名为 actions 的组织(org)下的 checkout 仓库
@v7 这个仓库的 tag/分支

所以你得先把 GitHub 上的常用 Action mirror 一份到自己的 Gitea

除非你是自由身

1
2
3
# 在 Gitea Web UI 上:新建仓库 → 迁移仓库
# 源地址:https://github.com/actions/checkout
# 目标:组织 actions / 仓库名 checkout

Gitea 的「迁移仓库」支持把 GitHub 仓库(含所有 tag)拉过来做镜像。常用的那几个 Action 我都 mirror 了:

  • actions/checkout
  • docker/login-action
  • docker/setup-qemu-action
  • docker/setup-buildx-action
  • docker/build-push-action

💡 为什么 workflow 文件里把 actions/checkout 那步注释掉了?
因为本例里我们只构建镜像、不跑单测,源码由 buildx 在构建 Docker 镜像时通过 context 直接拿到(runner 容器启动时已经把仓库 clone 到 ${{ github.workspace }} 了)。但如果你要在 workflow 里跑 go test,就必须显式 uses: actions/checkout@v7 一下,否则 workspace 是空的。

5.4 三件套:QEMU + Buildx + Build

构建多架构镜像的标准三步:

① QEMUdocker/setup-qemu-action):让 amd64 宿主机能通过模拟跑 arm64 的二进制。没这一步,platforms: linux/arm64 会直接报错。

② Buildxdocker/setup-buildx-action):Docker 官方的多架构构建器,基于 BuildKit。这里有个关键配置:

1
2
3
4
buildkitd-config-inline: |
[registry."172.29.102.221:5000"]
http = true
insecure = true

这两行告诉 BuildKit:拉/推 172.29.102.221:5000 这个 registry 时走 http(不是 https)、并且跳过证书校验。因为我的 Zot registry 是个纯 http、自签/无签的内部服务。没这个配置,buildx 会因为 https 握手失败而推不上去——这是私有 registry 最常见的坑。

③ Build and pushdocker/build-push-action):

1
2
3
4
5
6
platforms: linux/amd64,linux/arm64   # 一次构建两个架构
push: true # 构完直接推
tags: |
172.29.102.221:5000/actions/demo:${{ github.ref_name }}
provenance: false # 关掉 provenance manifest
sbom: false # 关掉 SBOM
  • ${{ github.ref_name }} 是触发 tag 的名字(去掉 refs/tags/ 前缀),比如打 v1.0.0,最终镜像 tag 就是 v1.0.0
  • provenance: falsesbom: false:Buildx 默认会额外推一个 provenance(构建来源溯源)manifest 和 SBOM(软件物料清单)。这俩东西在某些 registry(尤其 Zot 老版本)里会触发 tag 列表异常,关掉更干净。
  • github.ref_name 而不是 gitea.ref_name:Gitea Actions 同时暴露了 gitea.*github.* 两套上下文,值完全一样github.* 是为了兼容 GitHub Actions 现有生态,方便你直接抄 GitHub 的 workflow 过来。

5.5 配置 Secrets

secrets.REGISTRY_USERNAME / secrets.REGISTRY_PASSWORD 不能明文写,要在 Gitea Web UI 配:

仓库 → Settings → Secrets → Actions → Add Secret

加两个:REGISTRY_USERNAMEREGISTRY_PASSWORD。workflow 里用 ${{ secrets.XXX }} 引用,runner 执行时会注入,日志里会打码。


六、跑起来:打 tag,看执行

代码 push 到 Gitea 之后,触发构建:

1
2
git tag v1.0.0
git push origin v1.0.0

回到 Gitea Web UI:仓库 → Actions,能看到 release 这个 workflow 正在跑。点进去能看到每一步的实时日志。

执行流程按 step 顺序展开:

  1. Set up Job:runner 拉起 ubuntu-latest 容器,把仓库源码挂进去。
  2. Login to Docker Hubdocker/login-action 拿 secrets 里的账密,docker login172.29.102.221:5000
  3. Set up QEMU:注册 qemu-aarch64-static 等二进制,让内核能 exec arm64 ELF。
  4. Set up Docker Buildx:起 buildkitd 容器,把上面那段 inline config 灌进去。
  5. Build and push:buildx 串起 golang:1.26(builder 阶段,amd64+arm64 各跑一次)→ distroless(runner 阶段)→ 推到 Zot。

如果某一步红了,常见原因:

现象 原因
uses: http://...actions/checkout@v7 拉不到 这个 Action 没 mirror 到你的 Gitea,或者 mirror 了但没 v7 这个 tag
docker login 报 401 secrets 名字拼错,或账密不对
buildx 推送时报 http: server gave HTTP response to HTTPS client buildkitd-config-inline 没配 http = true
arm64 步骤 exec format error QEMU 没装上,或宿主机的 binfmt 没注册

七、结果:Zot registry 里的多架构镜像

构建成功后,镜像会出现在 172.29.102.221:5000/actions/demo:v1.0.0。我用 Zot(一个轻量、CNCF 兼容的 registry 实现)作为私有 registry,它的 Web UI 里能看到这次 push 出来的 manifest:

Zot registry 里看到的 actions/demo:v1.0.0 多架构镜像

一个 tag 下面挂着两个 platform(linux/amd64linux/arm64),每个 platform 各有一份 layer。在 amd64 机器上 docker pull 自动拿 amd64 那份,在 arm64 机器上自动拿 arm64 那份——对使用者完全透明,这是 OCI image manifest 的标准能力。

验证一下:

1
2
3
4
5
# 在 amd64 机器上
docker pull 172.29.102.221:5000/actions/demo:v1.0.0
docker run --rm -p 8080:8080 172.29.102.221:5000/actions/demo:v1.0.0
curl localhost:8080
# Hello, World!

八、总结:私有化 GitHub Actions 的完整拼图

把整条链路串起来,Gitea Actions 私有化一共四块拼图:

  1. Gitea serverdocker-compose 起)——托管代码 + 调度 CI。
  2. gitea-runner(注册到 server,靠 labels 决定 job 跑在哪)——执行 CI。
  3. Action mirror(把 GitHub 的 actions/*docker/* mirror 到自己 Gitea)——解决 uses: 前缀。
  4. 私有 registry(Docker registry / Zot / Harbor 都行,记得配 insecure + http)——接收构建产物。

记住这几个最容易卡的地方,能省你大半天:

  • runs-on: ubuntu-latest 不是魔法,它对应 runner 注册时的某个 label;label 后面的 docker://... 才决定实际执行容器。
  • uses: 后面的 URL 必须带 http(s):// 前缀 + 你的 Gitea 域名,并且 Action 仓库要预先 mirror 到本地。
  • 推私有 http registry,Buildx 的 buildkitd-config-inline 里一定要写 http = true; insecure = true
  • github.*gitea.* 上下文等价,抄 GitHub workflow 过来基本不用改。

到此,你就拥有了一套完全私有、零外部依赖的 CI/CD——语法和 GitHub Actions 一模一样,但代码、构建、产物全部不出内网。对内部项目、离线环境、或者单纯想自己掌控 CI 的同学,这套方案基本能带你上道。


Gitea Actions 全攻略:把 GitHub Actions 私有化
https://www.boer.xyz/posts/gitea-actions-101/
作者
boer
发布于
2026年7月24日
许可协议