Gitea源码深度剖析-第14章 Gitea Actions(CI/CD)与包注册表(Package Registry)
Gitea 从 1.19 起内置了 GitHub Actions 兼容的 CI/CD 引擎,从 1.17 起内置了多协议的包注册表(Package Registry)。这两块功能有一个共同的架构特征:服务端只负责调度、鉴权与存储,重活交给外部组件或标准协议客户端。Actions 的执行引擎 act_runner 是独立仓库的独立进程,服务端只暴露一组 Connect(connectrpc)接口;包注册表则完全复用各生态的官方客户端(docker login、pip install、npm install……),服务端只是把它们的协议实现了一遍。
本章的源码坐标与第 1 章 1.8 节导航表中第 14 行一致:入口在 routers/init.go 的 NormalRoutes() 挂载的 /api/actions、/api/packages、/v2 三条路由,实现分布在 routers/api/actions/、routers/api/packages/、services/actions/、services/packages/、models/actions/、models/packages/、modules/actions/、modules/packages/。第 8 章 8.11.3 节讲 commit status 作为 PR 合并闸门时留了一句"详见第 14 章"——那个把 job 状态写成 commit status 的人就在本章 14.7 节;第 12 章讲 notify 事件流时提到的 notify_service.Notifier 接口,其 Actions 实现者在本章 14.2 节。
本章所有结论都以本仓库(gitea.dev,28.x)代码为准;任务输入里可能过时或不准确的说法,会在正文中逐一验证并纠正。
14.1 架构总览:两个"外部化"的设计决策
14.1.1 模块分工
把 Actions 相关代码按"谁在什么时机执行"分层,可以看到清晰的四层:
| 层 | 目录 | 职责 | 运行时机 |
|---|---|---|---|
| 协议常量与纯函数 | modules/actions/ | workflow 文件解析(jobparser)、事件匹配(workflows.go)、glob 选择器(workflowpattern/)、日志格式与转存(log.go)、任务状态组装(task_state.go) | 被 routers 和 services 调用,自身不注册路由 |
| 业务编排 | services/actions/ | notifier(事件入口)、run/job 落库、任务挑选、secrets/variables 装配、commit status 写入、审批/重跑/取消 | web 进程内,随事件与队列驱动 |
| HTTP/Connect 端点 | routers/api/actions/ | runner 注册/心跳/拉任务/回报、artifact v3/v4 上传下载 | 被外部 runner 与 runner 内的 actions tool 调用 |
| 数据模型 | models/actions/ | ActionRun/ActionRunJob/ActionTask/ActionSchedule/ActionVariable 等表与查询 | xorm 层 |
包注册表的分层与之同构:modules/packages/ 是各协议的编解码器(conan、npm、pypi……每协议一个子包),routers/api/packages/ 是路由与协议端点,services/packages/ 是创建/去重/配额/下载计数的业务层,models/packages/ 是表结构。
把一次 push 到 job 完成的调用链按层画出来,箭头方向就是 import 方向(严格单向,routers → services → models,modules/actions 被前两者共享):
flowchart TB
subgraph external["外部组件(独立进程)"]
RUNNER["act_runner<br/>Connect 客户端 / act 执行引擎"]
CLIENT["各生态官方客户端<br/>docker / npm / pip / cargo ..."]
end
subgraph routers["routers 层(HTTP 端点)"]
RA["routers/api/actions/<br/>runner.go · artifacts.go · artifactsv4.go"]
RP["routers/api/packages/<br/>api.go · container/ · npm/ · pypi/ ..."]
RW["routers/web/repo/actions/<br/>(run/job/task 页面与日志流)"]
end
subgraph services["services 层(业务编排)"]
SA["services/actions/<br/>notifier · run · task · commit_status<br/>context · auth · artifacts · cron 集成"]
SP["services/packages/<br/>packages.go · container/"]
end
subgraph modules["modules 层(纯逻辑,无反向依赖)"]
MA["modules/actions/<br/>workflows.go · workflowpattern/<br/>jobparser/ · log.go · task_state.go"]
MP["modules/packages/<br/>npm/ pypi/ conan/ ... 编解码器<br/>content_store.go"]
SET["modules/setting/<br/>actions.go · packages.go"]
end
subgraph models["models 层(xorm 表)"]
MODA["models/actions<br/>run · attempt · job · task · schedule · variable"]
MODS["models/secret · models/variable"]
MODP["models/packages<br/>package · version · file · blob"]
end
RUNNER -- "Connect: Register/FetchTask/UpdateTask/UpdateLog" --> RA
CLIENT -- "标准协议 HTTP" --> RP
RA --> SA
RW --> SA
RP --> SP
SA --> MA
SA --> MODA
SA --> MODS
SP --> MP
SP --> MODP
SA -. "notify_service 总线" .-> SA
SET -. 配置读取 .-> SA
SET -. 配置读取 .-> SP
值得强调的是 modules/actions/ 里没有任何对 services/ 或 routers/ 的反向依赖——匹配逻辑(DetectWorkflows)是纯函数式的:输入 webhook 事件 + 工作流文件内容,输出匹配结果。这让触发逻辑可以被单元测试独立覆盖,也让 scoped workflow(14.4.4)这类"跨仓库借用工作流"的场景不必污染服务层。
14.1.2 验证一:act_runner 不在本仓库
任务输入假设"act_runner 可能在本仓库"。先验证:在全仓库搜索 package main 且引用 act 的代码、以及 nektos/act 依赖——
go.mod中与 Actions 相关的依赖只有gitea.dev/actionslib v1.2.1,没有github.com/nektos/act;routers/api/actions/runner/下是 Connect 服务的服务端实现(Register/Declare/FetchTask/UpdateTask/UpdateLog五个方法),不是 runner 进程;- 仓库中不存在
cmd/actions_runner之类的命令入口(cmd/下与 Actions 相关的只有cmd/actions.go,提供gitea actions generate-runner-token子命令)。
结论:runner 是外部独立项目(gitea.com/gitea/act_runner,内部复用 nektos/act 作为执行引擎),本仓库只拥有 wire 协议中服务端这一半。gitea.dev/actionslib 提供的正是协议共享层:Connect 定义(runnerv1 包)、任务上下文结构、以及表达式求值引擎——也就是说 ${{ }} 表达式在 runner 侧求值所需的代码是通过这个库与服务端共享的,而 if: 条件中需要落库参与调度判断的部分(job 是否可运行)则由服务端在 services/actions/run.go 里另行求值。
14.1.3 验证二:本版本没有独立的 Actions Cache API
另一个常见假设是"Gitea Actions 应该有 /api/actions/cache 之类的缓存端点"。检索 routers/api/actions/ 全部路由(actions.go 的 Routes() 只注册 ping + runner 两个 Connect handler;artifacts.go/artifactsv4.go 注册 artifact 两套协议),没有 cache 相关路由。本版本中与 cache 有关的唯一痕迹是 JWT claim 里的 Ac(cache scope)字段——见 14.9.4 的 CreateAuthorizationToken。文中不会杜撰不存在的端点。
14.1.4 入口路由:三条挂载
routers/init.go 的 NormalRoutes() 中与本章相关的挂载(节选自 L175-207):
m.Get("/api/packages/*", packages_router.CommonRoutes()) // 各生态包协议
m.Get("/v2/*", containers_router.ContainerRoutes()) // OCI/Docker,必须在根路径
m.Get("/api/actions/*", actions_router.Routes) // runner Connect + ping
m.Get("/api/actions_pipeline/*", actions_router.ArtifactsRoutes) // artifact v3 REST
m.Get("/twirp/*", actions_router.ArtifactsV4Routes) // artifact v4 twirp
/v2 必须挂在根路径是 OCI 规范的硬约束:Docker 客户端把 registry 主机名直接拼成 https://<host>/v2/...,没有任何前缀协商机制。源码在挂载处留了注释说明:如果 Gitea 部署在子路径(sub-path)下,必须在反向代理层把 /v2 重写映射到 Gitea 的子路径前缀,靠 Gitea 自身无法满足。container.go 的 APIUnauthorizedError 里也有对应的呼应——它构造 WWW-Authenticate 的 realm 时直接用 GuessCurrentHostURL + "/v2/token" 而不是 AppUrl,注释写着"container registry requires that the /v2 must be in the root, so the sub-path in AppURL should be removed"。
14.2 事件入口:actionsNotifier 与 notify 流
14.2.1 Notifier 的注册与角色
第 12 章讲过 notify_service.Notifier 是 Gitea 内部事件的总线接口,仓库创建、issue、PR、push、release、package 等动作都会广播。services/actions/init.go 的 Init() 做三件事:
func Init() error {
// 1. 创建 actions_ready_job 唯一队列
// 2. notify_service.RegisterNotifier(NewNotifier())
// 3. initGlobalRunnerToken() —— 从环境变量读取全局 runner 注册令牌
}
第二步把 services/actions/notifier.go 的 actionsNotifier 挂上总线。actionsNotifier 实现了接口的全部方法,每个方法的套路一致:构造 newNotifyInput(...)、.WithPayload(...)、.Notify(ctx)。文件中实际存在的方法与它们映射的 GitHub 事件(括号内为 canGithubEventMatch 中的对应关系):
| notifier 方法 | GitHub 事件名 | 备注 |
|---|---|---|
NewIssue / IssueChangeTitle / IssueChangeContent / IssueChangeStatus / IssueChangeLabels / IssueChangeAssignee / IssueChangeMilestone | issues(带 types 过滤) | 后四个共用 notifyIssueChangeWithTitleOrContent 之类的辅助入口 |
CreateIssueComment / UpdateComment / DeleteComment | issue_comment | 仅默认分支(14.3.3) |
NewPullRequest / PullRequestSynchronized / PullRequestChangeTargetBranch / AutoMergePullRequest | pull_request(opened/synchronize/…) | fork PR 走审批门(14.2.3) |
PullRequestReview | pull_request_review | approved/changes_requested 两种 activity |
PullRequestReviewRequest | pull_request_review_comment | |
PushCommits / SyncPushCommits | push | 同步镜像仓库的 push 走 Sync* 变体 |
CreateRef / DeleteRef / SyncCreateRef / SyncDeleteRef | create / delete | |
NewRelease / UpdateRelease / DeleteRelease | release(published 等) | |
PackageCreate / PackageDelete | registry_package | Actions 与包注册表的交汇点(见下) |
NewWikiPage / EditWikiPage / DeleteWikiPage | gollum | 名字来自 GitHub 的历史包袱 |
ForkRepository | fork | 仅默认分支 |
MigrateRepository | (不直接触发工作流) | 迁移完成后做 schedule 清理类处理 |
WorkflowRunStatusUpdate | workflow_run | run 状态变化时的自反事件,递归上限 5 层 |
值得注意的是不是所有方法都触发工作流。例如 NewIssue/CreateIssueComment 对应 issues/issue_comment 事件——它们会进入匹配流程(issue_comment 还是"仅默认分支"事件,见 14.3.3);IssueChangeAssignee 这类变更通知则只有在工作流声明了对应 types 过滤时才有意义。MigrateRepository 只在迁移完成时做清理类处理。PackageCreate/PackageDelete 则对应 registry_package 事件,让"发布包后触发构建"成为可能——这是 Actions 与包注册表两个子系统在事件层的交汇点:包的上传(14.13)会经由 notify_service 总线回头触发 CI,例如"每次发布 npm 包后自动构建文档站"。
14.2.2 notify 主流程:services/actions/notifier_helper.go
所有会触发工作流的通知最终汇到 notify() 函数,按顺序做七件事,每一件都是一个真实的边界情况:
- 防循环触发:如果触发者是
setting.Actions.ActionsUserID(即被配置为"以某用户身份执行"的那个账号),直接返回。没有这道闸,一个 push 触发的 workflow 用 GITHUB_TOKEN 再 push,会无限递归。 - 仓库级检查:仓库为空(无 commit)、已归档(archived)、未启用 Actions unit(
TypeActions被禁用)都直接返回。第三个检查意味着管理员可以在单个仓库粒度关掉 CI。 - 默认分支回退:
IsDefaultBranchWorkflow(event)为 true 的事件类型(delete、fork、issue_comment、wiki、schedule、issues、workflow_run)只在默认分支上找工作流文件——即便事件发生在别的分支,也把检出分支改回默认分支。原因很直接:这些事件的 payload 里根本没有有意义的"目标分支"。 - 跳过检查:
skipWorkflows()处理两类跳过——commit message 里含setting.Actions.SkipWorkflowStrings(默认[skip ci]、[ci skip]、[no ci]、[skip actions]、[actions skip])时跳过整个通知;workflow_run事件递归层数超过 5 层时跳过(防 A 触发 B 触发 C……的链式爆炸,上限值是硬编码的常量)。 - 工作流发现:调用
DetectWorkflows(14.3)拿到匹配、计划任务、被过滤三组工作流与解析错误表。 - 落库与审批:
buildApproveAndInsertRun把每个匹配的工作流变成ActionRun+ActionRunAttempt+ N 个ActionRunJob;对 fork PR 会先判断是否需要人工审批。 - 副作用处理:
handleFilteredWorkflows给被 paths 过滤的工作流补写 Skipped 状态的 commit status(14.7.3);handleSchedules为 cron 触发的工作流创建/更新ActionSchedule;detectAndHandleScopedWorkflows处理 scoped workflow(14.4.4)。
上述七步在源码里长这样(services/actions/notifier_helper.go 的 notify 函数节选,忠实原文):
func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
if input.Doer.ID == user_model.ActionsUserID {
// avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply,
// and the new comment will trigger the runner again.
log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name)
// we should update schedule tasks in this case, because
// 1. schedule tasks cannot be triggered by other events, so cyclic triggering will not occur
// 2. some schedule tasks may update the repo periodically, so the refs of schedule tasks need to be updated
if shouldDetectSchedules {
return DetectAndHandleSchedules(ctx, input.Repo)
}
return nil
}
if input.Repo.IsEmpty || input.Repo.IsArchived {
return nil
}
if unit_model.TypeActions.UnitGlobalDisabled() {
if err := CleanRepoScheduleTasks(ctx, input.Repo); err != nil {
log.Error("CleanRepoScheduleTasks: %v", err)
}
return nil
}
if err := input.Repo.LoadUnits(ctx); err != nil {
return fmt.Errorf("repo.LoadUnits: %w", err)
} else if !input.Repo.UnitEnabled(ctx, unit_model.TypeActions) {
return nil
}
...
ref := input.Ref
if ref.BranchName() != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) {
if ref != "" {
log.Warn("Event %q should only trigger workflows on the default branch, but its ref is %q. Will fall back to the default branch",
input.Event, ref)
}
ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
}
if ref == "" {
log.Warn("Ref of event %q is empty, will fall back to the default branch", input.Event)
ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
}
...
if skipWorkflows(ctx, input, commit) {
return nil
}
...
}
几个"只有读了原文才知道"的细节:
shouldDetectSchedules的判据是"push 到默认分支",它在函数第一行就算好,两个用处:第 6 步决定要不要走handleSchedules,以及防循环闸里的例外——ActionsUserID自己的 push 也照常刷新 schedule 记录,注释给了两条理由:schedule 事件不会被别的事件触发所以不可能成环;有些 schedule 工作流会周期性更新仓库,schedule 记录里的 ref 必须跟着刷新;- 全局禁用与仓库禁用是两级:
UnitGlobalDisabled()(实例级[units]全局禁用)发生时还会顺手CleanRepoScheduleTasks清掉该仓库的定时任务——否则全局关掉 Actions 后,遗留的ActionSchedule记录会在 cron 里空转; - ref 的两重回退:非默认分支事件若属于"仅默认分支"事件类型,先打
Warn日志再回退(这条 Warn 是排查"我的工作流为什么没按预期分支跑"的第一线索);ref 为空(如delete事件删掉了分支)也回退默认分支; pull_request_target的二次检测(notify后半段,见 14.3):第一轮DetectWorkflows检出的pull_request_target工作流被刻意暂扣(if wf.TriggerEvent.Name != GithubEventPullRequestTarget才进detectedWorkflows),等input.PullRequest != nil时用 base 分支的 commit 再跑一遍DetectWorkflows,把 base 侧检出的pull_request_target工作流补进来。安全动机:pull_request_target的语义是"在目标分支的代码与 secrets 上下文里处理 fork PR",工作流文件必须取 base 侧,否则 fork 往自己分支塞一个恶意工作流就能借 base 的 secrets 执行。
14.2.3 fork PR 审批门:ifNeedApproval
buildApproveAndInsertRun 内部对每个 run 调用 ifNeedApprovalWith 判断是否插入即跑还是挂起等人批准,规则按优先级:
- 事件不是
pull_request/pull_request_target,或 PR 来自同一仓库(非 fork)→ 不需要审批; - PR 作者是 restricted 用户 → 必须审批(这是对"低信任账号用 CI 挖矿/外泄 secrets"的核心防线);
- PR 作者对仓库有写权限 → 不需要审批;
- PR 作者此前已有被 merged 的 PR → 不需要审批(老贡献者豁免);
- 其余情况 → 需要审批。
需要审批的 run 会以 waiting 但不产出 task 的状态落库,等待仓库管理员在 Actions 页面点批准(services/actions/approve.go)。这与第 8 章 PR 审批是两套独立机制:这里批的是"允许不可信代码在我的基础设施上跑",不是"允许合并代码"。
14.2.4 事件 → 提交状态的写入白名单
modules/actions/workflows.go 里有一个 ShouldEventCreateCommitStatus(event) 函数,维护一个白名单:push、pull_request、pull_request_target、pull_request_review、pull_request_review_comment、release。只有这些事件的 job 才会写 commit status——issues、schedule 等事件没有对应的 commit 可挂。
该函数旁边有一条源码自带的 TODO,指出它必须与 services/actions/commit_status.go 的 getCommitStatusEventNameAndCommitID(决定"状态写到哪个 commit")手工保持同步,两处各维护了一份事件集合。这是真实存在的重复——如果你要新增一种会产生 commit status 的事件,必须同时改两处,否则会出现"状态被写到错误的 commit"或"该写的没写"。本文在 14.7 会把两处代码并排展示。
14.3 工作流发现与事件匹配
14.3.1 DetectWorkflows 的签名与三态输出
modules/actions/workflows.go 的核心入口(签名忠实原文):
func DetectWorkflows(
ctx context.Context,
gitRepo *git.Repository,
commit *git.Commit,
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
detectSchedule bool,
) (workflows, schedules, filtered []*DetectedWorkflow, invalid map[string]error, err error) {
_, entries, err := ListWorkflows(ctx, gitRepo, commit)
...
for _, entry := range entries {
content, err := GetContentFromEntry(ctx, gitRepo, entry)
...
// one workflow may have multiple events
events, static, err := readWorkflowEvents(content)
if err != nil {
log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
if static {
invalid[entry.Name()] = err
}
continue
}
for _, evt := range events {
if evt.IsSchedule() {
if detectSchedule {
schedules = append(schedules, dwf)
}
} else {
switch detectWorkflowMatch(ctx, gitRepo, commit, triggedEvent, payload, evt) {
case detectMatched:
workflows = append(workflows, dwf)
case detectFilteredOut:
filtered = append(filtered, dwf)
case detectNotApplicable:
}
}
}
}
return workflows, schedules, filtered, invalid, nil
}
三个实现细节值得展开:
static标志:readWorkflowEvents返回的第二个值区分"静态解析错误"(YAML 结构坏了,值得报给用户)与"动态错误"(例如表达式求值依赖的变量缺失,此时换次事件可能就好了)。只有static == true的错误才进invalid表——否则 UI 会把一堆临时错误当成工作流语法错误展示;- 一个文件多个事件:
events是切片——on: [push, pull_request]会为同一文件产出两个Event,各自独立匹配,可能一个进workflows、另一个进filtered; detectSchedule开关:由调用方决定这次调用是否关心 schedule(push 到默认分支时才为 true,见 14.10.3)。不关心时 schedule 事件直接被丢弃,避免每次 push 都把 cron 工作流重新登记一遍。
一次调用返回四个语义不同的桶:
workflows:真正要创建 run 的(事件匹配且未被 paths 过滤);schedules:on: schedule类型(event.IsSchedule()时不走普通匹配,单独走 cron 分支);filtered:事件匹配但被paths/paths-ignore过滤掉的——它们不跑,但仍需要写 commit status(见 14.7.3),否则 PR 页面会因为"required check 缺失"而永远无法合并;invalid:YAML 解析失败的工作流文件名 → 错误,用于在 UI 上提示。
三态输出(匹配/过滤/不匹配)比"匹配 vs 不匹配"的二分多了一档,这个多出来的一档正是 Gitea 对 GitHub 行为的忠实复刻:GitHub 也会为被 paths 过滤的 workflow 写一个 Skipped 状态。
14.3.2 工作流文件在哪找:IsWorkflow 与 WorkflowDirs
IsWorkflow(path) 判断路径是否落在 setting.Actions.WorkflowDirs(默认两个:.gitea/workflows/ 与 .github/workflows/)之下。loadActionsFrom(modules/setting/actions.go)在加载配置时会校验 WorkflowDirs 与 ScopedWorkflowDirs(默认 .gitea/scoped_workflows/)不得重叠——重叠会让同一个文件既被当成普通工作流又被当成 scoped 工作流,语义无法自洽,直接启动失败。
.github/workflows/ 被包含在默认值里意味着 GitHub 的既有工作流可以零改动迁移;.gitea/workflows/ 则是 Gitea 自己的命名空间,当一个仓库同时存在两处同名文件时,检测顺序由目录遍历顺序决定,实践中应避免同名。
14.3.3 事件名与 webhook 类型的映射:canGithubEventMatch
modules/actions/github.go 维护两组映射。第一组是 GithubEvent* 常量(GithubEventPush、GithubEventPullRequest……),即 workflow 里 on: 后面写的名字。第二组 canGithubEventMatch 把 GitHub 事件名映射到 Gitea 内部 webhook 事件类型,两处"名字对不上"的转换都在这里集中处理:
// 例如:
registry_package ↔ HookEventPackage // GitHub 叫 registry_package,Gitea webhook 叫 package
gollum ↔ HookEventWiki // GitHub 用 gollum 表示 wiki 编辑
这解释了一个常见困惑:为什么 on: registry_package 能被 Gitea 的"Package" webhook 触发——因为映射表做了翻译,而不是 Gitea 真的有个叫 registry_package 的 hook 类型。
同一文件里还有 IsDefaultBranchWorkflow(event)(14.2.2 第 3 步用到的默认分支事件清单)。
14.3.4 匹配主函数:detectWorkflowMatch 的 matchTimes 判据
detectWorkflowMatch 首先做 canGithubEventMatch(evt.Name, triggedEvent) 把关(14.3.3),不匹配直接 detectNotApplicable;然后按 webhook 事件类型分派到 matchPushEvent、matchPullRequestEvent、matchIssuesEvent 等专用函数。没有任何过滤参数的事件(create/delete/fork/wiki/schedule)名字匹配即 detectMatched,写了参数只会被 log.Warn 忽略。
以最复杂的 matchPushEvent 为例,它处理六类选择器:branches、branches-ignore、tags、tags-ignore、paths、paths-ignore。逐条件的骨架忠实原文(无关分支用 … 省略):
func matchPushEvent(ctx, gitRepo, commit, pushPayload, evt) detectResult {
// with no special filter parameters
if len(evt.Acts()) == 0 {
return detectMatched
}
matchTimes := 0
hasBranchFilter := false
hasTagFilter := false
refName := git.RefName(pushPayload.Ref)
// all acts conditions should be satisfied
for cond, vals := range evt.Acts() {
switch cond {
case "branches":
hasBranchFilter = true
if !refName.IsBranch() {
break
}
patterns, err := workflowpattern.CompilePatterns(vals...)
...
if !workflowpattern.Skip(patterns, []string{refName.BranchName()}) {
matchTimes++
}
case "branches-ignore":
...
if !workflowpattern.Filter(patterns, []string{refName.BranchName()}) {
matchTimes++
}
case "tags": // 与 branches 同构,对象换 TagName
case "tags-ignore": // 与 branches-ignore 同构
case "paths":
if refName.IsTag() {
matchTimes++
break
}
filesChanged, err := commit.GetFilesChangedSinceCommit(ctx, gitRepo, pushPayload.Before)
if err != nil {
return detectNotApplicable
}
...
if !workflowpattern.Skip(patterns, filesChanged) {
matchTimes++
}
case "paths-ignore":
// 与 paths 同构,换 workflowpattern.Filter
default:
log.Warn("push event unsupported condition %q", cond)
}
}
// if both branch and tag filter are defined in the workflow only one needs to match
if hasBranchFilter && hasTagFilter {
matchTimes++
}
if matchTimes == len(evt.Acts()) {
return detectMatched
}
return detectFilteredOut
}
这段代码里有四个容易读漏的行为,逐一说明:
- 无过滤参数直接匹配:
on: push不写任何条件时零开销返回,连文件变更都不算; paths对 tag 推送直接放行(matchTimes++):打 tag 时"本次变更文件"没有意义(tag 可能指向任意历史提交),所以声明了paths的工作流在 tag push 上等价于不过滤。GetFilesChangedSinceCommit(pushPayload.Before)算的是 before..after 的差集,Before全零(新建分支首推)时的行为由 git 层决定;算失败则整个事件降级为detectNotApplicable——算不清就不触发,宁可漏跑不可错跑;- 匹配失败不是
detectNotApplicable而是detectFilteredOut:事件本身是对的(push 到来了),只是被选择器过滤——这正是 14.3.1filtered桶的数据来源; - branch + tag 双过滤的
matchTimes++:matchTimes == len(evt.Acts())是 AND 判定,而当branches与tags同时声明时 GitHub 的语义是"推到 main 或 打了 v* 标签都触发"。额外+1让总数多出一个"免费名额",任意一边命中即可凑齐——这是把 OR 语义塞进 AND 计数器的技巧,也是整段逻辑里最反直觉的一行。
evt.Acts() 来自 jobparser/model.go 的 Event 结构:
type Event struct {
Name string
acts map[string][]string // 事件名 → 选择器值列表
schedules []map[string]string // schedule: - cron: ...
}
func (e *Event) Acts() map[string][]string
func (e *Event) IsSchedule() bool
len(evt.Acts()) 就是该事件声明的选择器个数。每个存在的选择器要么贡献 1(命中),要么贡献 0(未命中),最终"命中数 == 选择器总数"才算匹配——这是 AND 语义:写了 branches: main 和 paths: src/**,则分支和路径必须同时满足。而 branch+tag 同时声明时 GitHub 的语义是"推到 main 或 打了 v* 标签都触发",所以额外 +1 让 OR 关系下只要命中任意一个即可通过判定。这个 +1 是整段逻辑里最反直觉的一行,读懂它需要同时理解 AND/OR 两种组合语义。
paths/paths-ignore 的具体匹配不在此函数,而在 workflowpattern(下一节);push 事件会先收集本次 push 涉及的全部文件路径传进去。
14.4 选择器语法:workflowpattern 与三态语义
14.4.1 CompilePatterns:glob 与 ! 反选
modules/actions/workflowpattern/workflow_pattern.go 全文不足 70 行,却是 paths 语义的全部实现。CompilePatterns(patterns ...string) 把字符串列表编译成 []*WorkflowPattern:
type WorkflowPattern struct {
negative bool
glob glob.Glob
}
func CompilePatterns(patterns ...string) ([]*WorkflowPattern, error) {
ret := make([]*WorkflowPattern, 0, len(patterns))
for _, pattern := range patterns {
cp, err := glob.CompileWorkflow(pattern)
if err != nil {
return nil, err
}
ret = append(ret, &WorkflowPattern{glob: cp, negative: strings.HasPrefix(pattern, "!")})
}
return ret, nil
}
规则只有两条:每个模式交给 glob.CompileWorkflow(Gitea 自己的 glob 实现,支持 **、*、?,分隔符按 / 处理);以 ! 开头的是 negative,前缀剥掉后编译为反向匹配器。编译只做一次、匹配做多次——DetectWorkflows 对每个工作流文件的每个事件都要跑一遍,预编译避免重复解析。
14.4.2 Skip 与 Filter:paths 与 paths-ignore 的语义差异
同一个编译结果,paths 用 Skip 判断、paths-ignore 用 Filter 判断。两个函数的源码注释本身就解释了量词方向,逐行看:
// Skip returns true if the workflow should be skipped per paths/branches semantics.
func Skip(sequence []*WorkflowPattern, input []string) bool {
allSkipped := true
for _, file := range input {
shouldSkip := true
for _, item := range sequence {
if item.negative {
// "!README.md" doesn't match "README.md", so "README.md" should be skipped
// "!README.md" matches "help.md" but it shouldn't affect "skip or not",
// because "help.md" might have been skipped by other rules like "docs/*.md"
if !item.glob.Match(file) {
shouldSkip = true
}
} else if item.glob.Match(file) {
// if "*.md" matches "help.md" so it shouldn't be skipped
shouldSkip = false
}
}
allSkipped = allSkipped && shouldSkip
}
return len(sequence) > 0 && allSkipped
}
// Filter returns true if the workflow should be skipped per paths-ignore/branches-ignore semantics.
func Filter(sequence []*WorkflowPattern, input []string) bool {
for _, file := range input {
anyMatched := false
for _, item := range sequence {
if anyMatched = item.glob.Match(file); anyMatched {
break
}
}
if !anyMatched {
return false
}
}
return len(sequence) != 0
}
用全称/存在量词表述,两者的差异一目了然:
| 选择器 | 内层循环 | 外层循环 | 触发条件 | 直觉解释 |
|---|---|---|---|---|
paths: [a, b](Skip) | 任一正模式命中 → shouldSkip = false | ∀ file: shouldSkip | 所有改动都落空才跳过 | 全部改动在白名单内才触发 |
paths-ignore: [a, b](Filter) | 任一模式命中即 break | ∃ file: 未命中 → false | 有一个文件不在黑名单 | 有任何改动不在忽略清单就触发 |
三个细节:
- 两个函数都要求
len(sequence) > 0:空模式列表返回 false(不跳过),防止"写了paths:但值为空"的 YAML 把工作流永久禁用; !的反义其实发生在 glob 层:glob.CompileWorkflow带supportNegative: true,遇到前导!会剥掉前缀并让Match结果取反(globCompiler.Match:if g.negativeFlip { ret = !ret })。所以对!docs/README.md这个 pattern,glob.Match("docs/README.md")返回 false(正则没命中 → 取反)、glob.Match("docs/guide.md")返回 true。Skip里item.negative分支写的if !item.glob.Match(file) { shouldSkip = true }因此有了准确含义:该文件确实被这条排除规则覆盖时,把它标记回"应跳过"。以paths: ["docs/**", "!docs/README.md"]为例——docs/guide.md:正模式命中翻 false、负模式!Match=false 不动 → 触发;docs/README.md:正模式翻 false、负模式!Match=true 翻回 true → 被排除。Skip的源码注释"!README.md matches help.md but it shouldn't affect skip or not"说的正是:help.md 被负模式的 Match 判为 true,于是不执行shouldSkip = true赋值,维持现状;Filter不看item.negative字段,但底层Match依然取反:paths-ignore: ["!x"]并非语法错误,其效果是"文件不匹配 x 时算命中"——语义完全反转,容易写错却不会报错。Skip/Filter只是量词外壳,!的真正语义由modules/glob承担。
14.4.3 三态:detectMatched / detectNotApplicable / detectFilteredOut
workflows.go 内部把每个工作流的判定结果归入三态之一,对应 14.3.1 的输出桶:
detectNotApplicable:事件类型根本不匹配(比如工作流只写了on: issues,来了个 push)→ 不进任何桶,不写状态;detectFilteredOut:事件匹配但 paths 过滤 → 进filtered桶,写 Skipped status;detectMatched:匹配 → 进workflows桶,创建 run。
三态的意义在 PR 合并闸门场景下才完全显现:一个 required check build / test (pull_request) 如果因为只改了 README 而被 paths-ignore 过滤,必须留下一个 Skipped 状态,否则 GitHub/Gitea 的分支保护会认为该 check 没有运行,合并按钮保持禁用。14.7.3 会给出 handleFilteredWorkflows 的真实行为。
14.4.4 scoped workflow:跨仓库借用工作流
setting.Actions.ScopedWorkflowDirs(默认 .gitea/scoped_workflows/)支持一种扩展机制:仓库 A 可以引用仓库 B 中的某个工作流文件来跑自己。detectAndHandleScopedWorkflows(notifier_helper.go)负责这条支线:
- 检出仓库 A 的 scoped 目录,读取其中声明了"来源仓库"的工作流;
- 若来源工作流可用且 A 未显式 opt-out,则插入对应的 run;
- opt-out 逻辑允许仓库 A 关闭某个被共享的工作流,避免被上游仓库的更新"绑架"。
这个机制与 workflow_call(可复用工作流)不同:workflow_call 是同仓库或允许的仓库内按文件引用,scoped workflow 则把"工作流即服务"的模式做在了目录约定上。两者的配置校验都依赖 14.2.2 提过的目录不重叠检查。
14.5 Run / Attempt / Job 的落库与并发控制
14.5.1 三层对象模型
一次 CI 运行在数据库里是三层:
ActionRun:一条工作流在一个事件上的一次运行(含workflow_id、commit_sha、event、trigger_user_id、status、need_approval等);ActionRunAttempt:run 的第几次尝试(重跑一次 +1)。InsertRun的注释说明了为什么 run 要先插入:github.run_id表达式要取 run 的主键,而 run 的插入必须发生在解析 job、求值if:之前,否则表达式无值可用;ActionRunJob:run 内的单个 job(含job_id(YAML 里的 id)、name、concurrency、matrix、status、task_id等)。
services/actions/run.go 的 PrepareRunAndInsert/InsertRun 在一个事务内完成:插入 run → 插入 attempt → jobparser.Parse(content, WithVars/WithGitContext/WithInputs) 解析工作流 → 逐 job insertRunJob。逐 job 处理时会:
- 求值
if:(decideJobIf)——注意这与 runner 侧改写后的always()(14.6.3)不同,这里求的是原始条件,决定 job 起始状态; - 检查
concurrency组——同组并发超限时取消旧 run(services/actions/concurrency.go); - 处理
strategy.matrix——deferred matrix(矩阵里引用了 needs 输出的场景)此时无法展开,job 先落库为待展开状态,等 needs 就绪后再展开;这也是 commit status 写入时要跳过 deferred-matrix job 的原因(14.7.1); - 展开 reusable caller(
workflow_call调用方传入的 inputs)。
事务末尾,若存在 waiting 状态的 job,则 IncreaseTaskVersion()——这一步是 14.6.2 runner 轮询能立刻看到新任务的信号源。
InsertRun 的事务开头(services/actions/run.go,忠实原文节选)最值得逐段读:
func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error {
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
var needPostCommitEmit bool
if err := db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
...
run.Index = index
run.Title = util.EllipsisDisplayString(run.Title, 255)
run.Status = actions_model.StatusWaiting
...
// Insert before parsing jobs or evaluating workflow-level concurrency
// so that run.ID is populated. Expressions referencing github.run_id —
// in run-name, job names, runs-on, or a workflow-level concurrency
// group like `${{ github.head_ref || github.run_id }}` — would otherwise
// interpolate to an empty string.
if err := db.Insert(ctx, run); err != nil {
return err
}
runAttempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: run.TriggerUserID,
Status: actions_model.StatusWaiting,
}
if wfRawConcurrency != nil {
if err := EvaluateRunConcurrencyFillModel(ctx, run, runAttempt, wfRawConcurrency, vars, inputs); err != nil { ... }
// check run (workflow-level) concurrency
if !run.NeedApproval { // deferred to ApproveRuns
var jobsToCancel []*actions_model.ActionRunJob
runAttempt.Status, jobsToCancel, err = PrepareToStartRunWithConcurrency(ctx, runAttempt)
...
}
}
...
giteaCtx := GenerateGiteaContext(ctx, run, runAttempt, nil)
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs))
...
titleChanged := len(jobs) > 0 && jobs[0].RunName != ""
if titleChanged {
run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255)
}
...
四段注释背后的工程决策:
GetNextResourceIndex(ctx, "action_run_index", run.RepoID):run 的Index(UI 上显示的 "#42")是按仓库分配的资源序号,走 Gitea 统一的资源索引表——这与 issue 号、PR 号同一套机制,保证同一仓库内 run 序号单调且不复用;- 先 insert run 再解析 job(原注释逐字解释):
run-name、job 名、runs-on、workflow 级concurrency.group都可能是${{ github.run_id }}表达式,而github.run_id的值就是 run 的主键。若先解析后插入,这些表达式会插值成空字符串——于是顺序被强制为:拿主键 → 建 attempt →GenerateGiteaContext组装上下文 →jobparser.Parse求值。这也解释了为什么run.Title要在解析后二次覆盖:run 落库前的初始标题是commit.MessageTitle()(commit 首行,见notifier_helper.go构造ActionRun处),若 YAML 写了run-name:则用它(titleChanged分支),两者都用EllipsisDisplayString截到 255(与列宽一致,超长不报错只截断); - workflow 级并发的判定推迟到
!run.NeedApproval:需要审批的 run 还没获得"占并发组"的资格——审批通过(ApproveRuns)时才去竞争并发组,否则一个待审批的 run 会把同组的正常 run 挂起在那等一个可能永远不会被批准的东西。被并发规则挤掉的旧 run 的 job 收集进cancelledConcurrencyJobs,出事务后再统一发通知(事务内不能发外部副作用,这是 Gitea 全库的惯例); jobparser.Parse的三个 With 选项:WithVars/WithInputs提供表达式求值的原料,WithGitContext(giteaCtx.ToGitHubContext())提供github.*上下文——解析过程同时也是表达式求值过程(Gitea 在服务端就把能算的都算了,runner 拿到的是算好的 payload,见 14.6.3)。
14.5.2 job 就绪队列:actions_ready_job 唯一队列
job 之间靠 needs 形成 DAG,前驱 job 完成后要通知"后继 job 可能就绪了"。services/actions/job_emitter.go 用了一个唯一队列(unique queue):
var jobEmitterQueue = queue.CreateUniqueQueue(..., "actions_ready_job", ...)
唯一队列的语义是:同一 key(这里是 runID)在队列中至多存一份,重复投递被合并。这正好匹配"一个 run 的多个 job 同时完成、需要反复检查同一批后继 job"的场景——10 个前驱 job 同时结束,最多触发一次 EmitJobsIfReadyByRun(runID) 的重复检查洪峰被压成一次。EmitJobsIfReadyByRun 检查该 run 内每个 waiting job 的 needs 是否全部满足(FindTaskNeeds,同 attempt、同 ParentJobID 作用域),满足则把 job 状态推到 ready 并投递。
14.5.3 重跑、取消与清任务
- 重跑(
services/actions/rerun.go):创建新 attempt,把 job 状态复位,MaxRerunAttempts(默认 50)限制次数; - 取消(
cancel.go):把 run/job 置为取消态;正在 runner 上执行的任务通过下一次UpdateTask或FetchTask的取消信号传达(runner 有runnerCapabilityCancelling能力位,注册时声明——见 14.6.1,不支持取消的老 runner 走降级路径); - 清任务(
clear_tasks.go):批量删除 run 及其 task,同时清理 artifact 与日志。
这些操作都必须处理"任务已经在 runner 上跑了"的中间态,因此都以数据库状态机为准,而不是直接杀进程——runner 是不可信且不可达的外部组件,服务端只能靠状态收敛。
14.6 runner 协议:注册、心跳与任务拉取
14.6.1 Connect 服务与注册
routers/api/actions/actions.go 的 Routes(prefix) 注册两样东西:一个 ping 端点(runner 连通性自检)和 RunnerService 的 Connect handler。协议用的是 connectrpc(Connect 协议),不是 gRPC 也不是 twirp(twirp 只用于 artifact v4,14.8.3)。
routers/api/actions/runner/runner.go 实现五个方法:
Register:输入 registration token(管理员在 UI 生成,或用 gitea actions generate-runner-token --scope {owner}[/{repo}] 生成——scope 决定 runner 归属的 owner/repo,进而决定 14.6.2 选任务时的 scope 过滤条件)。校验 token 后创建 ActionRunner 记录、GenerateAndFillToken 生成 runner 自己的长效 token(存 hash + salt),响应中声明 runnerCapabilityCancelling 能力位。原文节选(runner.go):
func (s *Service) Register(ctx context.Context, req *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error) {
if req.Msg.Token == "" || req.Msg.Name == "" {
return nil, errors.New("missing runner token, name")
}
runnerToken, err := actions_model.GetRunnerToken(ctx, req.Msg.Token)
...
if !runnerToken.IsActive {
return nil, errors.New("runner registration token has been invalidated, please use the latest one")
}
...
// create new runner
name := util.EllipsisDisplayString(req.Msg.Name, 255)
runner := &actions_model.ActionRunner{
UUID: uuid.New().String(),
Name: name,
OwnerID: runnerToken.OwnerID,
RepoID: runnerToken.RepoID,
Version: req.Msg.Version,
AgentLabels: labels,
Ephemeral: req.Msg.Ephemeral,
HasCancellingSupport: hasCancellingSupport,
}
runner.GenerateAndFillToken()
...
// update token status
runnerToken.IsActive = true
if err := actions_model.UpdateRunnerToken(ctx, runnerToken, "is_active"); err != nil { ... }
...
}
// runnerCapabilityCancelling is the wire string the runner advertises in its
// capabilities list to indicate it understands the transitional cancelling
// state and will run post-step cleanup before finalizing the task.
const runnerCapabilityCancelling = "cancelling"
三处细节:
- runner 的身份凭据是 UUID + Token 两段式:UUID 明文进 header 用于定位记录,Token 走常量时间比较(14.6.4)。注册令牌的失效语义要看
models/actions/runner_token.go才准确:NewRunnerTokenWithValue生成新令牌时会把同 owner/repo scope 的旧令牌全部置IsActive=false,因此Register里!runnerToken.IsActive的拒绝分支拦的是被取代的过期令牌(错误信息 "has been invalidated, please use the latest one" 说的就是这件事)——注意同一个仍有效的注册令牌可以注册多个 runner(一令牌多 runner 是常规部署方式),防的不是重复注册而是旧令牌继续流通。顺带一提,GetRunnerToken找不到令牌时的错误只回显 token 前 3 个字符(util.TruncateRunes(token, 3)),防止完整令牌被日志/错误消息带出去; OwnerID/RepoID直接继承自注册令牌:runner 的任务可见范围在注册那一刻由令牌 scope 决定,运行期不可改——要改归属只能删掉重新注册;Ephemeral标志:一次性 runner(跑完一个任务就退出)如实入库,供 UI 标注与调度参考。runnerCapabilityCancelling的注释解释了能力协商的语义:支持该能力的 runner 收到取消信号后进入cancelling过渡态、先跑完 post-step 清理(删工作目录、上传日志)再回报终态;老 runner 走降级路径直接终止。
Declare:runner 上线后申报/更新 labels、version、capabilities;响应头回带 X-Gitea-Actions-Capabilities,让服务端知道该 runner 支持哪些扩展(决定是否把需要特定能力的任务派给它)。源码注释写明了为什么能力走 header 而不是改 proto:
// Capabilities are communicated via headers to avoid a hard dependency on a proto bump.
// Older runners ignore unknown headers; newer runners can use this for feature negotiation.
resp.Header().Set("X-Gitea-Actions-Capabilities", actions_model.RunnerCapabilities())
proto 是 actionslib 里发布的契约(gitea.dev/actionslib,见 14.1.2),改 proto 要发新版、两端同步升级;把协商信息放进响应头,新旧 runner 就能共存——这是"服务端先支持、runner 慢慢升级"的灰度前提。applyDeclareRequestToRunner 还体现了按列更新的细节:只有 has_cancelling_support 实际变化时才把该列加进 cols,避免每次 Declare 都写一次没变的字段(Declare 与心跳同频,写放大要省着来)。
14.6.2 FetchTask:tasksVersion 轮询与乐观锁领取
任务分发是拉模型:服务端不推送,runner 周期性 FetchTask。核心版本号是 tasksVersion——每次有新任务可领(IncreaseTaskVersion,见 14.5.1 事务末尾)就自增。FetchTask 的原文(routers/api/actions/runner/runner.go):
// FetchTask assigns a task to the runner
func (s *Service) FetchTask(
ctx context.Context,
req *connect.Request[runnerv1.FetchTaskRequest],
) (*connect.Response[runnerv1.FetchTaskResponse], error) {
runner := GetRunner(ctx)
var task *runnerv1.Task
tasksVersion := req.Msg.TasksVersion // task version from runner
latestVersion, err := actions_model.GetTasksVersionByScope(ctx, runner.OwnerID, runner.RepoID)
if err != nil {
return nil, status.Errorf(codes.Internal, "query tasks version failed: %v", err)
} else if latestVersion == 0 {
if err := actions_model.IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID); err != nil {
return nil, status.Errorf(codes.Internal, "fail to increase task version: %v", err)
}
// if we don't increase the value of `latestVersion` here,
// the response of FetchTask will return tasksVersion as zero.
// and the runner will treat it as an old version of Gitea.
latestVersion++
}
if tasksVersion != latestVersion {
// Re-load runner from DB so task assignment uses current IsDisabled state
// (avoids race where disable commits while this request still has stale runner).
freshRunner, err := actions_model.GetRunnerByUUID(ctx, runner.UUID)
...
// if the task version in request is not equal to the version in db,
// it means there may still be some tasks that haven't been assigned.
// try to pick a task for the runner that send the request.
if t, ok, throttled, err := actions_service.TryPickTask(ctx, freshRunner); err != nil {
log.Error("pick task failed: %v", err)
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
} else if throttled {
// Concurrency limit reached: don't advance the runner's tasks version,
// so it retries on its next poll instead of sleeping until the next bump.
latestVersion = tasksVersion
// A steady stream here means MAX_CONCURRENT_TASK_PICKS is too low for the fleet.
log.Debug("task pick throttled for runner %q (id %d); it will retry on its next poll", freshRunner.Name, freshRunner.ID)
} else if ok {
task = t
}
}
res := connect.NewResponse(&runnerv1.FetchTaskResponse{
Task: task,
TasksVersion: latestVersion,
})
return res, nil
}
逐条对照前面的概述:
- 版本比较即"有没有活"的全部判据:
req.Msg.TasksVersion是 runner 上次拿到的版本,与库中不同才进选任务分支;相同则原样返回当前版本——空轮询只读不写,没有写放大; latestVersion == 0的兜底:全新实例(或 scope 首次出现任务)版本为 0,这里自增一次并同步推进本地变量,注释直说原因:不推进的话响应里返回 0,runner 会把这当成"旧版 Gitea"(协议兼容性歧义)。这是版本号作为单调信号必须避开 0 值的细节;- 禁用态在轮询里处理(第二段注释):拦截器里的 runner 是请求开始时装进 ctx 的,管理员在这期间点下"禁用"就存在竞态;所以选任务前重新从库里读一遍
freshRunner,用它的IsDisabled判定——禁用生效最迟在下一次轮询,无需推送通道; - throttled 的回退:
latestVersion = tasksVersion——响应里回给 runner 的还是它自己的旧版本号,runner 下次轮询时差异依旧存在,会再次尝试领取;而log.Debug那句运维注释很实用:限流日志持续刷屏 =MAX_CONCURRENT_TASK_PICKS对这个集群太小(14.10.1)。
TryPickTask(services/actions/task.go)有一道并发闸:
// taskPickLimiter:并发选任务的信号量,容量 max(1, setting.Actions.MaxConcurrentTaskPicks)
// 默认 MaxConcurrentTaskPicks = 16
如果没有这道闸,100 个 runner 同时 FetchTask 且版本号刚变化,会同时进入选任务逻辑,全部去抢同一批 job(惊群)。信号量把并发压到 16;throttled(拿不到名额)时直接返回,不推进 tasksVersion——这样任务没被领走、版本号不变,runner 下次轮询还会再来,任务不会因为撞锁而"消失"。
真正的选取在 models/actions/task.go 的 CreateTaskForRunner,两个关键设计:
const pickTaskBatchSize = 100
- keyset 分页:按
(updated, id)游标取最多 100 个候选 job,避免LIMIT/OFFSET在大表上的深分页扫描; - 两层过滤:
runner.CanMatchLabels(v.RunsOn)(runner 的 labels 要覆盖 job 的runs-on)+ repo/owner scope 条件(注册时的 scope 决定任务可见性); - 乐观锁领取:对每个候选 job,在事务里把 job 置 Running、插入
ActionTask及其 steps,然后执行影响行数UPDATE action_run_job SET task_id = ? WHERE id = ? AND task_id = 0 AND status = 'waiting'n != 1说明被别的 runner 抢先了(errJobAlreadyClaimed),继续试下一个候选,而不是失败返回。
乐观锁 + 续试的组合让"并发领取"既不需要分布式锁,也不会因冲突整体失败。领取成功后 buildRunnerTask 组装 runnerv1.Task{Id, WorkflowPayload, Context, Secrets, Vars, Needs} 返回。若组装阶段失败(例如 secret 解密失败),走 releaseTaskForRunnerCleanup 把已领取的任务释放回 waiting,避免任务被永久卡死在"已领未派"状态。
14.6.3 任务负载的两处改写
buildRunnerTask 之前有两个值得单独讲的改写:
runnerWorkflowPayload:把 job 的 if: 条件改写为 always()。原因:if: 已经在服务端求过值(决定 job 是否进入本次派发),runner 只是执行者;如果 runner 再求一次原始条件,由于上下文不完整(比如 needs 状态在派发瞬间与执行瞬间可能不同),可能出现"服务端认为该跑、runner 认为不该跑"的分歧,任务就无声无息地被跳过。改写成 always() 后 runner 必然执行,判定权完全归服务端。
generateTaskContext(services/actions/task.go)与 GenerateGiteaContext(services/actions/context.go):组装 github.* 上下文。值得抄录的细节:
// context.go,ToGitHubContext 内:
githubCtx.Token = "" // deliberately omitted for security
上下文结构里 token 字段被显式置空——token 不通过上下文 JSON 传递,而是走 generateTaskContext 注入的独立字段(token 与 gitea_runtime_token),减少被日志或调试输出顺带打印的面。上下文还包含 ref_protected(当前 ref 是否受保护,供表达式判断)、gitea_default_actions_url(Gitea 对 GitHub 上下文的扩展,用于生成指向 Gitea UI 的链接)、run_attempt 回退为 "1"(无 attempt 时的兜底,避免表达式求值炸出空值)。
GenerateGiteaContext(services/actions/context.go)本身的构造逻辑里有一段安全语义极强的原文:
baseRef := ""
headRef := ""
ref := run.Ref
sha := run.CommitSHA
if pullPayload, err := run.GetPullRequestEventPayload(); err == nil && pullPayload.PullRequest != nil && ... {
baseRef = pullPayload.PullRequest.Base.Ref
headRef = pullPayload.PullRequest.Head.Ref
// if the TriggerEvent is pull_request_target, ref and sha need to be set according to the base of pull request
// In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target,
// the ref will be the base branch.
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Ref
sha = pullPayload.PullRequest.Base.Sha
}
}
...
"ref_protected": refProtected, // boolean, true if protection rules are configured for the ref that triggered the workflow run.
"run_id": strconv.FormatInt(run.ID, 10),
"run_number": strconv.FormatInt(run.Index, 10),
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
...
if job != nil {
gitContext["job"] = job.JobID
gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10)
}
pull_request_target的 ref/sha 指向 base:这是该事件最容易配错的点——run.Ref/run.CommitSHA记录的是触发提交(fork 的 head),但上下文要按 GitHub 文档给成 base 分支。若不特判,if: github.ref == 'refs/heads/main'这类条件在 target 事件上会永远为假;配合 14.2.2 的"工作流文件取 base 侧",两处合起来才是pull_request_target的完整语义:代码、工作流、ref、sha 全在 base,只有 payload 里的pull_request.head指向 fork;ref_protected是每次生成上下文时实时查库(IsRefProtected),查询失败只打 Error 并降级为false——降级方向选"不受保护"是刻意的:让依赖它的条件判断走保守分支,而不是因为一个查询错误让整条工作流跑不起来;run_id用主键、run_number用仓库内序号(run.Index,14.5.1 的action_run_index),两者语义不同——重跑(re-run)不改变run_id/run_number,只改变run_attempt;run_attempt与job仅在job != nil(job 级上下文)时才填,workflow 级上下文里它们是占位;secret_source: "Actions"复刻 GitHub 的字段(None/Actions/Dependabot/Codespaces 四值),让检查"这个 run 用的 secrets 来自哪里"的表达式在 Gitea 上有确定答案;- 标准字段的每一行都带着 GitHub 官方文档的注释逐条对齐(
// string, The ...),这套注释本身就是兼容性清单:凡是 GitHub 文档列出的github.*字段,Gitea 要么填真值、要么给空串占位,保证${{ github.graphql_url }}这种冷门字段求值不会报"属性不存在"。
14.6.4 鉴权拦截器:withRunner 与心跳去抖
routers/api/actions/runner/interceptor.go 的 withRunner 拦截器对每个 Connect 调用做三件事:
- 从 header 取
protocol.UUIDHeader与TokenHeader; GetRunnerByUUID查出 runner,用常量时间比较校验 token:
常量时间比较防止通过响应耗时侧信道暴力猜测 token 前缀;util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt))- 更新
last_active/last_online——但去抖:ShouldPersistLastActive/ShouldPersistLastOnline判断距上次写库是否超过阈值。UpdateLog是高频调用(日志流可能每秒数十次),如果每次都写数据库,心跳本身会变成写放大压力源。去抖后,活跃时间的精度略降(秒级),换来数据库的稳定。
14.7 执行回报:状态机、commit status 与 outputs
14.7.1 UpdateTask:一次回报触发的连锁反应
runner 完成任务状态变更时调 UpdateTask,服务端在 routers/api/actions/runner/runner.go 里依次做:
UpdateTaskByState:按 runner 上报的状态推进 task/job/run 的状态机;- outputs 校验:key 长度 ≤ 255、value 长度 ≤ 1MB(超限直接拒绝——outputs 会进数据库,无限制等于允许 runner 把内存打爆);
CreateCommitStatusForRunJobs:把本次涉及的 job 状态写成 commit status(14.7.2);- task done 时通知(推进 run 状态、触发
workflow_run事件的下游); EmitJobsIfReadyByRun:本 job 完成 → 检查后继 job 是否就绪(14.5.2);- 回执
SentOutputs:runner 收到回执才清理本地 outputs,防止"服务端没存成功、runner 已经删了"的数据丢失。
这个顺序是有讲究的:先落库、再写状态、后发通知。任何一步失败,重试都从状态机的持久点恢复。
UpdateTask 的后半段原文(routers/api/actions/runner/runner.go)能把上面六步一一对上:
for k, v := range req.Msg.Outputs {
if len(k) > 255 {
log.Warn("Ignore the output of task %d because the key is too long: %q", task.ID, k)
continue
}
// The value can be a maximum of 1 MB
if l := len(v); l > 1024*1024 {
log.Warn("Ignore the output %q of task %d because the value is too long: %v", k, task.ID, l)
continue
}
// There's another limitation on GitHub that the total of all outputs in a workflow run can be a maximum of 50 MB.
// We don't check the total size here because it's not easy to do, and it doesn't really worth it.
// See https://docs.github.com/en/actions/using-jobs/defining-outputs-for-jobs
if err := actions_model.InsertTaskOutputIfNotExist(ctx, task.ID, k, v); err != nil {
log.Warn("Failed to insert the output %q of task %d: %v", k, task.ID, err)
// It's ok not to return errors, the runner will resend the outputs.
}
}
sentOutputs, err := actions_model.FindTaskOutputKeyByTaskID(ctx, task.ID)
...
actions_service.CreateCommitStatusForRunJobs(ctx, task.Job.Run, task.Job)
if task.Status.IsDone() {
actions_service.NotifyWorkflowJobStatusUpdateWithTask(ctx, task.Job, task)
}
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
if err := actions_service.EmitJobsIfReadyByRun(task.Job.RunID); err != nil {
log.Error("Emit ready jobs of run %d: %v", task.Job.RunID, err)
}
if task.Job.Run.Status.IsDone() {
actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, task.Job.RepoID, task.Job.RunID)
}
}
return connect.NewResponse(&runnerv1.UpdateTaskResponse{
State: &runnerv1.TaskState{
Id: req.Msg.State.Id,
Result: task.Status.AsResult(),
},
SentOutputs: sentOutputs,
}), nil
设计要点:
- 超限 outputs 是"忽略"不是"报错"(
log.Warn+continue):单个输出写不进数据库不该让整个状态回报失败——状态机推进比 outputs 完整性重要。注释还诚实承认了与 GitHub 的差异:GitHub 限制一次 run 的 outputs 总量 50MB,Gitea 不检查总量,理由是 "it's not easy to do, and it doesn't really worth it"; InsertTaskOutputIfNotExist+SentOutputs构成幂等协议:runner 每次UpdateTask都全量重发 outputs,服务端按 key 幂等插入,然后把库里实际存在的 key 列表回执给 runner;runner 只清理回执里出现的 key。这保证"服务端没存成功的,runner 会下次再发"(两处// It's ok not to return errors注释都在说这件事);Result_RESULT_UNSPECIFIED作为"还没结束"的哨兵:runner 汇报中间状态(running)时Result未指定,此时不触发EmitJobsIfReadyByRun——只有带终态结果的回报才推进后继 job,避免每个 step 的状态更新都去扫一遍整个 run 的就绪判定;- 回执里
Result: task.Status.AsResult()是服务端视角的最终结果而非 runner 上报值:runner 想报 success,但服务端状态机可能已把它标成 cancelled(并发组抢占、用户点了取消),以服务端为准才能让 runner 收敛到一致状态。
14.7.2 commit status 的上下文命名与 hash
第 8 章 8.11.3 展示过 PR 页面上的 build / test (pull_request) 这样的 check 名,出处在 modules/actions/workflows.go:
func WorkflowStatusContextName(...) string {
return fmt.Sprintf("%s / %s (%s)", displayName, jobName, event)
}
而 services/actions/commit_status.go 的 createWorkflowCommitStatus 计算上下文 hash:
ctxHash := HashCommitStatusContext(ctxName + "\x00" + workflowID)
// 另计算一个不含 workflowID 的 legacy hash 用于兼容旧数据
把 workflowID 混进 hash 是为了修复 issue #35699:两个不同工作流文件恰好产生同名 check(同 workflow 名 + 同 job 名 + 同事件)时,不混入 workflowID 会让其中一个的更新覆盖另一个的状态。保留 legacy hash 则是为了让历史 commit 上的旧状态仍能被更新而不是新开一条。
onlyReplace 参数控制 pending 状态只替换已存在的 pending——防止重复推送 pending 把时间线刷乱。
getCommitStatusEventNameAndCommitID 决定"状态挂到哪个 commit":
| 事件 | 目标 commit |
|---|---|
push | HeadCommit.ID(本次 push 的头提交,不是分支头) |
pull_request | PR.Head.Sha |
pull_request_target | 同 PR 目标但需区分(target 事件的上下文是 base,状态写 head) |
| 其他白名单事件 | 各自规则 |
这就是 14.2.4 那条 TODO 要求与 ShouldEventCreateCommitStatus 手工同步的原因:一边决定"写不写",一边决定"写哪",两边事件集合必须一致。
14.7.3 状态映射与 filtered 补写
toCommitStatus/toCommitStatusDescription 把内部状态映射为 Gitea commit status 枚举(pending/success/failure/error/cancelled/warning),并生成带 job 名与事件的描述文本。cron 触发的 run 不写 commit status(没有新 commit 可挂),deferred-matrix 未展开的 job 也跳过(还没有确定的 check 上下文)。
handleFilteredWorkflows(14.3.1 的 filtered 桶的消费者)为每个被 paths 过滤的工作流写一个 Skipped 状态,上下文与正常跑时一致(WorkflowStatusContextName 同样可算),从而让 required check 满足"出现过且结论为跳过"的分支保护条件。这是 14.4.3 三态设计的收口。
14.8 日志与 artifact:双段存储与双协议上传
14.8.1 UpdateLog:ack 去重与两段式落盘
runner 执行时持续 UpdateLog 推送日志块,服务端在 runner.go 的 UpdateLog 中的处理顺序:
- ack 去重:日志块带序号,已确认过的序号直接返回成功(runner 断线重发是常态);
LogInStorage检查:任务日志已经完成转存(14.8.2)后,再来的追加块会被拒绝为 AlreadyExists——转存后的日志是不可变压缩对象,不接受事后追加;WriteLogs追加到 DBFS;- 任务结束时
TransferLogs触发转存。
对应原文(routers/api/actions/runner/runner.go,忠实节选):
task, err := actions_model.GetTaskByID(ctx, req.Msg.TaskId)
...
} else if runner.ID != task.RunnerID {
return nil, status.Errorf(codes.Internal, "invalid runner for task")
}
ack := task.LogLength
// Trim rows the runner already had acked.
var rows []*runnerv1.LogRow
if req.Msg.Index <= ack && int64(len(req.Msg.Rows))+req.Msg.Index > ack {
rows = req.Msg.Rows[ack-req.Msg.Index:]
}
// Ack a re-sent finalize idempotently. Appending new rows past the seal errors.
if task.LogInStorage {
if len(rows) > 0 {
return nil, status.Errorf(codes.AlreadyExists, "log file has been archived")
}
res.Msg.AckIndex = ack
return res, nil
}
// Bail unless we have new rows or a NoMore to finalize. Even with
// NoMore, bail when the runner has outrun the server — archiving a
// log with a gap is worse than asking it to retry.
if len(rows) == 0 && (!req.Msg.NoMore || req.Msg.Index > ack) {
res.Msg.AckIndex = ack
return res, nil
}
// WriteLogs is called even with no rows: with offset==0 it bootstraps
// an empty DBFS file so TransferLogs below has something to read when
// the runner finalizes a task that produced no log output.
ns, err := actions.WriteLogs(ctx, task.LogFilename, task.LogSize, rows)
...
task.LogLength += int64(len(rows))
for _, n := range ns {
task.LogIndexes = append(task.LogIndexes, task.LogSize)
task.LogSize += int64(n)
}
res.Msg.AckIndex = task.LogLength
var remove func()
if req.Msg.NoMore {
task.LogInStorage = true
remove, err = actions.TransferLogs(ctx, task.LogFilename)
...
}
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_length", "log_size", "log_in_storage"); err != nil { ... }
if remove != nil {
remove()
}
这段代码里的三个状态量要一起看才明白:LogLength(已确认的行数,用作 ack 水位)、LogSize(DBFS 文件字节数,用作下次写入的 offset)、LogIndexes(每行在文件里的字节偏移表,读取时按行定位用)。注释对应的五个边界:
runner.ID != task.RunnerID拒绝越权写日志:task 归属校验放在最前——没有它,一个 runner 可以往别人任务的日志里灌内容;- 裁剪已确认行:
rows = req.Msg.Rows[ack-req.Msg.Index:]——重叠部分丢弃、只保留 ack 之后的新增行。请求的起始 index 在 ack 之前、结束位置在 ack 之后的"半重叠"块被精确切尾;完全在 ack 之前的块裁成空数组; - 封存后的幂等应答:日志已转存(
LogInStorage)时,若还带新行返回 AlreadyExists(runner 据此停止发送),不带新行则照常回 ack——"重发的 finalize 请求"因此幂等; - "空行也调 WriteLogs"的 bootstrap:任务一行日志都没产生时(例如所有 step 都被跳过),
NoMore到来时仍以offset==0创建一个空 DBFS 文件,让TransferLogs有东西可读——否则转存读不到文件,任务会卡在日志收尾; - "落后就别封存":
req.Msg.Index > ack说明 runner 抢跑超前(它的行号已经越过服务端水位),此时即使带NoMore也直接回 ack 让它重发——注释说得很直白:带空洞归档比要求重试更糟(压缩归档后空洞永远补不上,读日志时中间凭空缺一段)。
转存成功后的 remove() 延迟调用(UpdateTask 落库之后才执行删除)是最后一道保险:先确保"新位置可读 + 元数据已更新",再删旧文件——顺序反了会出现"新位置没有、旧文件已删"的日志黑洞。
modules/actions/log.go 的 WriteLogs 函数头注释与函数体(节选)解释了整套双段设计的动机:
const (
MaxLineSize = 64 * 1024
DBFSPrefix = "actions_log/"
timeFormat = "2006-01-02T15:04:05.0000000Z07:00"
defaultBufSize = MaxLineSize
)
// WriteLogs appends logs to DBFS file for temporary storage.
// It doesn't respect the file format in the filename like ".zst", since it's difficult
// to reopen a closed compressed file and append new content.
// Why doesn't it store logs in object storage directly? Because it's not efficient
// to append content to object storage.
func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) {
flag, openFileFor := os.O_WRONLY, "write-only"
if offset == 0 {
// Only allow to create file if offset is 0 (the first write), see #25560.
// Otherwise, it might result in content holes if the file has been deleted
// after transferred (actions.TransferLogs).
flag, openFileFor = os.O_WRONLY|os.O_CREATE, "write-create"
}
name := DBFSPrefix + filename
f, err := dbfs.OpenFile(ctx, name, flag)
...
stat, err := f.Stat()
...
if stat.Size() < offset {
// If the size is less than offset, refuse to write, or it could result in content holes.
// However, if the size is greater than offset, we can still write to overwrite the content.
return nil, fmt.Errorf("size of %q is less than offset", name)
}
if _, err := f.Seek(offset, io.SeekStart); err != nil { ... }
writer := bufio.NewWriterSize(f, defaultBufSize)
ns := make([]int, 0, len(rows))
for _, row := range rows {
n, err := writer.WriteString(FormatLog(row.Time.AsTime(), row.Content) + "\n")
...
}
...
}
四个设计点逐一拆解:
- 为什么是 DBFS 而不是直接写对象存储:函数头注释自问自答——对象存储没有"append"操作,每次追加都要重写整个对象;运行中的日志每秒可能来几十块,DBFS(数据库文件系统,行级追加)才是可承受的选择。而压缩文件(
.zst)一旦关闭就无法再追加,所以运行期的 DBFS 文件不带压缩后缀,压缩是转存时才做的; - offset==0 才允许创建(issue #25560):转存成功后 DBFS 文件会被删除。若 runner 在转存后重发一段日志,带
O_CREATE的写会凭空重建一个只有尾部内容的文件,读出来就是"内容空洞"(中间缺失)。现在的规则:文件不存在就必须从 0 写起; - size < offset 拒绝、size > offset 允许覆盖:注释写得明白——小于 offset 意味着中间有洞,拒绝;大于 offset 只是重复数据,覆盖无害。这条规则配合 ack 协议(服务端 ack 位置 =
task.LogLength)实现幂等重传; - 单行格式:
FormatLog把一行日志存成"<RFC3339微秒时间戳> <内容>\n",内容里的\n被转义为字面\n(否则一行变两行,ParseLog按第一个空格切分的约定就碎了),超过MaxLineSize截断。ReadLogs用bufio.Scanner配len(timeFormat)+MaxLineSize+1的缓冲读回,往返无损。
14.8.2 TransferLogs:zstd 压缩与对象存储转存
日志"运行中在数据库、结束后在对象存储"的双段设计:运行期间日志是热数据,需要频繁追加和实时查看(DBFS 每次 append 是一次行更新/插入,读取按 offset 分块);结束后日志变成冷数据,只读且体量大,放数据库纯属浪费。
TransferLogs 在任务无更多日志(NoMore)时执行,函数头注释与主体(节选):
const (
// logZstdBlockSize is the block size for zstd compression.
// 128KB leads the compression ratio to be close to the regular zstd compression.
// And it means each read from the underlying object storage will be at least
// 128KB*(compression ratio). The compression ratio is about 30% for text files,
// so the actual read size is about 38KB, which should be acceptable.
logZstdBlockSize = 128 * 1024 // 128KB
)
// TransferLogs transfers logs from DBFS to object storage.
// It happens when the file is complete and no more logs will be appended.
// It respects the file format in the filename like ".zst", and compresses the content if needed.
// The task log file must be marked as "log_in_storage=true" after the transfer.
func TransferLogs(ctx context.Context, filename string) (func(), error) {
name := DBFSPrefix + filename
remove := func() {
if err := dbfs.Remove(ctx, name); err != nil {
log.Warn("dbfs.Remove %q: %v", name, err)
}
}
f, err := dbfs.Open(ctx, name)
...
var reader io.Reader = f
if strings.HasSuffix(filename, ".zst") {
r, w := io.Pipe()
reader = r
zstdWriter, err := zstd.NewSeekableWriter(w, logZstdBlockSize)
...
go func() {
defer func() { _ = w.CloseWithError(zstdWriter.Close()) }()
if _, err := io.Copy(zstdWriter, f); err != nil {
_ = w.CloseWithError(err)
return
}
}()
}
if _, err := storage.Actions.Save(filename, reader, -1); err != nil { ... }
return remove, nil
}
要点:
- 是否压缩由文件名后缀决定:运行期 DBFS 文件无后缀(不可压),转存目标
filename带.zst(任务落库时就定好),TransferLogs据此选择直通或压缩分支——同一函数服务两种配置(LOG_COMPRESSION = none | zstd); zstd.NewSeekableWriter而非普通 zstd:写出的.zst尾部带 seek table,OpenLogs里zstd.NewSeekableReader靠它实现压缩文件的随机读——UI 日志查看器按 offset 翻页不能退化成"从头解压到目标位置";io.Pipe+ goroutine 流式转存:DBFS 读取、压缩、对象存储写入三者用管道串起来,不把整个日志读进内存,一个 2GB 的日志也只占固定缓冲。storage.Actions.Save(filename, reader, -1)的-1表示 size 未知、由存储后端自行处理(S3 走 multipart);- 返回值是
remove函数而非立即删除:调用方(UpdateLog的收尾路径)在成功标记log_in_storage=true之后才调用remove清掉 DBFS 副本。若转存成功但标记失败,DBFS 副本仍在,读取路径(OpenLogs(inStorage))按标记决定读哪边,宁可双份不可断档。
LogRetentionDays(默认 365 天)与 RemoveLogs 则负责转存文件的最终过期(由 14.10.2 的 cleanup cron 驱动)。
读取路径(services/actions/ 的日志查看接口)按 task.LogInStorage 标记分流:OpenLogs(ctx, inStorage, filename)——false 读 DBFS,true 读 storage.Actions 并按后缀决定是否套 NewSeekableReader。注意源码里两条防御性注释:"reads the seek table, so a lazily opened object already fails here" 与 "object storage opens lazily, force a missing object to surface before the caller commits a response"——对象存储是惰性连接,必须在写响应头之前先 Seek(0) 逼出缺失错误,否则 HTTP 200 头已经发出、中途才发现对象不存在,客户端只能收到截断的响应。这个双段对用户透明。
14.8.3 artifact 双协议:v3 REST 与 v4 twirp
Gitea 同时实现两代 GitHub artifact 协议,因为 runner 版本与 actions/upload-artifact action 版本各自演化:
v3(REST)——routers/api/actions/artifacts.go,挂 /api/actions_pipeline/_apis/pipelines/...。文件头部的大段注释本身就是协议文档,描述三步:
POST .../artifacts?artifactName=...向服务端申请上传,返回上传 URL;PUT分块上传,块头带x-actions-results-md5校验与Content-Range头(断点续传语义);PATCH结束上传并合并分块。
鉴权用 ArtifactContexter:Bearer JWT(新 runner,14.9.4 的 ACTIONS_RUNTIME_TOKEN)或旧式 GITEA_TOKEN(兼容老 runner)。路由基串是常量 artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts"。
v4(twirp)——routers/api/actions/artifactsv4.go,挂 /twirp/github.actions.results.api.v1.ArtifactService,基串常量 ArtifactV4RouteBase。头注释描述的协议与 v3 完全不同:
1. CreateArtifact → 返回 signedUploadUrl(带 sig/expires 的签名 URL)
2. UploadArtifact → 对签名 URL PUT,comp=block / appendBlock / blockList
(blockList 是 XML,800MB 级文件并行乱序上传后靠它恢复顺序)
3. FinalizeArtifact → 提交完成
4. ListArtifacts → 列出
5. GetSignedArtifactURL → 下载时换取签名 URL
v4 的关键差异是上传数据不走认证的 twirp 通道:CreateArtifact 换取一次性签名 URL,随后的数据 PUT 是无认证请求(凭 sig+expires 授权),这样大文件传输不占用 Connect 会话、也便于走 CDN/反代直传。签名由服务端生成、下载侧 GetSignedArtifactURL 同理——services/actions/artifacts.go 的 IsArtifactV4 判定(ContentEncoding 含 /)与 ServeDirect 下载逻辑就在这里分叉。
ArtifactPreviewMaxSize(默认 10MB)控制 UI 里文本 artifact 的内联预览上限,超过只提供下载链接,避免渲染一个 1GB 的日志文件把浏览器打死。
14.9 secrets、variables 与 permissions
14.9.1 GetSecretsOfTask:自动 token 与 fork 隔离
models/secret/secret.go 的 GetSecretsOfTask 是任务下发前组装 secrets 的总入口,规则按优先级:
- 自动注入:
GITHUB_TOKEN与GITEA_TOKEN恒定存在,值为task.Token(14.9.4 的 JWT)——即使仓库一个 secret 都没配,CI 也能拿 token 调 API; - fork PR 隔离:PR 来自 fork 且事件不是
pull_request_target时,只给自动 token,丢弃全部用户 secret。这是 CI 安全的铁律:不可信代码不能读到仓库机密;pull_request_target是显式豁免(它在 base 分支上下文运行,GitHub/Gitea 都要求作者自行承担风险); - owner + repo 合并:组织(或个人)级 secret 与仓库级 secret 合并,同名时仓库级覆盖 owner 级;两层都走解密(secret 落库是加密存储)。
getScopedSecretsForJob 处理可复用工作流的 secret 传递,递归沿 ParentJobID 链向上解析两种写法:
secrets: inherit # 继承调用方全部 secrets
secrets: { MY_SECRET: SRC } # 显式映射:调用方的 SRC → 被调方的 MY_SECRET
命名大小写:secret 名称入库时统一转大写,比较时大小写不敏感——这复刻了 GitHub secrets.FOO 与 secrets.foo 等价的行为,代价是用户不能用大小写区分两个 secret(文档应明确这一点)。
14.9.2 variables:三级优先级
models/actions/variable.go 的 GetVariablesOfRun 头注释一行说明优先级:
// Level precedence: Repo > Org / User > Global
| 层级 | 归属 | 典型用途 |
|---|---|---|
| Global | 实例管理员([actions] 相关管理界面) | 公司代理地址、镜像源 |
| Org/User | 组织或个人 | 团队共享变量 |
| Repo | 单仓库 | 项目专属值 |
变量与 secret 的区别在明文:变量通过 vars.* 表达式引用、UI 可见;secret 加密存储、仅在任务下发时解密注入。services/actions/variables.go 的增删改都先过 secret_service.ValidateName(复用 secret 的命名校验,禁非法字符),util.NormalizeStringEOL 统一换行符——防止 Windows 上编辑的变量值带 \r\n 注入到 shell 命令里产生诡异行为。
14.9.3 permissions:workflow → 仓库 unit 的映射
services/actions/permission_parser.go 的 ExtractJobPermissionsFromWorkflow 决定 GITHUB_TOKEN 的权限面,解析顺序:job 级 permissions 优先,其次 workflow 级,都没有则返回 nil(nil 表示"未显式声明",与"显式声明为空"语义不同)。
标量形态只有两个:
permissions: read-all # 全部 unit 置 read
permissions: write-all # 全部 unit 置 write
映射形态从 perm.AccessModeNone 起步(未列出的 scope 默认无权限),逐 scope 赋值:
| scope | 映射到 unit |
|---|---|
contents | TypeCode + TypeReleases |
code | TypeCode(Gitea 扩展名) |
issues | TypeIssues |
pull-requests | TypePullRequests |
packages | TypePackages |
actions | TypeActions |
wiki | TypeWiki |
releases | TypeReleases |
projects | TypeProjects |
两处设计动机值得注意:
contents先应用(低优先级),随后其他 scope 覆盖——contents映射到 code + releases 两个 unit,若用户再单独写releases: read会覆盖 releases 那一半,这是 map 赋值顺序刻意安排的结果;- GitHub 有而 Gitea 无的 scope(
attestations、checks、deployments、id-token、discussions、pages、security-events、statuses、artifact-metadata、models)显式列为"不支持",静默忽略;而未知 scope 走setting.PanicInDevOrTesting——开发/测试环境 panic(尽早暴露拼写错误),生产环境放过(不让一个新 scope 拖垮线上)。
这个解析结果最终决定 GITHUB_TOKEN 能调哪些 API——比如 permissions: {}(显式空)时,workflow 里 git push 必然 403,因为 TypeCode 是 none。
14.9.4 运行时 JWT:CreateAuthorizationToken
services/actions/auth.go 为每个任务签发运行时令牌:
// HS256 签名,claims 结构 actionsClaims:
ExpiresAt = now + 1h + setting.Actions.EndlessTaskTimeout
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID) // 作用域绑定到具体 run/job
Ac: cache scope 字段
- 过期时间加了
EndlessTaskTimeout(默认 3h,14.10)的余量——否则一个合法的长任务可能在执行中途令牌先过期,日志/artifact 上传开始 401; Scp把令牌绑定到 runID:jobID,artifact 上传端点据此校验"这个令牌只能给这个 run 传这个 job 的产物"——令牌泄露也无法跨任务写入;- 该 JWT 同时用作任务下发时的
GITHUB_TOKEN值(14.9.1)与gitea_runtime_token、artifact 的ACTIONS_RUNTIME_TOKEN; TokenToTaskID提供反查,Ac字段预留 cache scope(14.1.3 已验证本版本无 cache 端点消费它)。
14.10 [actions] 配置与运维
14.10.1 配置键全表(modules/setting/actions.go + app.example.ini)
以下默认值全部来自 modules/setting/actions.go 的 loadActionsFrom 与 custom/conf/app.example.ini L3069-3111 的官方注释,逐一核对过:
| 键 | 默认值 | 说明 |
|---|---|---|
ENABLED | true | 总开关,false 时 /api/actions 路由不生效 |
DEFAULT_ACTIONS_URL | github | 表达式里 github 上下文的 API/网页 URL 指向:github(外部 GitHub)/self(本实例)/自定义 URL |
SKIP_WORKFLOW_STRINGS | [skip ci],[ci skip],[no ci],[skip actions],[actions skip] | commit message 含任一串则跳过(14.2.2) |
WORKFLOW_DIRS | .gitea/workflows,.github/workflows | 工作流目录(14.3.2) |
SCOPED_WORKFLOW_DIRS | .gitea/scoped_workflows | scoped 工作流目录,不得与 WORKFLOW_DIRS 重叠 |
MAX_RERUN_ATTEMPTS | 50 | 单 run 最大重跑次数 |
MAX_CONCURRENT_TASK_PICKS | 16 | 并发选任务信号量容量(14.6.2) |
LOG_RETENTION_DAYS | 365 | 日志保留天数 |
ARTIFACT_RETENTION_DAYS | 90 | artifact 保留天数 |
RUN_RETENTION_DAYS | 400 | run 记录保留天数 |
ZOMBIE_TASK_TIMEOUT | 10m | 僵尸任务超时(runner 领了任务后失联) |
ENDLESS_TASK_TIMEOUT | 3h | 无进展任务超时(也参与 JWT 过期时间,14.9.4) |
ABANDONED_JOB_TIMEOUT | 24h | 被放弃 job 的超时 |
ARTIFACT_PREVIEW_MAX_SIZE | 10MB | artifact 内联预览上限(14.8.3) |
LOG_COMPRESSION | zstd | 可选 none/zstd(14.8.2) |
storage 子节由 loadActionsFrom 读取两个键:actions_log(DBFS 转存目标)与 actions_artifacts(artifact 存储),它们指向 [storage] 节的对应后端。
DEFAULT_ACTIONS_URL 的三种取值直接决定 ${{ github.server_url }} 等表达式的值——自建实例通常应设为 self,否则生成的 PR 链接、run 页面链接会指向 github.com。
14.10.2 runner 侧的三个超时:zombie / endless / abandoned
services/cron/tasks_actions.go 注册了 Actions 专属的 cron 任务族:
| cron 任务 | 扫描对象 | 触发条件(超时值) | 处理 |
|---|---|---|---|
| zombie task timeout | 已派发的任务 | ZOMBIE_TASK_TIMEOUT(10m) | runner 失联后把任务标失败/释放 |
| endless task timeout | 运行中的任务 | ENDLESS_TASK_TIMEOUT(3h) | 防 runner 卡死不回报 |
| abandoned job timeout | waiting/ready 的 job | ABANDONED_JOB_TIMEOUT(24h) | 长期没人领的 job 清理 |
| schedule | ActionSchedule 表 | cron spec 到点 | 生成 schedule 事件的 run(14.10.3) |
| cleanup_actions | 过期 run/artifact/log | *_RETENTION_DAYS | 按保留期删除 |
| delete_old_actions | 更老的历史数据 | 自身配置周期 | 深度清理 |
三个超时的分工可以这样理解:zombie 管"派出去没回音"(runner 掉线),endless 管"回音不断但活不干"(任务内部死循环/等待),abandoned 管"根本没人接"(没有可用 runner 匹配 labels)。三者缺一不可——只配一个会出现另外两类任务永久悬挂,run 状态永远转不出去,用户看到的是一个永不结束的 CI 图标。
14.10.3 schedule 触发与全局注册令牌
schedule:services/actions/schedule_tasks.go 负责把工作流里的 on: schedule: - cron: ... 转成 ActionSchedule 记录。cron spec 按标准五段语法解析;CreateScheduleTaskBySpec 在到点时合成一个 schedule 事件 payload 再走 14.2 的标准 notify 流程(所以 schedule 也受"仅默认分支"规则约束,14.3.3 的 IsDefaultBranchWorkflow 把 schedule 列入其中)。handleSchedules 只为默认分支创建 schedule 记录——feature 分支上的 schedule 若也生效,每个分支都会各起一套定时任务。
全局 runner 注册令牌:initGlobalRunnerToken(services/actions/init.go)支持从环境变量注入:
GITEA_RUNNER_REGISTRATION_TOKEN // 令牌明文
GITEA_RUNNER_REGISTRATION_TOKEN_FILE // 或从文件读
// 校验:长度必须 ≥ 32 字符,否则拒绝启动
这服务于容器化部署:注册令牌不必先落进数据库/UI,管理员在编排系统里注入即可完成 runner 的 bootstrap。32 字符下限是硬校验——弱令牌等同于把 CI 执行权拱手让人。此外 CLI gitea actions generate-runner-token --scope {owner}[/{repo}] 可以按 owner/repo 粒度生成限定 scope 的令牌(对应 14.6.2 的任务可见性过滤)。
14.11 全链路时序:从 push 到日志归档
把 14.2–14.8 串成一条完整链路。下图覆盖"用户 push → 创建 run → runner 领任务 → 执行回报 → 状态写入 → 日志转存"的主干,省略了审批与 schedule 两条支线:
sequenceDiagram
autonumber
participant U as 用户/git push
participant N as actionsNotifier<br/>(services/actions/notifier.go)
participant D as DetectWorkflows<br/>(modules/actions/workflows.go)
participant DB as 数据库<br/>action_run/job/task
participant R as act_runner<br/>(外部进程)
participant API as Connect API<br/>(routers/api/actions)
participant CS as commit status<br/>(services/actions/commit_status.go)
U->>N: push 事件 (notify 总线)
N->>N: 防循环 / 空仓库 / [skip ci] 检查
N->>D: DetectWorkflows(doer, repo, commitID, event)
D-->>N: workflows / schedules / filtered / invalid
N->>DB: 事务: run + attempt + jobs (InsertRun)
N->>DB: IncreaseTaskVersion()
N->>CS: filtered 工作流补写 Skipped 状态
loop runner 轮询
R->>API: FetchTask (header: UUID + token)
API->>API: withRunner 拦截器: 常量时间 token 校验
API->>DB: TryPickTask (信号量 16 / keyset 100 / 乐观锁 claim)
DB-->>API: runnerv1.Task (payload, context, secrets, vars, needs)
API-->>R: Task (job if: 已改写为 always())
end
R->>API: UpdateLog (DBFS 追加, offset==0 校验)
R->>API: UpdateTask (状态 + outputs ≤1MB)
API->>DB: UpdateTaskByState (job → 成功/失败)
API->>CS: CreateCommitStatusForRunJobs<br/>ctx = "display / job (event)", hash 混入 workflowID
API->>DB: EmitJobsIfReadyByRun → actions_ready_job 唯一队列
DB-->>API: 后继 job ready → 再次 FetchTask
R->>API: UpdateTask (task done)
API->>DB: TransferLogs: DBFS → zstd → storage.Actions
Note over API: artifact: v3 REST 或 v4 twirp<br/>(签名 URL 直传)
几个读图要点:
- 步骤 9 的信号量、keyset 分页、乐观锁三件套(14.6.2)是唯一可能发生多 runner 竞争的地方,其余步骤都由单机 web 进程串行完成;
- 步骤 11 的
UpdateLog与步骤 13 的UpdateTask是两个独立调用——日志流与状态流解耦,日志洪峰不会阻塞状态推进; - 步骤 14 的 commit status 写入发生在状态落库之后,保证 PR 页面刷新时看到的状态一定已持久(先写外部、后写内部会导致失败时状态凭空消失);
- 步骤 16 的日志转存由"任务结束"事件驱动,与 runner 无关——runner 挂掉不影响转存(cron 的 zombie 处理兜底)。
14.12 包注册表总览:一个入口,二十三种协议
14.12.1 路由结构与 CommonRoutes
包注册表的 HTTP 入口有三个(14.1.4):
/api/packages/*→packages_router.CommonRoutes():除 OCI 外的全部协议;/v2/*→containers_router.ContainerRoutes():OCI/Docker,必须根路径;- Web UI 走
/user/{name}/packages等页面路由(routers/web/web.go,非本章重点)。
routers/api/packages/api.go 的 CommonRoutes() 是一张大而平的路由表,每个生态的端点挂在自己的前缀下。以下为原文节选(保留结构,省略中间协议):
// CommonRoutes provide endpoints for most package managers (except containers - see below)
// These are mounted on `/api/packages` (not `/api/v1/packages`)
func CommonRoutes() *web.Router {
r := web.NewRouter()
r.AfterRouting(context.PackageContexter())
verifyAuth(r, []auth.Method{
&auth.OAuth2{},
&auth.Basic{},
&nuget.Auth{},
&Auth{},
&chef.Auth{},
}, verifyAuthOptions{})
r.Group("/{username}", func() {
r.Group("/alpine", func() {
r.Get("/key", alpine.GetRepositoryKey)
r.Group("/{branch}/{repository}", func() {
r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile)
r.Group("/{architecture}", func() {
r.Methods("HEAD,GET", "/APKINDEX.tar.gz", alpine.GetRepositoryFile)
...
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/cargo", func() {
r.Group("/api/v1/crates", func() {
r.Get("", cargo.SearchPackages)
r.Put("/new", reqPackageAccess(perm.AccessModeWrite), cargo.UploadPackage)
r.Group("/{package}", func() {
r.Group("/{version}", func() {
r.Get("/download", cargo.DownloadPackageFile)
r.Delete("/yank", reqPackageAccess(perm.AccessModeWrite), cargo.YankPackage)
r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), cargo.UnyankPackage)
})
r.Get("/owners", cargo.ListOwners)
})
})
r.Get("/config.json", cargo.RepositoryConfig)
r.Get("/1/{package}", cargo.EnumeratePackageVersions)
r.Get("/2/{package}", cargo.EnumeratePackageVersions)
// Use dummy placeholders because these parts are not of interest
r.Get("/3/{_}/{package}", cargo.EnumeratePackageVersions)
r.Get("/{_}/{__}/{package}", cargo.EnumeratePackageVersions)
}, reqPackageAccess(perm.AccessModeRead))
// … chef / composer / conan / conda / cran / debian / go / generic /
// helm / maven / npm / nuget / pub / pypi / rpm / rubygems /
// swift / terraform / vagrant 同构,各自委托 routers/api/packages/<proto>
...
})
return r
}
三处结构值得注意:
- 所有协议组都以
reqPackageAccess(perm.AccessModeRead)收尾——组内任何未显式再标 write 的端点默认要求读权限,写端点(PUT/POST/DELETE)逐个加reqPackageAccess(perm.AccessModeWrite)。权限标注是逐端点的,不是逐协议的; verifyAuth的认证方法集合:OAuth2、Basic、NuGet 的特殊 Basic 变体(NuGet 客户端的明文密码传递方式不同)、通用 access tokenAuth、Chef 的认证变体,外加可选的反向代理认证(EnableReverseProxyAuth)。一个包 API 要同时伺候五种客户端的认证习惯;- cargo 的 dummy 路由:
/1/{package}、/3/{_}/{package}、/{_}/{__}/{package}——cargo 协议把版本按 URL 语义分成若干类(/1/是精确版本、/2/是兼容版本、/3/是本仓库构建),Gitea 不做语义区分,用占位路径全部接到同一个EnumeratePackageVersions,注释直言 "Use dummy placeholders because these parts are not of interest"。这是"兼容协议外壳、简化内部语义"的典型取舍。
整体上,每个前缀对应 routers/api/packages/<protocol>/ 下的一个处理器包,而编解码逻辑不在 routers 里——在 modules/packages/<protocol>/,路由器只做"HTTP ↔ 领域对象"的翻译,交给 services/packages 落库。
14.12.2 协议支持矩阵
下表把 models/packages/package.go 的 23 个 Type 常量与路由、客户端命令对齐:
| # | 协议 | Type 常量 | 路由前缀 | 典型客户端 |
|---|---|---|---|---|
| 1 | Alpine | TypeAlpine | /api/packages/alpine | apk add --repository |
| 2 | Arch | TypeArch | /api/packages/arch | pacman (repo-add) |
| 3 | Cargo | TypeCargo | /api/packages/cargo | cargo publish |
| 4 | Chef | TypeChef | /api/packages/chef | chef push |
| 5 | Composer | TypeComposer | /api/packages/composer | composer config repositories |
| 6 | Conan | TypeConan | /api/packages/conan | conan remote add |
| 7 | Conda | TypeConda | /api/packages/conda | conda config --add channels |
| 8 | CRAN | TypeCRAN | /api/packages/cran | install.packages(repos=) |
| 9 | Debian | TypeDebian | /api/packages/debian | deb [trusted=yes] ... |
| 10 | Go | TypeGo | /api/packages/{owner}/go | goproxy (GOPROXY=...) |
| 11 | Generic | TypeGeneric | /api/packages/{owner}/generic | curl 直传 |
| 12 | Helm | TypeHelm | /api/packages/{owner}/helm | helm repo add |
| 13 | Maven | TypeMaven | /api/packages/maven | <repository> in pom.xml |
| 14 | npm | TypeNpm | /api/packages/npm | npm publish / .npmrc |
| 15 | NuGet | TypeNuGet | /api/packages/nuget | dotnet nuget add source |
| 16 | Pub | TypePub | /api/packages/pub | dart pub |
| 17 | PyPI | TypePypi | /api/packages/pypi | pip install --index-url / twine |
| 18 | RPM | TypeRPM | /api/packages/rpm | dnf config-manager |
| 19 | RubyGems | TypeRubyGems | /api/packages/rubygems | gem push --key |
| 20 | Swift | TypeSwift | /api/packages/swift | SwiftPM registry |
| 21 | Terraform | TypeTerraform | /api/packages/terraform | terraform login |
| 22 | Vagrant | TypeVagrant | /api/packages/vagrant | vagrant cloud |
| 23 | OCI/Container | TypeContainer | /v2(根路径) | docker login/push/pull |
Type 的 Name() 与 SVGName() 分别给 UI 文案与图标(SVG 图标按类型名约定取值)。TypeList 是给管理界面遍历用的全集——新增一个协议的完整步骤是:Type 常量 + Name() 分支 + modules/packages/<proto> 编解码器 + routers/api/packages/<proto> 路由 + api.go 挂载 + (可选)下载计数/搜索集成。
OCI 单独占第 23 号且走不同前缀,原因已在 14.1.4 讲过:协议本身把 /v2 写死为根路径探测端点。
14.12.3 一个协议的完整上传路径:以 npm 为例
虽然各生态协议差异巨大,但穿过 routers 层后收敛到同一个服务接口。routers/api/packages/npm/npm.go 的 UploadPackage 是最完整的样本(原文节选):
func UploadPackage(ctx *context.Context) {
// about the npmjs and GitHub Packages limit, fits base64 tarballs up to ~200 MB
npmPackage, deprecation, err := npm_module.ParseUpload(http.MaxBytesReader(ctx.Resp, ctx.Req.Body, 256*1024*1024))
if err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
apiError(ctx, http.StatusRequestEntityTooLarge, err)
} else if errors.Is(err, util.ErrInvalidArgument) {
apiError(ctx, http.StatusBadRequest, err)
} else {
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
// `npm deprecate` reuses the publish endpoint with no `_attachments`.
if deprecation != nil {
deprecatePackage(ctx, deprecation)
return
}
repo, err := repo_model.GetRepositoryByURLRelax(ctx, npmPackage.Metadata.Repository.URL)
if err == nil {
canWrite := repo.OwnerID == ctx.Doer.ID
if !canWrite {
perms, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
...
canWrite = perms.CanWrite(unit.TypePackages)
}
if !canWrite {
apiError(ctx, http.StatusForbidden, "no permission to upload this package")
return
}
}
buf, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(npmPackage.Data))
...
defer buf.Close()
pv, _, err := packages_service.CreatePackageAndAddFile(
ctx,
&packages_service.PackageCreationInfo{
PackageInfo: packages_service.PackageInfo{
Owner: ctx.Package.Owner,
PackageType: packages_model.TypeNpm,
Name: npmPackage.Name,
Version: npmPackage.Version,
},
SemverCompatible: true,
Creator: ctx.Doer,
Metadata: npmPackage.Metadata,
},
&packages_service.PackageFileCreationInfo{
PackageFileInfo: packages_service.PackageFileInfo{
Filename: npmPackage.Filename,
},
Creator: ctx.Doer,
Data: buf,
IsLead: true,
},
)
if err != nil {
switch err {
case packages_model.ErrDuplicatePackageVersion:
apiError(ctx, http.StatusConflict, err)
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
apiError(ctx, http.StatusForbidden, err)
default:
apiError(ctx, http.StatusInternalServerError, err)
}
return
}
...
ctx.Status(http.StatusCreated)
}
逐段对上文的各机制:
http.MaxBytesReader(..., 256*1024*1024):HTTP 层先截断,超限的 body 连解析都不做,直接 413。注释说明 256MB 是"对标 npmjs/GitHub Packages、容纳 ~200MB base64 tarball"的取值——协议兼容的尺寸上限不是随手写的,是要与上游行为对齐的;npm deprecate复用 publish 端点:npm 协议把"弃用"也塞进PUT(无_attachments),Gitea 靠"解析出 deprecation 且无附件"分流——协议设计的锅由服务端消化;- 仓库关联的额外权限:若 package.json 声明了
repository.url且能解析到本实例的仓库,上传者必须对该仓库有TypePackages写权限。这防的是"任意用户把别人的仓库名写进 package.json 借道绑定"; CreateHashedBufferFromReader:流式计算 sha1/sha256/sha512/md5 四哈希 + size,正是 14.13.1 blob 唯一键的输入——哈希在进入服务层之前就算好,服务层只做查重不做重算;IsLead: true:npm 的 tarball 是该版本的主文件,标记后 14.13.3 的下载计数只数它;- 错误映射表:
ErrDuplicatePackageVersion → 409、三种配额错误 → 403、其余 → 500。协议错误格式由apiError→helper.ProcessErrorForUser翻译(npm 要 JSON、debs 要纯文本……各协议的错误方言在自己的 router 里处理); - 落库成功后的两步:写 dist-tags(npm 的
latest指针)、可选SetRepositoryLink绑定仓库——这两步失败只报错不回滚版本本身(tag 可补,版本是主体)。
这个分层的意义是协议无关的逻辑只写一遍:配额、去重、权限、下载计数全在 services/models 层,新增协议只需解决"怎么把字节流解析成 packages_module.Package"。
14.13 包存储模型:Package / Version / Blob 三层与内容寻址
14.13.1 三层对象与去重
数据库模型是三层:
Package:(owner_id, type, lower_name)唯一——同一个 owner 下同协议同名归为一个包(models/packages/package.go的UNIQUE(s))。TryInsertPackage撞唯一键返回ErrDuplicatePackage,由服务层转为"取已有包继续加版本";IsInternal标记内部包(不出现在公开列表);PackageVersion:包下的一个版本(version 字符串 + 结构化 major/minor/patch + 是/否 prerelease);PackageFile→PackageBlob:版本包含 N 个文件,每个文件指向一个 blob。
Blob 是全局去重的:models/packages/package_blob.go 的 PackageBlob 表对四个哈希列 + size 建唯一约束——
// 列:sha1 / sha256 / sha512 / md5 + size
func GetOrInsertBlob(ctx, ...) // 先按 size+4hash 查,命中即复用
同一篇 tarball 被十个包引用只存一份字节。GetOrInsertBlob 的并发处理与包 upsert 同款:INSERT 撞唯一键后重查。更进一步,services/packages.GetOrSavePackageBlob 有一道诚实性校验:blob 记录已存在时,会去内容存储里核对实际文件 size,不一致则重新写入——防"数据库说有、对象存储里其实是空文件/半截文件"的悬空引用(通常由早先一次失败的写入留下)。写入失败则回滚删除刚插入的 blob 记录,保持两层一致。
FindExpiredUnreferencedBlobs 用 LEFT JOIN package_file IS NULL 找出"记录还在但已无任何文件引用"的 blob,配合 created_unix 宽限期交给 cleanup cron 真正删除——这对应 RemovePackageVersion 里那条 HINT 注释 PACKAGE-DEFER-STORAGE-DELETE:删除版本时先删引用,字节延迟删,避免删除事务里同步做慢 IO,也给"误删后立刻重传"留出复用窗口。
14.13.2 内容寻址路径与 ServeDirect
modules/packages/content_store.go 把 blob 键映射为存储路径:
func KeyToRelativePath(key string) string {
// sha256 全量 → "aa/bb/<sha256>"
}
前两位/次两位十六进制做两级目录,单目录不会因百万级 blob 而退化——这是 git 对象库、Docker registry 同款的内容寻址布局。路径由内容哈希决定意味着同一内容永远同一路径,天然幂等。
ShouldServeDirect/GetServeDirectURL 决定下载是否走 302 重定向:当存储后端支持预签名 URL(S3/MinIO/OSS 等)时,OpenBlobForDownload 直接 302 到预签名地址,字节流不过 Gitea 进程——大文件下载的带宽成本转移到对象存储。不支持的后端(本地磁盘)则由 Gitea 流式读转发。这个开关完全由 storage 后端能力决定,上层代码无感知。
14.13.3 下载计数:只数"主文件"的一次 GET
OpenBlobForDownload 里有段看似不起眼的条件:
if pf.IsLead && req.Method == http.MethodGet {
IncrementDownloadCounter(...)
}
三个讲究:
pf.IsLead:一个版本往往有多个文件(wheel + metadata、tarball + signature),只给"主文件"计数——否则下载一次会被数成 N 次;GET才计数:HEAD探测(很多客户端先 HEAD 拿 size)不计数;- 计数发生在成功打开之后而不是请求进来时,避免 404/403 也被计入。
计数落在 PackageDownload 相关列,UI 上"下载量"排序与统计都基于它。这个设计直接决定了榜单的可信度:npm 客户端一次 npm install 可能触发 metadata GET + tarball GET + 若干 HEAD,全计会把下载量灌水数倍。
14.13.4 配额:三种超额错误
services/packages/packages.go 的 CheckSizeQuotaExceeded/CheckCountQuotaExceeded 在每个文件写入前执行,返回三种错误:
| 错误 | 触发配置 | 语义 |
|---|---|---|
ErrQuotaTypeSize | LIMIT_SIZE_<TYPE> | 单个文件超该协议的大小上限 |
ErrQuotaTotalSize | LIMIT_TOTAL_OWNER_SIZE | owner 的全部包总字节超限 |
ErrQuotaTotalCount | LIMIT_TOTAL_OWNER_COUNT | owner 的总包数超限 |
site admin 跳过全部配额——管理员的上传不该被自己设的规则挡住。[packages] 节的 23 个 LIMIT_SIZE_* 键与 LIMIT_TOTAL_OWNER_SIZE 默认 -1(不限),mustBytes 支持 "512 MiB" 这类人类可读写法(modules/setting/packages.go)。配额检查放在服务层而非路由器层,保证所有协议(含 OCI 的 /v2)走同一套规则——OCI 上传分块进行时,配额在每个 blob 落盘前判断,一个超大镜像无法靠分块绕过总量限制。
14.14 包权限模型
14.14.1 reqPackageAccess:token scope 强制
routers/api/packages/api.go 的 reqPackageAccess(accessMode) 是所有包 API 的权限中间件,四条规则:
- token scope 强制:携带 access token 的请求,scope 必须包含
ReadPackage(读)或WritePackage(写)/All。一个只给了ReadRepository的 token 不能借道包 API 写包——scope 最小权限在包子系统同样成立; PublicOnlytoken:scope 带PublicOnly的 token 只能访问公开包,服务端额外检查 owner 可见性;- site admin 旁路:实例管理员直接放行;
- 其余情况交给
determineAccessMode(下一节)算出的AccessMode与所需accessMode比较。
第 1 条常被忽略但很关键:包管理器的凭证(~/.netrc、.npmrc 里的 token)长期驻留在开发者机器上,scope 限制让这些长期凭证即便泄露也只暴露包读写面,而不是整个 API。
14.14.2 determineAccessMode:组织、可见性与 ghost
services/context/package.go 的 determineAccessMode(ctx, pkgOwner, doer) 是包可见性的总判官,分支如下:
// 0. RequireSignInViewStrict 且 doer 为空/ghost → None
// doer 存在但被禁用(!IsActive / ProhibitLogin)→ None
if pkgOwner 是组织 {
// 1. 登录用户:GetOrgUserMaxAuthorizeLevel 求组织级授权级别
// 2. 若结果 < Write:遍历用户在该组织的所有 team,
// 取 t.UnitAccessMode(unit.TypePackages) 的最大值
// —— org 成员最低是 Read,team 可以提升到 Write/Owner
// 3. 结果为 None 且组织对 doer 可见(HasOrgOrUserVisible)→ Read
} else {
// 1. doer == owner → Owner
// 2. owner 公开可见 → Read;limited 且 doer 非 restricted → Read
// 3. 匿名/ghost:owner 公开 → Read
}
四个边界:
- 组织权限取 team 并集的最大值而不是默认 team 权限——一个用户在 team A 只读、team B 可写,最终对包可写。这与第 5 章仓库权限的 team 模型一致;
restricted用户:limited 可见性的 owner 对 restricted 用户不降级放行——restricted 账号(常见于防滥用注册)只能看 public;- ghost 用户:匿名请求在内部被表示为 ghost(
user_model.NewGhostUser()),规则退化为"owner 是否公开"; RequireSignInViewStrict一刀切:整个实例强制登录时,匿名连 public 包也读不到,直接 None。
14.14.3 OCI 的匿名 token:ghost 发签
OCI 协议的鉴权流程特殊在 Docker 客户端的 token 模型(RFC 6749 风格 bearer):
- 客户端请求
/v2/...无凭据 →ReqContainerAccess中间件发现ctx.Doer为空(或 strict 模式下的 ghost)→APIUnauthorizedError返回 401 +WWW-Authenticate: Bearer realm=".../v2/token"(14.1.4 讲过为何是根路径);owner 非公开或 strict 模式时追加一条WWW-Authenticate: Basic realm="Gitea Container Registry"——这是给container login -u这类 Basic 认证客户端的兼容分支; - 客户端带 Basic 凭据 GET
/v2/token→Authenticate:- 匿名且非 strict → 用 ghost 用户签发 token(
packages_service.CreateAuthorizationToken(NewGhostUser(), scope))——anonymous pull 因此有合法 token,权限仍按 ghost 规则收敛到"仅 public"; - 已认证 → 校验 scope 含
ReadPackage/WritePackage/All,不含则 401;
- 匿名且非 strict → 用 ghost 用户签发 token(
- 客户端带 Bearer token 访问 blobs → 服务端验签(JWT,同 14.9.4 的机制,scope 限定包读写)。
AuthenticateNotImplemented 故意返回 404 而非 405——OAuth 端点是规范里"可选"的,405 会让某些客户端当作"协议损坏"而放弃,404 则让它回落到 Basic/Bearer 常规流程。这类兼容性小动作在各协议处理器里还有不少,是实现"多家客户端都能用"的实际代价。
14.14.4 blob 跨包可见性:IsBlobAccessibleForUser
OCI 的 mount(跨仓库挂载 blob,PostBlobsUploads)需要判断"发起者能否复用另一个仓库里已有的 blob":
accessible, err := packages_model.IsBlobAccessibleForUser(ctx, blob.Blob.ID, ctx.Doer)
if accessible {
mountBlob(...) // 不传字节,直接建立引用 —— registry 间/仓库间零拷贝
}
复用 blob 不传字节是 registry 性能的基石(docker push 遇到已有层秒过)。但复用不能越权:如果目标 blob 属于一个 doer 无权读的私有包,就拒绝 mount、退化为完整上传。Gitea 用 IsBlobAccessibleForUser 把"内容寻址导致的物理共享"与"权限决定的逻辑可见"分开——哈希相同 ≠ 你可以引用它。
14.15 源码级问答
Q1:为什么 runner 不用 WebSocket/gRPC 推送任务,而要轮询 FetchTask?
轮询 + tasksVersion 版本号(14.6.2)把"新任务通知"简化成一个单调递增整数的比较。推送模型需要服务端维护长连接、处理连接断开后的任务重派语义;拉模型下,runner 失联的语义天然清晰——没人拉就没有任务被领,配合 zombie cron 收尸。代价是任务延迟 = 轮询间隔,用 IncreaseTaskVersion 在任务就绪时立刻唤醒正在等待的长轮询(FetchTask 在版本无变化时保持挂起直到超时/新版本),实际延迟接近推送而复杂度远低。
Q2:DetectWorkflows 为什么要分开 filtered 桶,直接不输出不行吗?
不行。required check 的分支保护要求"该 check 出现过"(14.4.3)。一个只改文档的 PR 若因 paths-ignore 导致 build check 完全不出现,合并按钮会永久禁用——用户被迫加空提交骗 check 跑。filtered 桶 + Skipped 状态补写消除这个死角,这是三态设计存在的全部理由。
Q3:为什么 job 的 if: 要在服务端和 runner 各求值一次,还特地改写成 always()?
其实只求值一次才对。服务端求值决定 job 是否派发(decideJobIf),runner 若再求原始条件,两次求值的上下文快照不同(needs 状态、时间敏感表达式),可能分歧。改写为 always()(runnerWorkflowPayload)让 runner 的第二次求值恒真,判定权唯一化。改写而不是删掉条件,是因为 runner/act 引擎要求 job 结构完整。
Q4:GITHUB_TOKEN 是数据库里的长期凭证吗?
不是。它是 14.9.4 的短期 JWT(HS256,过期时间 = now + 1h + EndlessTaskTimeout),Scp 绑定具体 runID:jobID,任务结束即失去意义。数据库里存的只是 runner 的 token hash+salt(14.6.4)。自动注入它的目的正是"CI 永远有可用凭证,但凭证生命周期与任务一致"。
Q5:同一 blob 内容出现在两个私有包里,A 包的读者能通过猜哈希下载 B 包的文件吗?
不能,两道闸:下载必须经 PackageFile 引用链定位(URL 里是 owner/type/name/version 路径,不是裸哈希),权限在 determineAccessMode 层按包算;OCI mount 场景由 IsBlobAccessibleForUser 单独把关(14.14.4)。物理共享一份字节不影响逻辑权限隔离。
Q6:为什么删版本不立刻删对象存储里的字节?
RemovePackageVersion 的 PACKAGE-DEFER-STORAGE-DELETE HINT(14.13.1):删除事务里做慢 IO 会拖长事务、且网络存储删除可能失败导致事务回滚困难。延迟到 cleanup_packages cron 用"无引用 + 过创建宽限期"判据清理,还顺带覆盖了"删了马上重传同内容"的场景——宽限期内 blob 还在,重传直接命中复用。
Q7:on: push 的工作流在 force push 后还能匹配吗?事件的 paths 用什么算?
匹配走 matchPushEvent 的 branch/tag/paths 计数逻辑(14.3.4),paths 输入是本次 push 的全部变更文件列表(来自 webhook payload 的 commits 文件聚合)。force push 的语义由 payload 决定——Gitea push webhook 提供 before/after 与提交列表,匹配逻辑不关心是否 force,只关心"事件声明的选择器 vs 本次变更集"。删除分支/标签事件同样进 push 匹配,但 branches/tags 的匹配对象是被删引用名。
Q8:新增一种包协议要动几层?
六处(14.12.2 末):models/packages 加 Type 常量与 Name()/SVGName() 分支、modules/packages/<proto> 写编解码器、routers/api/packages/<proto> 写端点、api.go 的 CommonRoutes() 挂路由、(可选)TypeList UI 集成与限额键 LIMIT_SIZE_<PROTO>。存储、配额、权限、计数零改动——这正是 14.12.3 分层的回报。
Q9:workflow_run 事件为什么有"递归 5 层"限制?在哪实现?
A 触发 B(B on: workflow_run)、B 触发 C……可能形成无限链或深度爆炸。skipWorkflows(14.2.2 第 4 步)统计当前事件已嵌套的 workflow_run 深度,超过 5 层直接跳过。上限硬编码,语义是"链式编排够用(典型 2-3 层:build → e2e → notify),恶意/失误配置止血"。
Q10:一个仓库可以只禁 Actions 不禁其他功能吗?
可以,两个粒度:实例级 [actions] ENABLED=false;仓库级关闭 TypeActions unit(notify() 第 2 步检查)。包注册表同理有 [packages] ENABLED 与 TypePackages unit。两者相互独立——只关 Actions 时包 API 照常,反之亦然。
14.16 [packages] 配置与本章小结
14.16.1 [packages] 配置节(modules/setting/packages.go)
| 键 | 默认值 | 说明 |
|---|---|---|
ENABLED | true | 包注册表总开关 |
LIMIT_TOTAL_OWNER_COUNT | -1(不限) | 单 owner 包总数上限 |
LIMIT_TOTAL_OWNER_SIZE | -1(不限) | 单 owner 全部包总字节上限 |
LIMIT_SIZE_<TYPE>(23 个) | -1(不限) | 按协议的单文件大小上限(LIMIT_SIZE_ALPINE … LIMIT_SIZE_VAGRANT,Terraform 键为 LIMIT_SIZE_TERRAFORM_STATE),mustBytes 支持 "512 MiB" 写法 |
DEFAULT_RPM_SIGN_ENABLED | false | RPM 上传是否默认签名 |
[packages] STORAGE 子节 | 本地磁盘 | 内容存储后端(经 getStorage(rootCfg, "packages", ...) 加载) |
键数核对:loadPackagesFrom 中 mustBytes 调用共 24 次 = 1 个 LIMIT_TOTAL_OWNER_SIZE + 23 个 LIMIT_SIZE_*,后者与 14.12.2 的 23 种协议类型一一对应(含 OCI 的 LIMIT_SIZE_CONTAINER;Terraform 的键名因语义收窄为 LIMIT_SIZE_TERRAFORM_STATE,不是简单的 LIMIT_SIZE_TERRAFORM)。
与 [actions] 一样,配额默认全开(-1)意味着开箱即用不设防——公网实例应当显式配置 LIMIT_TOTAL_OWNER_SIZE 与各常用协议的 LIMIT_SIZE_*,否则一个注册接口暴露的实例可以被当成无限网盘。一个实现细节:mustBytes 里值非法或溢出 math.MaxInt64 时**静默回落为 -1(不限)**而不是报错拒绝启动——配置写错的效果是"限额意外失效",运维排查时应优先怀疑这里。
14.16.2 两个子系统的对照
| 维度 | Gitea Actions | Package Registry |
|---|---|---|
| 入口路由 | /api/actions、/api/actions_pipeline、/twirp | /api/packages、/v2 |
| 外部对端 | act_runner(Connect 协议,主动拉取) | 各生态官方客户端(标准 HTTP 协议) |
| 服务端角色 | 调度、状态机、签发 JWT | 协议实现、去重存储、权限判官 |
| 共享基建 | notify_service 事件总线、commit status、[storage] 后端 | 同左(storage.Packages) |
| 重活归属 | runner 进程(执行) | 客户端(构建/推送)+ 对象存储(分发) |
| 权限抓手 | secrets fork 隔离 + JWT scope + permissions→unit | token scope + determineAccessMode + blob 可达性 |
| 保留策略 | *_RETENTION_DAYS + 三个任务超时 cron | cleanup_packages 延迟删除 cron + 配额 |
两者的共同设计哲学是:服务端永远不信任对端(runner 要常量时间验 token、artifact 要签名 URL、包上传要配额与哈希校验),也永远不替对端干活(执行交给 runner、字节分发交给对象存储 ServeDirect)。这让 Gitea 主进程的负载与 CI/包流量解耦——流量越大,扩 runner 和对象存储即可,web 进程只涨状态机操作。
14.16.3 与前文的衔接
- 第 8 章 8.11.3 的 commit status 合并闸门:本章 14.7 给出了写入方的完整实现(上下文命名、hash 混 workflowID、filtered 补写 Skipped);
- 第 12 章的 notify 事件总线:14.2 的
actionsNotifier是其最重要的订阅者之一,PackageCreate/Delete事件同时喂给了 Actions(registry_package)与外部 webhook; - 第 5 章的权限/unit 模型:
permissions解析(14.9.3)与包权限(14.14)最终都落到unit.AccessMode这同一个原语上。
至此,Gitea 的"开发者闭环"——代码托管(前 13 章)、CI 验证(14.1–14.11)、产物分发(14.12–14.15)——在本章合拢。第 15 章将转向 Gitea 的可扩展性与未来演进。