Gitea源码深度剖析-第14章 Gitea Actions(CI/CD)与包注册表(Package Registry)

1 阅读42分钟

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[&#34;act_runner<br/>Connect 客户端 / act 执行引擎&#34;]
        CLIENT[&#34;各生态官方客户端<br/>docker / npm / pip / cargo ...&#34;]
    end

    subgraph routers[&#34;routers 层(HTTP 端点)&#34;]
        RA[&#34;routers/api/actions/<br/>runner.go · artifacts.go · artifactsv4.go&#34;]
        RP[&#34;routers/api/packages/<br/>api.go · container/ · npm/ · pypi/ ...&#34;]
        RW[&#34;routers/web/repo/actions/<br/>(run/job/task 页面与日志流)&#34;]
    end

    subgraph services[&#34;services 层(业务编排)&#34;]
        SA[&#34;services/actions/<br/>notifier · run · task · commit_status<br/>context · auth · artifacts · cron 集成&#34;]
        SP[&#34;services/packages/<br/>packages.go · container/&#34;]
    end

    subgraph modules[&#34;modules 层(纯逻辑,无反向依赖)&#34;]
        MA[&#34;modules/actions/<br/>workflows.go · workflowpattern/<br/>jobparser/ · log.go · task_state.go&#34;]
        MP[&#34;modules/packages/<br/>npm/ pypi/ conan/ ... 编解码器<br/>content_store.go&#34;]
        SET[&#34;modules/setting/<br/>actions.go · packages.go&#34;]
    end

    subgraph models[&#34;models 层(xorm 表)&#34;]
        MODA[&#34;models/actions<br/>run · attempt · job · task · schedule · variable&#34;]
        MODS[&#34;models/secret · models/variable&#34;]
        MODP[&#34;models/packages<br/>package · version · file · blob&#34;]
    end

    RUNNER -- &#34;Connect: Register/FetchTask/UpdateTask/UpdateLog&#34; --> RA
    CLIENT -- &#34;标准协议 HTTP&#34; --> RP
    RA --> SA
    RW --> SA
    RP --> SP
    SA --> MA
    SA --> MODA
    SA --> MODS
    SP --> MP
    SP --> MODP
    SA -. &#34;notify_service 总线&#34; .-> 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 / IssueChangeMilestoneissues(带 types 过滤)后四个共用 notifyIssueChangeWithTitleOrContent 之类的辅助入口
CreateIssueComment / UpdateComment / DeleteCommentissue_comment仅默认分支(14.3.3)
NewPullRequest / PullRequestSynchronized / PullRequestChangeTargetBranch / AutoMergePullRequestpull_request(opened/synchronize/…)fork PR 走审批门(14.2.3)
PullRequestReviewpull_request_reviewapproved/changes_requested 两种 activity
PullRequestReviewRequestpull_request_review_comment
PushCommits / SyncPushCommitspush同步镜像仓库的 push 走 Sync* 变体
CreateRef / DeleteRef / SyncCreateRef / SyncDeleteRefcreate / delete
NewRelease / UpdateRelease / DeleteReleaserelease(published 等)
PackageCreate / PackageDeleteregistry_packageActions 与包注册表的交汇点(见下)
NewWikiPage / EditWikiPage / DeleteWikiPagegollum名字来自 GitHub 的历史包袱
ForkRepositoryfork仅默认分支
MigrateRepository(不直接触发工作流)迁移完成后做 schedule 清理类处理
WorkflowRunStatusUpdateworkflow_runrun 状态变化时的自反事件,递归上限 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() 函数,按顺序做七件事,每一件都是一个真实的边界情况:

  1. 防循环触发:如果触发者是 setting.Actions.ActionsUserID(即被配置为"以某用户身份执行"的那个账号),直接返回。没有这道闸,一个 push 触发的 workflow 用 GITHUB_TOKEN 再 push,会无限递归。
  2. 仓库级检查:仓库为空(无 commit)、已归档(archived)、未启用 Actions unit(TypeActions 被禁用)都直接返回。第三个检查意味着管理员可以在单个仓库粒度关掉 CI。
  3. 默认分支回退:IsDefaultBranchWorkflow(event) 为 true 的事件类型(delete、fork、issue_comment、wiki、schedule、issues、workflow_run)只在默认分支上找工作流文件——即便事件发生在别的分支,也把检出分支改回默认分支。原因很直接:这些事件的 payload 里根本没有有意义的"目标分支"。
  4. 跳过检查:skipWorkflows() 处理两类跳过——commit message 里含 setting.Actions.SkipWorkflowStrings(默认 [skip ci]、[ci skip]、[no ci]、[skip actions]、[actions skip])时跳过整个通知;workflow_run 事件递归层数超过 5 层时跳过(防 A 触发 B 触发 C……的链式爆炸,上限值是硬编码的常量)。
  5. 工作流发现:调用 DetectWorkflows(14.3)拿到匹配、计划任务、被过滤三组工作流与解析错误表。
  6. 落库与审批:buildApproveAndInsertRun 把每个匹配的工作流变成 ActionRun + ActionRunAttempt + N 个 ActionRunJob;对 fork PR 会先判断是否需要人工审批。
  7. 副作用处理: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
}

这段代码里有四个容易读漏的行为,逐一说明:

  1. 无过滤参数直接匹配:on: push 不写任何条件时零开销返回,连文件变更都不算;
  2. paths 对 tag 推送直接放行(matchTimes++):打 tag 时"本次变更文件"没有意义(tag 可能指向任意历史提交),所以声明了 paths 的工作流在 tag push 上等价于不过滤。GetFilesChangedSinceCommit(pushPayload.Before) 算的是 before..after 的差集,Before 全零(新建分支首推)时的行为由 git 层决定;算失败则整个事件降级为 detectNotApplicable——算不清就不触发,宁可漏跑不可错跑;
  3. 匹配失败不是 detectNotApplicable 而是 detectFilteredOut:事件本身是对的(push 到来了),只是被选择器过滤——这正是 14.3.1 filtered 桶的数据来源;
  4. 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 处理时会:

  1. 求值 if:(decideJobIf)——注意这与 runner 侧改写后的 always()(14.6.3)不同,这里求的是原始条件,决定 job 起始状态;
  2. 检查 concurrency 组——同组并发超限时取消旧 run(services/actions/concurrency.go);
  3. 处理 strategy.matrix——deferred matrix(矩阵里引用了 needs 输出的场景)此时无法展开,job 先落库为待展开状态,等 needs 就绪后再展开;这也是 commit status 写入时要跳过 deferred-matrix job 的原因(14.7.1);
  4. 展开 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
}

逐条对照前面的概述:

  1. 版本比较即"有没有活"的全部判据:req.Msg.TasksVersion 是 runner 上次拿到的版本,与库中不同才进选任务分支;相同则原样返回当前版本——空轮询只读不写,没有写放大;
  2. latestVersion == 0 的兜底:全新实例(或 scope 首次出现任务)版本为 0,这里自增一次并同步推进本地变量,注释直说原因:不推进的话响应里返回 0,runner 会把这当成"旧版 Gitea"(协议兼容性歧义)。这是版本号作为单调信号必须避开 0 值的细节;
  3. 禁用态在轮询里处理(第二段注释):拦截器里的 runner 是请求开始时装进 ctx 的,管理员在这期间点下"禁用"就存在竞态;所以选任务前重新从库里读一遍 freshRunner,用它的 IsDisabled 判定——禁用生效最迟在下一次轮询,无需推送通道;
  4. 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 调用做三件事:

  1. 从 header 取 protocol.UUIDHeader 与 TokenHeader;
  2. GetRunnerByUUID 查出 runner,用常量时间比较校验 token:
    util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt))
    
    常量时间比较防止通过响应耗时侧信道暴力猜测 token 前缀;
  3. 更新 last_active/last_online——但去抖:ShouldPersistLastActive/ShouldPersistLastOnline 判断距上次写库是否超过阈值。UpdateLog 是高频调用(日志流可能每秒数十次),如果每次都写数据库,心跳本身会变成写放大压力源。去抖后,活跃时间的精度略降(秒级),换来数据库的稳定。

14.7 执行回报:状态机、commit status 与 outputs

14.7.1 UpdateTask:一次回报触发的连锁反应

runner 完成任务状态变更时调 UpdateTask,服务端在 routers/api/actions/runner/runner.go 里依次做:

  1. UpdateTaskByState:按 runner 上报的状态推进 task/job/run 的状态机;
  2. outputs 校验:key 长度 ≤ 255、value 长度 ≤ 1MB(超限直接拒绝——outputs 会进数据库,无限制等于允许 runner 把内存打爆);
  3. CreateCommitStatusForRunJobs:把本次涉及的 job 状态写成 commit status(14.7.2);
  4. task done 时通知(推进 run 状态、触发 workflow_run 事件的下游);
  5. EmitJobsIfReadyByRun:本 job 完成 → 检查后继 job 是否就绪(14.5.2);
  6. 回执 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
pushHeadCommit.ID(本次 push 的头提交,不是分支头)
pull_requestPR.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 中的处理顺序:

  1. ack 去重:日志块带序号,已确认过的序号直接返回成功(runner 断线重发是常态);
  2. LogInStorage 检查:任务日志已经完成转存(14.8.2)后,再来的追加块会被拒绝为 AlreadyExists——转存后的日志是不可变压缩对象,不接受事后追加;
  3. WriteLogs 追加到 DBFS;
  4. 任务结束时 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/...。文件头部的大段注释本身就是协议文档,描述三步:

  1. POST .../artifacts?artifactName=... 向服务端申请上传,返回上传 URL;
  2. PUT 分块上传,块头带 x-actions-results-md5 校验与 Content-Range 头(断点续传语义);
  3. 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 的总入口,规则按优先级:

  1. 自动注入:GITHUB_TOKEN 与 GITEA_TOKEN 恒定存在,值为 task.Token(14.9.4 的 JWT)——即使仓库一个 secret 都没配,CI 也能拿 token 调 API;
  2. fork PR 隔离:PR 来自 fork 且事件不是 pull_request_target 时,只给自动 token,丢弃全部用户 secret。这是 CI 安全的铁律:不可信代码不能读到仓库机密;pull_request_target 是显式豁免(它在 base 分支上下文运行,GitHub/Gitea 都要求作者自行承担风险);
  3. 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
contentsTypeCode + TypeReleases
codeTypeCode(Gitea 扩展名)
issuesTypeIssues
pull-requestsTypePullRequests
packagesTypePackages
actionsTypeActions
wikiTypeWiki
releasesTypeReleases
projectsTypeProjects

两处设计动机值得注意:

  • 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 的官方注释,逐一核对过:

键默认值说明
ENABLEDtrue总开关,false 时 /api/actions 路由不生效
DEFAULT_ACTIONS_URLgithub表达式里 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_workflowsscoped 工作流目录,不得与 WORKFLOW_DIRS 重叠
MAX_RERUN_ATTEMPTS50单 run 最大重跑次数
MAX_CONCURRENT_TASK_PICKS16并发选任务信号量容量(14.6.2)
LOG_RETENTION_DAYS365日志保留天数
ARTIFACT_RETENTION_DAYS90artifact 保留天数
RUN_RETENTION_DAYS400run 记录保留天数
ZOMBIE_TASK_TIMEOUT10m僵尸任务超时(runner 领了任务后失联)
ENDLESS_TASK_TIMEOUT3h无进展任务超时(也参与 JWT 过期时间,14.9.4)
ABANDONED_JOB_TIMEOUT24h被放弃 job 的超时
ARTIFACT_PREVIEW_MAX_SIZE10MBartifact 内联预览上限(14.8.3)
LOG_COMPRESSIONzstd可选 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 timeoutwaiting/ready 的 jobABANDONED_JOB_TIMEOUT(24h)长期没人领的 job 清理
scheduleActionSchedule 表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 = &#34;display / job (event)&#34;, 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
}

三处结构值得注意:

  1. 所有协议组都以 reqPackageAccess(perm.AccessModeRead) 收尾——组内任何未显式再标 write 的端点默认要求读权限,写端点(PUT/POST/DELETE)逐个加 reqPackageAccess(perm.AccessModeWrite)。权限标注是逐端点的,不是逐协议的;
  2. verifyAuth 的认证方法集合:OAuth2、Basic、NuGet 的特殊 Basic 变体(NuGet 客户端的明文密码传递方式不同)、通用 access token Auth、Chef 的认证变体,外加可选的反向代理认证(EnableReverseProxyAuth)。一个包 API 要同时伺候五种客户端的认证习惯;
  3. 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 常量路由前缀典型客户端
1AlpineTypeAlpine/api/packages/alpineapk add --repository
2ArchTypeArch/api/packages/archpacman (repo-add)
3CargoTypeCargo/api/packages/cargocargo publish
4ChefTypeChef/api/packages/chefchef push
5ComposerTypeComposer/api/packages/composercomposer config repositories
6ConanTypeConan/api/packages/conanconan remote add
7CondaTypeConda/api/packages/condaconda config --add channels
8CRANTypeCRAN/api/packages/craninstall.packages(repos=)
9DebianTypeDebian/api/packages/debiandeb [trusted=yes] ...
10GoTypeGo/api/packages/{owner}/gogoproxy (GOPROXY=...)
11GenericTypeGeneric/api/packages/{owner}/genericcurl 直传
12HelmTypeHelm/api/packages/{owner}/helmhelm repo add
13MavenTypeMaven/api/packages/maven<repository> in pom.xml
14npmTypeNpm/api/packages/npmnpm publish / .npmrc
15NuGetTypeNuGet/api/packages/nugetdotnet nuget add source
16PubTypePub/api/packages/pubdart pub
17PyPITypePypi/api/packages/pypipip install --index-url / twine
18RPMTypeRPM/api/packages/rpmdnf config-manager
19RubyGemsTypeRubyGems/api/packages/rubygemsgem push --key
20SwiftTypeSwift/api/packages/swiftSwiftPM registry
21TerraformTypeTerraform/api/packages/terraformterraform login
22VagrantTypeVagrant/api/packages/vagrantvagrant cloud
23OCI/ContainerTypeContainer/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)
}

逐段对上文的各机制:

  1. http.MaxBytesReader(..., 256*1024*1024):HTTP 层先截断,超限的 body 连解析都不做,直接 413。注释说明 256MB 是"对标 npmjs/GitHub Packages、容纳 ~200MB base64 tarball"的取值——协议兼容的尺寸上限不是随手写的,是要与上游行为对齐的;
  2. npm deprecate 复用 publish 端点:npm 协议把"弃用"也塞进 PUT(无 _attachments),Gitea 靠"解析出 deprecation 且无附件"分流——协议设计的锅由服务端消化;
  3. 仓库关联的额外权限:若 package.json 声明了 repository.url 且能解析到本实例的仓库,上传者必须对该仓库有 TypePackages 写权限。这防的是"任意用户把别人的仓库名写进 package.json 借道绑定";
  4. CreateHashedBufferFromReader:流式计算 sha1/sha256/sha512/md5 四哈希 + size,正是 14.13.1 blob 唯一键的输入——哈希在进入服务层之前就算好,服务层只做查重不做重算;
  5. IsLead: true:npm 的 tarball 是该版本的主文件,标记后 14.13.3 的下载计数只数它;
  6. 错误映射表:ErrDuplicatePackageVersion → 409、三种配额错误 → 403、其余 → 500。协议错误格式由 apiError → helper.ProcessErrorForUser 翻译(npm 要 JSON、debs 要纯文本……各协议的错误方言在自己的 router 里处理);
  7. 落库成功后的两步:写 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 在每个文件写入前执行,返回三种错误:

错误触发配置语义
ErrQuotaTypeSizeLIMIT_SIZE_<TYPE>单个文件超该协议的大小上限
ErrQuotaTotalSizeLIMIT_TOTAL_OWNER_SIZEowner 的全部包总字节超限
ErrQuotaTotalCountLIMIT_TOTAL_OWNER_COUNTowner 的总包数超限

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 的权限中间件,四条规则:

  1. token scope 强制:携带 access token 的请求,scope 必须包含 ReadPackage(读)或 WritePackage(写)/ All。一个只给了 ReadRepository 的 token 不能借道包 API 写包——scope 最小权限在包子系统同样成立;
  2. PublicOnly token:scope 带 PublicOnly 的 token 只能访问公开包,服务端额外检查 owner 可见性;
  3. site admin 旁路:实例管理员直接放行;
  4. 其余情况交给 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):

  1. 客户端请求 /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 认证客户端的兼容分支;
  2. 客户端带 Basic 凭据 GET /v2/token → Authenticate:
    • 匿名且非 strict → 用 ghost 用户签发 token(packages_service.CreateAuthorizationToken(NewGhostUser(), scope))——anonymous pull 因此有合法 token,权限仍按 ghost 规则收敛到"仅 public";
    • 已认证 → 校验 scope 含 ReadPackage/WritePackage/All,不含则 401;
  3. 客户端带 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)

键默认值说明
ENABLEDtrue包注册表总开关
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_ENABLEDfalseRPM 上传是否默认签名
[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 ActionsPackage 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→unittoken scope + determineAccessMode + blob 可达性
保留策略*_RETENTION_DAYS + 三个任务超时 croncleanup_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 的可扩展性与未来演进。