Gitea源码深度剖析-第15章 测试体系与部署运维

6 阅读19分钟

Gitea源码深度剖析-第15章 测试体系与部署运维

第 13 章结尾承诺"第 15 章收束全书的部署与运维形态",第 1 章 1.8 节的路线图把本章定位为"测试与部署运维 | 1.3.3 的'每层可独立测试'、make help 目标 | tests/、models/unittest/、Makefile、docs/testing.md"。本章兑现这两个承诺,也兑现全书最后一块拼图:前 14 章讲的是"Gitea 如何被写出来",本章讲它如何被证明是对的(测试与 lint),以及如何被交付和维持(构建、部署、升级、监控、安全基线)。

本章有一个贯穿始终的方法论约束,来自工作区 AGENTS.md 的第一条:"Never assume, verify before claim"。因此文中每个结论都附带可复核的源码坐标(文件路径 + 关键行),凡是与常见印象不符的地方,都会给出纠正说明——事实上本章至少纠正了七处与常见印象不符的说法(集成测试没有 build tag、集成测试文件数第 1 章记 264 实测 262、本机无 make 需用 PowerShell 还原 help、gitea dump 实为 14 个 flag、gitea doctor check 默认不跑任何检查、SECRET_KEY 默认值是硬编码字符串而非随机值、PASSWORD_ITERATION_NUMBER 配置项并不存在),详见各节编号的"核实与纠正"小框。

本章代码基于 gitea-28.1.0 源码树,module 名 gitea.dev,main.go 中 Version = "development"(未注入时的开发版字符串)。


15.1 测试分层总览

Gitea 的测试体系是经典的四层结构:单元测试、集成测试、端到端测试(e2e)、前端测试,外加一层专门的 migration 测试。docs/testing.md 把它归纳为四类(原文):

There are four main kinds of tests: Unit tests, Integration tests, Migration tests, End-to-End tests.

先用本机(Windows,无 make)PowerShell 统计的真实文件数给每一层定量:

层位置规模(本源码树实测)运行入口
Go 单元测试散布于 modules/、models/、services/、routers/、cmd/、build/、modelmigration/ 的 *_test.go共 794 个(tests/ 之外):modules 325、services 182、models 142、routers 69、modelmigration 57、cmd 17、build 2make test-backend / make test-backend#TestName
集成测试tests/integration/262 个 *_test.go(另有 tests/integration/migration-test/ 1 个)make test-integration(GITEA_TEST_DATABASE 选库)
e2e 测试tests/e2e/30 个 *.test.ts(Playwright)make test-e2e(GITEA_TEST_E2E_FLAGS 透传)
前端单元测试web_src/**/*.test.ts + tools/**/*.test.ts51 + 1(vitest,browser/node 双 project)make test-frontend = pnpm exec vitest
migration 测试modelmigration/(被测对象)+ tests/integration/migration-test/57 个单测文件 + 1 个集成入口make test-migration

第 1 章 1.2.7 曾写"integration/(264 个 _test.go,含 _mock_data)",与本树实测的 262 有 2 个出入——测试文件数量随版本自然波动,以实测为准,这也正是"Never assume, verify before claim"的日常形态。

flowchart TB
    subgraph L4["④ e2e(tests/e2e/*.test.ts,30 个)"]
        E2E[&#34;Playwright 驱动真实 gitea web 进程<br/>tools/test-e2e.sh 起服务 + curl 等就绪<br/>断言语义定位器 getByRole/getByLabel&#34;]
    end
    subgraph L3[&#34;③ 集成测试(tests/integration/,262 个)&#34;]
        INT[&#34;routers.NormalRoutes() 全栈路由<br/>TestSession cookie 会话 + MakeRequest<br/>GITEA_TEST_DATABASE=sqlite/mysql/pgsql/mssql&#34;]
    end
    subgraph L2[&#34;② 服务与模型层(models/、services/ 单测)&#34;]
        SVC[&#34;unittest.MainTest 起 sqlite 内存库<br/>fixtures 注入 + AssertExistsAndLoadBean<br/>可直接测 services,绕开 routers&#34;]
    end
    subgraph L1[&#34;① 纯逻辑单元(modules/、routers/ 单测)&#34;]
        MOD[&#34;queue/router/setting 等纯 Go 单测<br/>无数据库,sub-2s 内跑完&#34;]
    end
    L1 --> L2 --> L3 --> L4

15.1.1 单元测试的分布规律

794 个单测文件的分布(modules 325 > services 182 > models 142 > routers 69)不是随机的,它和第 1 章 1.3 节的分层规则严格同构:越靠近纯逻辑的层,测试越多、越快;越靠近 IO 的层,越要靠集成测试兜底。modules/ 承载队列、缓存、日志、配置、Git 封装等可独立验证的逻辑,所以单测密度最高;routers/ 的 handler 大量依赖 context.Context + 数据库,很多逻辑被推给 services/,于是 services 层既有 182 个单测文件,也是集成测试的主战场。

需要数据库的单测通过 models/unittest 包的 MainTest 挂载 sqlite(详见 15.2),不需要数据库的直接裸写 func TestXxx(t *testing.T)。Makefile 中 GO_TEST_PACKAGES 的定义决定哪些包会被 test-backend 扫过:

GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list gitea.dev/modelmigration/...) gitea.dev/tests/integration/migration-test gitea.dev/tests gitea.dev/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/))

注意 filter-out 的四个名单:modelmigration/...、tests/integration/migration-test、tests、tests/integration 被刻意排除在 test-backend 之外——migration 测试和集成测试各有专属 target(test-migration、test-integration),避免与单元测试混跑时共享数据库互相踩踏。

15.1.2 集成测试如何选库,以及一个需要纠正的误解

集成测试只有一份代码,靠环境变量 GITEA_TEST_DATABASE 切换四种数据库。docs/testing.md 的说明:

GITEA_TEST_DATABASE specifies the database to test with, options are sqlite, mysql, pgsql, mssql, or sqlite3 for a different mode for sqlite.

核实与纠正(第 1 处:build tag 之谜):一种常见说法是"集成测试用 build tag 隔离"。实测 grep go:build tests/integration/*.go 无任何匹配——集成测试没有任何 build tag,它的隔离机制有三层:

  1. 包路径隔离:GO_TEST_PACKAGES 把 tests/integration 从单元测试名单里 filter-out;
  2. 配置隔离:setting.PrepareIntegrationTestConfig() 从 tests/{db}.ini.tmpl 渲染出 tests/{db}.ini(见 15.2.4);
  3. 进程隔离:make test-integration 先把整个包编译成一个二进制再执行(go test -c gitea.dev/tests/integration -o ./test-integration-$(GITEA_TEST_DATABASE).test),原因写在 Makefile 注释里:
test-integration: $(EXECUTABLE)
        @# Use a compiled binary: testlogger forwards gitea logs to t.Log, so `go test -v`
        @# would flood output per passing test. testcache can't help these tests anyway —
        @# they mutate the work directory, so cache inputs change between runs.
        $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -c gitea.dev/tests/integration -o ./test-integration-$(GITEA_TEST_DATABASE).test
        ./tools/test-integration.sh ./test-integration-$(GITEA_TEST_DATABASE).test

这段注释信息量很大:testlogger 会把 Gitea 的日志转发进 t.Log,如果用 go test -v 跑,每个通过的测试都会刷屏;而 go test 的构建缓存对这些测试无效——它们会修改工作目录,缓存输入每次都在变。

15.1.3 e2e 的运行方式与 GITEA_TEST_E2E_FLAGS

make test-e2e 展开为:

test-e2e: playwright frontend backend
        @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS)

三个前置依赖说明 e2e 是"全量"级别的测试:先装 Playwright 浏览器(playwright target),再构建前端产物和后端二进制,然后 tools/test-e2e.sh 接管一切(详见 15.4.3)。GITEA_TEST_E2E_FLAGS 是透传给 pnpm exec playwright test 的参数槽,例如只想跑一个文件:

make test-e2e GITEA_TEST_E2E_FLAGS='tests/e2e/pull-merge-box.test.ts'

docs/testing.md 还记录了三个配套环境变量:GITEA_TEST_E2E_DEBUG(把服务日志打到控制台)、GITEA_TEST_E2E_TIMEOUT_FACTOR(超时倍率)、GITEA_TEST_E2E_EXTERNAL(对接外部浏览器服务)。

15.1.4 选择器:本地跑测试的语法

docs/testing.md 明确了两种运行单个测试的写法,对应 Makefile 里两个带 # 的 target(它们出现在 make help 的手工条目里,见 15.6.2):

  • make test-backend#TestRepoApplyPatch —— go test -run '$(subst .,/,$*)',正则完全匹配;
  • make test-integration#TestPullMerge —— 同理作用于集成测试包。

以及直接用 go 原生选择器:go test -run "^TestName$" ./models/repository/(AGENTS.md 规定的写法),vitest 用 pnpm exec vitest <path-filter>,e2e 用 GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e。


15.2 测试基础设施

这一节逐段拆解"一个测试从进程启动到拿到数据库"的完整链路,涉及 modules/setting/testenv.go、models/unittest/、modules/testlogger/、tests/test_utils.go 四块基石。

15.2.1 SetupGiteaTestEnv:测试进程的环境自举

单元测试和集成测试共享同一个环境初始化函数 setting.SetupGiteaTestEnv()(modules/setting/testenv.go)。它的几个动作值得逐行看,因为每个都对应一个真实的测试痛点:

func SetupGiteaTestEnv() {
	if giteaTestSourceRoot != nil {
		return // already initialized
	}

	IsInTesting = true

	log.OsExiter = func(code int) {
		if code != 0 {
			// Non-zero exit code (log.Fatal) shouldn't occur during testing, if it happens:
			// * Show a full stacktrace for more details.
			// * If the "log.Fatal" is abused in tests, should fix.
			panic(fmt.Errorf("non-zero exit code during testing: %d", code))
		}
		os.Exit(0)
	}
	…

为什么要劫持 log.OsExiter:生产代码里 log.Fatal 会直接 os.Exit(1),在测试进程中这会无声杀掉整个测试二进制、掩盖真实错误。改成 panic 之后,测试失败会带出完整栈,一眼定位到是谁滥用 log.Fatal。

源码根如何定位:不用环境变量,而是用 runtime.Caller 反推——

func detectGiteaTestRoot() string {
	_, filename, _, _ := runtime.Caller(0)
	giteaRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename)))
	fixturesDir := filepath.Join(giteaRoot, "models", "fixtures")
	if _, err := os.Stat(fixturesDir); err != nil {
		panic("in gitea source code directory, fixtures directory not found: " + fixturesDir)
	}
	return giteaRoot
}

testenv.go 本身在 modules/setting/ 下,向上三级恰好是仓库根;再校验 models/fixtures 存在才算数(防止路径假设失效)。giteaTestSourceRoot 特意声明为指针类型并附注释"intentionally use a pointer to make sure the uninitialized access panics"——未初始化就取值会解引用 nil 直接崩,而不是静默返回空串。

测试环境对配置的三个特殊处理(initGiteaConf / cleanUpEnv / initWorkPathAndConfig):

  1. 没有 GITEA_TEST_CONF 时用一个不存在的临时配置 custom/conf/app-test-tmp.ini,注释解释了原因:"the test can run concurrently, using the same config file will cause data-race between tests";
  2. 清掉所有 GIT_* 和非 GITEA_TEST_* 前缀的 GITEA_* 环境变量,防止宿主机环境(比如用户机器上的 GITEA__server__ROOT_URL)污染测试;
  3. 注册测试专用密码哈希器并设为默认:
// register the dummy hash algorithm function used in the test fixtures
_ = hash.Register("dummy", hash.NewDummyHasher)
PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy")

这保证 fixtures 里那些历史密码哈希能被验证逻辑识别,且整个测试期哈希计算零成本(dummy 不做真实计算)。

15.2.2 MainTest:需要数据库的单测骨架

models/unittest/testdb.go 的 MainTest 是所有需要数据库的 TestMain 的可复用实现,签名 func MainTest(m *testing.M, testOptsArg ...*TestOptions),其中:

// TestOptions represents test options
type TestOptions struct {
	FixtureFiles []string
	SetUp        func() error // SetUp will be executed before all tests in this package
	TearDown     func() error // TearDown will be executed after all tests in this package
}

FixtureFiles 是按需加载的白名单——一个包只关心自己的表,就能只加载这些表的 fixtures(不传则加载全部)。mainTest 的执行序列是固定的一条直线:

tempWorkPath, tempCleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("unit-test-dir-")
…
defer setting.MockBuiltinPaths(tempWorkPath, "", "")()
setting.SetupGiteaTestEnv()

giteaRoot := setting.GetGiteaTestSourceRoot()
fixturesOpts := FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: testOpts.FixtureFiles}
if err := CreateTestEngine(filepath.Join(tempWorkPath, "sqlite-test.db"), fixturesOpts); err != nil { … }

setting.AppURL = "https://try.gitea.io/"
setting.RunUser = "runuser"
setting.SSH.User = "sshuser"
…
setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master"

几个细节:

  • 数据库文件名强制 sqlite-test.db 结尾,ResetTestDatabase 会校验这个后缀(testing database file for sqlite3 must end in "-test.db")——这是防止测试代码误删真实数据库的硬保险;
  • SyncDirs(filepath.Join(giteaRoot, "tests", "gitea-repositories-meta"), setting.RepoRootPath) 把夹具仓库同步进临时工作目录,第 1 章提到的 user2、org4 等仓库就来自这里;
  • git.InitFull()、cache.Init()、storage.Init() 依次初始化子系统,SetUp 钩子在 m.Run() 之前、TearDown 之后——包级的一次性准备不必散落在每个测试里。

CreateTestEngine 则是 sqlite 单库的创建逻辑:

func CreateTestEngine(testSQLiteFile string, opts FixturesOptions) error {
	driver, connStr, err := db.ConnStr(db.ConnOptions{Type: setting.DatabaseTypeSQLite3, SQLitePath: testSQLiteFile, SQLiteBusyTimeout: setting.DefaultSQLiteBusyTimeout})
	…
	x.SetMapper(names.GonicMapper{})
	db.SetDefaultEngine(context.Background(), x)

	if err = db.SyncAllTables(); err != nil {
		return err
	}
	switch os.Getenv("GITEA_TEST_LOG_SQL") {
	case "true", "1":
		x.ShowSQL(true)
	}
	return InitFixtures(opts)
}

GITEA_TEST_LOG_SQL=1 打印 SQL,是排查测试失败时的第一手段(docs/testing.md 有记载)。

15.2.3 fixtures 注入机制:真实加载代码逐段读

fixtures 是 78 个 YAML 文件(models/fixtures/*.yml,文件名即表名),加载分三个协同部件。

(1)NewFixturesLoader 按方言生成方言适配的 INSERT 语句(models/unittest/fixtures_loader.go):

switch f.dbType {
case schemas.SQLITE:
	f.quoteObject = func(s string) string { return fmt.Sprintf(`"%s"`, s) }
	f.paramPlaceholder = func(idx int) string { return "?" }
case schemas.POSTGRES:
	f.quoteObject = func(s string) string { return fmt.Sprintf(`"%s"`, s) }
	f.paramPlaceholder = func(idx int) string { return fmt.Sprintf(`$%d`, idx) }
case schemas.MYSQL:
	f.quoteObject = func(s string) string { return fmt.Sprintf("`%s`", s) }
	f.paramPlaceholder = func(idx int) string { return "?" }
case schemas.MSSQL:
	f.quoteObject = func(s string) string { return fmt.Sprintf("[%s]", s) }
	f.paramPlaceholder = func(idx int) string { return "?" }
}

标识符引用符("、反引号、[])和参数占位符(?、$n)四种方言各不相同,这里一次抽象掉。prepareFixtureItem 把 YAML 行拼成预编译好的 INSERT INTO "表" ("k1","k2") VALUES (?,?) 与参数数组,逐行执行时零解析开销;0x 前缀的字符串值会被 hex.DecodeString 还原成二进制(用于 BLOB 列)。

(2)表级增量重载:tableSyncMap + SQL 动词拦截钩子。核心设计问题是:测试 A 改了 user 表的数据,测试 B 需要干净的 user 表,但每次全量重载 78 个表太慢。Gitea 的解法是把"哪张表被改过"记录下来,下次 Load() 只重载脏表:

func (f *fixturesLoaderInternal) Load() error {
	tx, err := f.db.Begin()
	…
	for _, fixture := range f.fixtures {
		synced, existing := f.tableSyncMap.Load(fixture.tableName)
		if synced == true || !existing {
			continue
		}
		if err := f.loadFixtures(tx, fixture); err != nil {
			return fmt.Errorf("failed to load fixtures from %s: %w", fixture.fileFullPath, err)
		}
		f.tableSyncMap.Store(fixture.tableName, true)
	}
	…

"谁改了表"由挂在 xorm 上的钩子 fixturesHookStruct.BeforeProcess 拦截 SQL 判定(models/unittest/fixtures.go):它解析 SQL 首个动词,SELECT/SHOW/PRAGMA/ALTER/CREATE/DROP/... 等不改数据的直接放行;INSERT/MERGE/UPDATE/DELETE/TRUNCATE 则取出表名调用 fixturesLoader.MarkTableChanged(...):

default:
	// should either parse the table name if it changes data, or ignore it
	panic("unrecognized sql: " + sql)

未知 SQL 动词直接 panic 是个刻意的防御:将来 xorm 或业务代码产生一种新形态的写 SQL,测试会在第一时间、带栈炸出来,而不是静默漏标脏表导致测试间数据污染。加载自身的 SQL 通过 context 键 db.ContextKeyTestFixtures 标记跳过拦截,避免自激。

(3)方言相关的边角处理都在同一文件里解决:MSSQL 自增列需要 SET IDENTITY_INSERT ... ON/OFF 才能显式插入 ID;pgsql 加载后要重置序列(loadFixtureResetSeqPgsql 生成 SELECT SETVAL(...) 批量执行),否则插入的新行会撞主键;loadFixtures 里用 DELETE FROM 而不是 TRUNCATE,因为注释写明"sqlite3 doesn't support truncate"。

InitFixtures 除了建 loader 还顺手注册 dummy 哈希器并挂上拦截钩子:

func InitFixtures(opts FixturesOptions) (err error) {
	xormEngine := GetXORMEngine()
	fixturesLoader, err = NewFixturesLoader(xormEngine, opts)
	// register the dummy hash algorithm function used in the test fixtures
	_ = hash.Register("dummy", hash.NewDummyHasher)
	setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy")
	xormEngine.AddHook(&fixturesHookStruct{})
	return err
}

15.2.4 集成测试的配置渲染与测试环境准备

setting.PrepareIntegrationTestConfig()(modules/setting/testenv.go)把模板渲染成实际配置:

func PrepareIntegrationTestConfig() error {
	giteaTestRoot := detectGiteaTestRoot()
	isInCI := os.Getenv("CI") != ""
	testDatabase := os.Getenv("GITEA_TEST_DATABASE")
	if testDatabase == "" {
		if isInCI {
			return errors.New("GITEA_TEST_DATABASE environment variable not set")
		}
		// for local development, default to sqlite. CI needs to explicitly set a database to avoid unexpected results
		testDatabase = "sqlite"
		…
	}

	_ = os.Setenv("GITEA_TEST_ROOT", giteaTestRoot)
	_ = os.Setenv("GITEA_TEST_CONF", filepath.Join("tests", testDatabase+".ini"))
	…
	confFile := filepath.Join(giteaTestRoot, "tests", testDatabase+".ini")
	tmplBuf, err := os.ReadFile(confFile + ".tmpl")
	…
	for k, v := range envVarMap {
		tmpl = strings.ReplaceAll(tmpl, fmt.Sprintf("{{%s}}", k), v)
	}
	err = os.WriteFile(confFile, []byte(tmpl), 0o644)
	return err
}

要点:

  • CI 强制显式选库、本地默认 sqlite——避免 CI 里"没设变量于是测了 sqlite"这种假绿;
  • 模板占位符是 {{KEY}} 形式,由环境变量 + MAKEFILE_VARS(shellquote.Split 解析)填充,TEST_WORK_PATH、TEST_LOGGER 由函数内部注入;
  • 产出 tests/{db}.ini,同时设 GITEA_TEST_CONF 供 git hooks 使用(hooks 以 $GITEA_ROOT/$GITEA_CONF 引用配置)。

tests/sqlite.ini.tmpl 里可以窥见集成测试的刻意配置,例如把所有队列改成同步([queue] TYPE = immediate)、关掉签名(SIGNING_KEY = none)、RUN_MODE = prod、写死的 SECRET_KEY/INTERNAL_TOKEN/JWT_SECRET——确定性优先,一切异步和随机因素都被压平。[mailer] PROTOCOL = dummy、[markup.html] RENDER_COMMAND = go run {{GITEA_TEST_ROOT}}/tools/test-echo.go 则把外部依赖替换成可断言的哑件。

tests/test_utils.go 的 InitIntegrationTest 把这些串起来,最后启动完整 Web 路由:

func InitIntegrationTest() error {
	testlogger.Init()
	err := setting.PrepareIntegrationTestConfig()
	…
	setting.SetupGiteaTestEnv()
	setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master"
	if err := git.InitFull(); err != nil { return err }
	setting.Database.SlowQueryThreshold = 0
	setting.LoadDBSetting()
	cleanupDb, err := unittest.ResetTestDatabase()
	…
	if err := storage.Init(); err != nil { return err }
	routers.InitWebInstalled(graceful.GetManager().HammerContext())
	return nil
}

每个测试文件开头的 TestMain 只需调它(tests/integration/integration_test.go):

func testMain(m *testing.M) int {
	defer log.GetManager().Close()
	managerCtx, cancel := context.WithCancel(context.Background())
	graceful.InitManager(managerCtx)
	defer cancel()

	err := tests.InitIntegrationTest()
	if err != nil {
		return testlogger.MainErrorf("InitTest error: %v", err)
	}
	testWebRoutes = routers.NormalRoutes()
	…

注意 testWebRoutes = routers.NormalRoutes()——集成测试跑的是真路由树(第 3 章那套完整中间件),测试请求由它直接处理,不经过网络监听。TestMain 里还有一个小技巧:

func TestMain(m *testing.M) {
	// -test.list must skip InitIntegrationTest, which requires a database.
	flag.Parse()
	if flag.Lookup("test.list").Value.String() != "" {
		os.Exit(m.Run())
	}
	os.Exit(testMain(m))
}

-test.list 模式(15.4.2 分片脚本会用)只枚举测试名不执行,因此跳过昂贵的环境初始化——这是分片脚本能做到"秒级列出全部测试"的前提。

15.2.5 造测试用户与会话:loginUser 与 MakeRequest

集成测试的"登录"不是走浏览器,而是构造带 cookie 的会话。tests/integration/integration_test.go 中 TestSession 持有一个 http.CookieJar,MakeRequest 负责在请求上贴 cookie、把响应的 Set-Cookie 写回 jar:

func (s *TestSession) MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
	t.Helper()
	if s == nil {
		return MakeRequest(t, rw, expectedStatus)
	}
	req := rw.Request
	baseURL, err := url.Parse(setting.AppURL)
	assert.NoError(t, err)
	for _, c := range s.jar.Cookies(baseURL) {
		req.AddCookie(c)
	}
	resp := MakeRequest(t, rw, expectedStatus)

	ch := http.Header{}
	ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
	cr := http.Request{Header: ch}
	s.jar.SetCookies(baseURL, cr.Cookies())

	return resp
}

loginUser(t, "user1") 用 userPassword = "password"(常量就在同一文件)POST 登录表单拿到 session cookie,之后所有 session.MakeRequest(...) 自动带上——这就是 15.3 里 testPullMerge(t, session, ...) 中 session 的来历。而 fixtures 用户的密码之所以能用 "password" 登录,靠的正是 15.2.1 里注册的 dummy 哈希器。

按需准备子系统资源的 Prepare* 函数家族也在 tests/test_utils.go,注释明确要求不要往 PrepareTestEnv 里乱加:

func PrepareTestEnv(t testing.TB, skip ...int) func() {
	t.Helper()
	deferFn := PrintCurrentTest(t, util.OptionalArg(skip)+1)

	// load database fixtures
	assert.NoError(t, unittest.LoadFixtures())

	// do not add more Prepare* functions here, only call necessary ones in the related test functions
	PrepareGitRepoDirectory(t)
	PrepareLFSStorage(t)
	PrepareCleanPackageData(t)
	return deferFn
}

PrepareLFSStorage、PrepareAttachmentsStorage、PrepareArtifactsStorage 各自从 tests/gitea-lfs-meta、tests/testdata/data/attachments 等夹具目录把文件复制进 storage——因为这些 IO 资源不是每个测试都需要,全量准备会白白拖慢无关测试(AGENTS.md 的"sub-2s per integration test"纪律要求测试只做必要功)。

15.2.6 testlogger:日志与测试的桥

modules/testlogger/testlogger.go 的 PrintCurrentTest 是每个集成测试第一行的 === 测试名 分隔符的来源,它同时承担三件事:日志归属、超时哨兵、慢测试告警。

// PrintCurrentTest prints the current test to os.Stdout
func PrintCurrentTest(t testing.TB, skip ...int) func() {
	t.Helper()
	runStart := time.Now()
	actualSkip := util.OptionalArg(skip) + 1
	_, filename, line, _ := runtime.Caller(actualSkip)
	…
	stdoutPrintf("=== %s (%s:%d)\n", log.NewColoredValue(t.Name()), strings.TrimPrefix(filename, prefix), line)

	WriterCloser.pushT(t)
	timeoutChecker := time.AfterFunc(TestTimeout, func() {
		stdoutPrintf("!!! %s ... timeout: %v ... stacktrace:\n%s\n\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestTimeout, getRuntimeStackAll())
	})
	return func() {
		deferHasRun = true
		flushStart := time.Now()
		slowFlushChecker := time.AfterFunc(TestSlowFlush, func() {
			stdoutPrintf("+++ %s ... still flushing after %v ...\n", log.NewColoredValue(t.Name(), log.Bold, log.FgRed), TestSlowFlush)
		})
		if err := queue.GetManager().FlushAll(t.Context(), -1); err != nil {
			// if panic occurs, then the t.Context() is also cancelled ahead, so here it shows "context canceled" error.
			t.Errorf("Flushing queues failed with error %q, cause %q", err, context.Cause(t.Context()))
		}
		slowFlushChecker.Stop()
		timeoutChecker.Stop()

		runDuration := time.Since(runStart)
		flushDuration := time.Since(flushStart)
		if runDuration > TestSlowRun {
			stdoutPrintf("+++ %s is a slow test (run: %v, flush: %v)\n", log.NewColoredValue(t.Name(), log.Bold, log.FgYellow), runDuration, flushDuration)
		}
		WriterCloser.popT()
	}
}
机制常量行为
测试标记===打印 测试名 (文件:行),runtime.Caller(skip) 定位调用点
超时哨兵TestTimeout = 10 * time.Minute超时打印 !!! + 全进程 goroutine 栈转储,用于揪出卡死的测试
慢测试告警TestSlowRun = 10 * time.Second超过 10s 打印 +++ ... is a slow test
flush 慢告警TestSlowFlush = 1 * time.Second队列 flush 超 1s 提示
队列清账queue.GetManager().FlushAll测试结束强制清空所有队列,否则异步任务会泄漏进下一个测试

WriterCloser 是日志的去向:Write 把日志塞进栈顶 t.Log,没有运行中测试时降级输出到 stdout 并用 ??? 前缀(与 ===、+++ 形成可 grep 的标记家族);注释还解释了为什么它需要读锁——"the logger system could still try to output logs after the runner is finished",这是曾经真实存在的 data race。Reset 会在还有未闭合 writer 时直接 t.Errorf,防止日志串到别的测试里。

modules/test/logchecker.go 提供反向能力:测试断言日志。NewLogChecker 给某个 logger 挂一个自定义 writer,Filter(msg...) 设置要命中的消息,Check(d) 以 10ms 为步长轮询直到命中或超时:

// Check returns the filtered slice and whether the stop mark is reached.
func (lc *LogChecker) Check(d time.Duration) (filtered []bool, stopped bool) {
	stop := time.Now().Add(d)
	for {
		lc.mu.Lock()
		stopped = lc.stopped
		lc.mu.Unlock()

		if time.Now().After(stop) || stopped {
			…
			return f, stopped
		}
		time.Sleep(10 * time.Millisecond)
	}
}

这正是 AGENTS.md"Wait on a deterministic condition rather than sleep"的两种落地形态之一:Check(d) 是"带超时上界的条件等待",而不是固定睡多久。


15.3 关键测试策略解读

测试数量不等于测试质量。本节挑三个有代表性的测试,说明 Gitea 如何用测试锁住并发与时序行为,并把 AGENTS.md 的测试纪律(sub-2s/sub-4s、等确定条件、语义定位器)落到真实代码上。

15.3.1 案例一:队列的唤醒与阻塞语义(modules/queue/base_test.go)

队列是最容易写出 flaky 测试的组件——它本质上是"等待别人投递"。testQueueBasic 用两个手法把时序变成确定的。

手法 1:用 mockBackoffDuration 把退避调到荒谬的大,从而区分"被唤醒"和"熬过退避":

t.Run("PushNotify", func(t *testing.T) {
	defer mockBackoffDuration(5000 * time.Millisecond)()
	// pop an empty queue, but it can be notified and pop the item immediately
	wg := sync.WaitGroup{}
	wg.Go(func() {
		it, err := q.PopItem(ctx) // it should return immediately after PushItem, no "backoff" waiting
		assert.NoError(t, err)
		assert.Equal(t, "item-notify", string(it))
	})
	time.Sleep(10 * time.Millisecond)
	err = q.PushItem(ctx, []byte("item-notify"))
	wg.Wait()
	if opts.NotifiableQueue {
		v, _ := q.(baseQueueNotifiableInterface)
		assert.Empty(t, v.getNotifySignalChan(), "notify signal should have been read")
		assert.NoError(t, q.PushItem(ctx, []byte("item-dummy")))
		assert.Len(t, v.getNotifySignalChan(), 1, "notify signal should exist for newly pushed item")
		…

把 backoff 人为设成 5 秒之后,如果队列没有被 push 唤醒,PopItem 就要等满 5 秒——测试的超时断言会立刻暴露"通知机制坏了";反之它"立即返回"就证明唤醒信号确实传递了。紧接着还直接检查通知 channel 的消费与再产生(assert.Empty → push → assert.Len == 1),把"信号语义"也钉死。这里有一处 10ms 的 time.Sleep——它不是用来"等结果"的(结果由 wg.Wait + 断言确认),只是保证 goroutine 已进入阻塞,属于必要的调度让步,与"用 sleep 代替条件等待"有本质区别。

手法 2:对阻塞时长做下界断言,而不是上界:

// test blocking push if queue is full (with custom pushBlockTime)
oldPushBlockTime := pushBlockTime
timeStart := time.Now()
pushBlockTime = 30 * time.Millisecond
err = q.PushItem(ctx, []byte("item-full"))
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.GreaterOrEqual(t, time.Since(timeStart), pushBlockTime*2/3)
pushBlockTime = oldPushBlockTime

断言耗时 >= pushBlockTime*2/3(下界、还留了 1/3 容差)。慢机器上测试只会"更慢地满足下界",绝不会因为机器快而误报——这正是 sub-2s 纪律所要求的"测试自身要在 2 秒内",同时也是"不要用固定 sleep 等结果"的镜像:对'至少等了多久'做下界断言是安全的,对'正好等多久'做精确断言是 flaky 的。同一段测试上文还覆盖了 context.DeadlineExceeded 与 context.Canceled 两种空队列 Pop 的失败路径,边界(超时 vs 取消)被显式区分。

15.3.2 案例二:中间件执行顺序(modules/web/router_test.go)

第 3 章讲过中间件顺序的重要性(ProtocolMiddlewares 里注释着 "the order is important")。顺序靠人眼 review 是守不住的,于是 modules/web/router_test.go 用一个自定义 testRecorder 把每个 handler 的执行记进切片,测完直接断言顺序与参数:

测试中的 recorder 记录 handlerMarks(handler 执行序列)、pathParams(路径参数)、chiRoutePattern(最终匹配到的 chi 路由模式),从而一次性验证三件事:

  1. 中间件与 handler 的相对执行顺序符合声明顺序;
  2. 路径参数(:owner、:name 之类)被正确解析并传递;
  3. 请求确实落到了期望的路由模式上(而不是被前缀路由截胡)。

这类"观察副作用序列而非最终输出"的测试,是验证横切机制(中间件、拦截器、钩子)的通用范式——Gitea 中同样思想的应用还包括 15.2.3 的 fixturesHookStruct(观察 SQL 流)和第 12 章 notifier 的测试(观察事件流)。

15.3.3 案例三:用 require.Eventually 等 PR 合并完成(tests/integration/pull_merge_test.go)

PR 合并会触发队列任务(判冲突、更新索引、发通知),HTTP 返回 200 不代表状态已落库。testPullMerge 的写法:

var resp *httptest.ResponseRecorder
require.Eventually(t, func() bool {
	req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/pulls/%s/merge", user, repo, pullNum), options)
	resp = session.MakeRequest(t, req, NoExpectedStatus)
	return resp.Code == http.StatusOK
}, 5*time.Second, 50*time.Millisecond, "Timed out waiting for pull merge to succeed")

结构是"5 秒上界 + 50ms 轮询间隔 + 明确的失败消息":条件不满足就继续,满足立刻通过,5 秒耗尽才失败并给出可读原因。同一文件里还有 testWaitForPullRequestStatus(5s/20ms 轮询 commit status),全仓 require.Eventually 是集成测试等待异步结果的标准姿势。对比反面写法 time.Sleep(3 * time.Second):后者要么慢(每次白等 3 秒,直接违反 sub-2s),要么脆(慢 CI 上 3 秒不够)——等待时长应当由"条件何时满足"决定,测试只提供上界。

配套的还有 LogChecker(15.2.6)和 modules/test/utils.go 里的小工具,例如 test.RedirectURL(resp) 从响应里解析重定向目标,让"合并后应跳回 PR 页"这类断言写得像自然语言:

redirect := test.RedirectURL(resp)
assert.Equal(t, fmt.Sprintf("/%s/%s/pulls/%s", user, repo, pullNum), redirect)

15.3.4 e2e 的语义定位器实践

AGENTS.md 要求 e2e "prefer semantic locators"。看 tests/e2e/pull-merge-box.test.ts 的完整用例(它同时演示了 API 造数据 + 浏览器操作的混合模式):

test('merge box merges a pull request', async ({page, request}) => {
  const repo = `e2e-merge-box-${randomString(8)}`;
  const createPR = (async () => {
    await apiCreateRepo(request, {name: repo});
    await apiCreateFiles(request, owner, repo, [{path: 'feat.txt', content: 'feature\n'}], {branch: 'main', newBranch: 'feat'});
    return apiCreatePR(request, owner, repo, 'feat', 'main', 'merge box test');
  })();
  const [index] = await Promise.all([createPR, login(page)]);
  await page.goto(`/${owner}/${repo}/pulls/${index}`, {waitUntil: 'commit'});

  // expand the merge form, then submit the merge
  await page.getByRole('button', {name: 'Create merge commit'}).click();
  await page.locator('form.form-fetch-action').getByRole('button', {name: 'Create merge commit'}).click();

  await expect(page.getByText(/successfully merged/i)).toBeVisible();
  await assertNoJsError(page);
});

三个值得注意的策略:

  • 数据准备走 API、交互走 UI:apiCreateRepo/apiCreateFiles/apiCreatePR(tests/e2e/utils.ts)比点 UI 快一个数量级,而被测的"合并框"才用浏览器操作;
  • Promise.all 并行准备:创建 PR 与登录两个独立步骤并发执行,压缩 e2e 本就昂贵的耗时(AGENTS.md 要求单 e2e sub-4s);
  • 语义定位器:getByRole('button', {name: 'Create merge commit'}) 而非 CSS 类名——按钮文字变样式不变时测试不碎,反之按钮被重构挪位时测试仍能发现"找不到该角色元素"这一真实回归。

utils.ts 的 apiRetry 是另一处纪律体现:只对 500/502/503 带抖动的指数退避重试(1000 * (attempt + 1) + Math.random() * 500),4xx 立即失败——瞬时故障可重试,确定性错误不可掩盖。而 login() 用 maxRedirects: 0 只验 302/303 状态码,不真跳转,把"登录成功"这一条件变成廉价断言。

15.3.5 测试纪律的源码级对照

AGENTS.md 纪律源码中的落点
sub-2s / sub-4stestlogger 的 TestSlowRun = 10s 慢测试告警(超线即 +++ 可见);e2e 超时倍率 CI=4/local=1(15.4.4)
等确定条件而非 sleeprequire.Eventually(5s/50ms)、LogChecker.Check(d)(10ms 轮询)、e2e apiRetry(带抖动退避)
语义定位器getByRole / getByLabel / getByText 全面取代 CSS 选择器(tests/e2e/*.test.ts)
少而快的测试Prepare*LFSStorage 等按需准备(15.2.5);TestOptions.FixtureFiles 白名单(15.2.2)
只改被测必要物make test-check:跑完测试后 git status -s 非空即失败,提示"change the tests to create these files in a temporary directory"

最后一行值得一提——test-check 是个"元测试":

test-check:
	@echo "Running test-check...";
	@diff=$$(git status -s); \
	if [ -n "$$diff" ]; then \
		echo "make test-backend has changed files in the source tree:"; \
		printf "%s" "$${diff}"; \
		echo "You should change the tests to create these files in a temporary directory."; \
		echo "Do not simply add these files to .gitignore"; \
		exit 1; \

它把"测试污染了源码树"这个最容易被 .gitignore 掩盖的问题变成了 CI 红灯,且错误信息直接给出正确做法(用临时目录),而不是简单拒绝。CI 里 test-unit job 的最后一步就是它。


15.4 CI 流水线

本节拆开 .github/workflows/,说明"一次 PR 会触发哪些门禁、各自防什么"(lint 配置本身在 15.5)。

15.4.1 files-changed:12 类变更的精确路由

所有 PR 流水线的第一步都是复用 files-changed.yml(dorny/paths-filter),它的 outputs 决定后续 job 是否执行,实测 12 类输出:

backend、frontend、docs、actions、templates、docker、dockerfile、swagger、yaml、json、e2e、shell

其中 backend 的过滤路径集合刻意包含测试基建本身——models/fixtures/**、tests/*.ini.tmpl 等都会被算作 backend 变更,保证"改了 fixtures 也得跑全量后端测试"。

flowchart LR
    PR[&#34;Pull Request&#34;] --> FC[&#34;files-changed.yml<br/>dorny/paths-filter<br/>12 类输出&#34;]
    FC -->|&#34;backend / actions&#34;| DB[&#34;db-tests.yml&#34;]
    FC -->|&#34;backend / frontend / docs / …&#34;| COMP[&#34;compliance.yml&#34;]
    FC -->|&#34;backend / e2e&#34;| E2E_W[&#34;e2e-tests.yml&#34;]

    subgraph DB[&#34;pull-db-tests.yml&#34;]
        U[&#34;test-unit<br/>-race ×2(bindata / gogit)<br/>+ test-check&#34;]
        PG1[&#34;pgsql-shard-1<br/>-race, TEST_SHARD=1/2&#34;]
        PG2[&#34;pgsql-shard-2<br/>TEST_SHARD=2/2&#34;]
        SQ[&#34;test-sqlite<br/>TAGS=bindata gogit&#34;]
        MY[&#34;test-mysql&#34;]
        MS[&#34;test-mssql&#34;]
        U & PG1 & PG2 & SQ & MY & MS
    end

    subgraph COMP[&#34;pull-compliance.yml&#34;]
        LB[&#34;lint-backend<br/>lint-go 双平台 + 版权头&#34;]
        LON[&#34;lint-on-demand<br/>lint-js/css/templates/…&#34;]
        CB[&#34;checks-backend<br/>tidy/fmt/swagger/security&#34;]
        FE[&#34;frontend<br/>build + vitest&#34;]
        BK[&#34;backend<br/>交叉编译&#34;]
    end

    subgraph E2E_W[&#34;pull-e2e-tests.yml&#34;]
        E2EJ[&#34;test-e2e<br/>make frontend/backend/playwright<br/>make test-e2e&#34;]
    end

15.4.2 db-tests:四库矩阵 + pgsql 分片 + race

pull-db-tests.yml 是最重的一条流水线,六个 job 的关键差异(实测):

job数据库/服务关键环境变量特殊点
test-unitelasticsearch + meilisearch + redis + minio + azuriteGOTEST_FLAGS: -race -timeout=20m,TAGS: bindata跑两遍:bindata 与 bindata gogit(覆盖 git 双实现,第 6 章);结尾 make test-check
test-pgsql-shard-1/2postgres:14 + ldap + minioGOTEST_FLAGS: -race -timeout=40m,`TEST_SHARD=12、TEST_TOTAL_SHARDS=2`复用 composite action .github/actions/pgsql-shard;注释解释选 pgsql 背 race 的原因
test-sqlite无外部服务GOTEST_FLAGS: -timeout=40m(故意不带 -race)注释:sqlite 驱动包含大量 Go 代码,race 下"extremely slow"
test-mysqlmysql:8.4 + elasticsearch + smtpimapGITEA_TEST_DATABASE=mysqltmpfs 挂数据目录提速
test-mssqlmssql:2019 + azuriteGITEA_TEST_DATABASE=mssql

pgsql-shard action 的注释是理解取舍的钥匙:

    - name: run tests
      shell: bash
      run: GITEA_TEST_DATABASE=pgsql make test-integration
      env:
        # pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower.
        GOTEST_FLAGS: -race -timeout=40m
        TAGS: bindata gogit
        TEST_LDAP: 1
        TEST_SHARD: ${{ inputs.shard }}
        TEST_TOTAL_SHARDS: ${{ inputs.total-shards }}

race detector 只挑一个库跑(pgsql),因为 race 会让测试慢约 60%,四库都开不划算;pgsql 又用两分片消化额外耗时。分片的实现完全在 tools/test-integration.sh(31 行,值得整段看):

NAMES=$("$BINARY" -test.list='^Test' | LC_ALL=C sort -u | awk -v r=$((TEST_SHARD - 1)) -v t="$TEST_TOTAL_SHARDS" '(NR - 1) % t == r')
if [ -z "$NAMES" ]; then
  echo "shard $TEST_SHARD/$TEST_TOTAL_SHARDS has no tests assigned" >&2
  exit 1
fi
PATTERN=$(echo "$NAMES" | paste -sd '|' -)
echo "Running shard $TEST_SHARD/$TEST_TOTAL_SHARDS ($(echo "$NAMES" | wc -l | tr -d ' ') tests)"
exec "$BINARY" -test.run "^($PATTERN)\$"

流程:先用 -test.list 枚举全部 Test* 名字(这正是 15.2.4 里 TestMain 为它开的后门——跳过数据库初始化),sort -u 保证分片边界与执行顺序无关(否则两台机器枚举顺序不同会重测或漏测),awk 按 (NR-1) % t == r 取模分片,最后拼成一个正则 -test.run '^(TestA|TestB|…)$' 交给已编译的二进制。空分片直接 exit 1 暴露"分片数配错了"而不是静默跳过。

15.4.3 e2e 与 compliance 流水线

pull-e2e-tests.yml 的 test-e2e job 只有六步:make deps-frontend → frontend → deps-backend → backend → playwright → make test-e2e——e2e 前置依赖全量构建,与 15.1.3 的 target 依赖一致。

pull-compliance.yml 有六个 job:files-changed、lint-backend、lint-on-demand、checks-backend、frontend、backend(lint 细节见 15.5)。其中 backend job 做交叉编译验证——确保代码在非本机 GOOS/GOARCH 下也能编译(配合 tools/lint-go-all.go 的双平台 lint,见 15.5.2)。

15.4.4 e2e 脚本的自足式服务编排(tools/test-e2e.sh)

这个脚本是"测试即运维"的缩影,它自己完成了一次完整的 Gitea 部署:随机端口 → 生成配置 → 起服务 → 等就绪 → 建管理员 → 跑测试 → 清理。关键段落:

free_port() {
  node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close()})"
}

用 node 申请随机空闲端口(而不是写死 3000),多套 e2e 可并行、与宿主机现有服务不冲突。配置生成:

WORK_DIR=$(mktemp -d)
FREE_PORT=$(free_port)
…
mkdir -p "$WORK_DIR/custom/conf"
cat > "$WORK_DIR/custom/conf/app.ini" <<EOF
[database]
DB_TYPE = sqlite3
PATH = $WORK_DIR/data/gitea.db

[server]
HTTP_PORT = $FREE_PORT
ROOT_URL = http://localhost:$FREE_PORT
…
[security]
INSTALL_LOCK = true
…
[log]
MODE = console
LEVEL = Warn
EOF

export GITEA_WORK_DIR="$WORK_DIR"
export GITEA_TEST_E2E=true

INSTALL_LOCK = true 跳过安装向导,GITEA_WORK_DIR 隔离全部数据。就绪判定用 curl 轮询 + 进程存活双重检查:

while ! curl -sf --max-time 5 "$E2E_URL" > /dev/null 2>&1; do
  if ! kill -0 "$SERVER_PID" 2>/dev/null; then
    echo "error: Gitea server process exited unexpectedly. Server log:" >&2
    cat "$WORK_DIR/server.log" 2>/dev/null >&2 || true
    exit 1
  fi
  if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
    echo "error: Gitea server not reachable after ${MAX_WAIT}s. Server log:" >&2
    …

"等确定条件而非 sleep"在这里的形态:120 秒上界内每 2 秒探测一次,服务先死立即失败并回吐日志,超时也回吐日志——失败时永远附带诊断材料。随后创建管理员账号:

"./$EXECUTABLE" admin user create \
  --username "$GITEA_TEST_E2E_USER" \
  --password "$GITEA_TEST_E2E_PASSWORD" \
  --email "$GITEA_TEST_E2E_EMAIL" \
  --must-change-password=false \
  --admin

再决定超时倍率(CI 慢机 ×4、本地 ×1),最后按需把 Playwright 跑在容器里:

if [ "$PLAYWRIGHT_MODE" = "container" ]; then
  export PW_TEST_CONNECT_WS_ENDPOINT="ws://127.0.0.1:${PLAYWRIGHT_SERVER_PORT}/"
fi
pnpm exec playwright test "$@"

detect_playwright_mode 的逻辑是:只有 Linux 且发行版不是 ubuntu/debian 时才回退到容器模式(Playwright 官方只支持这两个发行版的浏览器构建),容器以 --network=host 共享回环地址以访问宿主机上的 gitea。这种"能本地就本地、不兼容才容器"的自动探测,让同一套 make test-e2e 在 macOS 开发机和 CI 上都能跑。


15.5 lint 体系

make lint 是全部 lint 的总入口,展开为:

lint: lint-frontend lint-backend lint-templates lint-swagger lint-spell lint-md lint-actions lint-json lint-yaml lint-shell ## lint everything

十个子 lint 各管一个文件族。本节重点讲 Go 与前端两套,其余给配置要点。

15.5.1 .golangci.yml:v2 格式与架构级 depguard

配置声明 version: "2"、default: none + 显式 enable 25 个 linter(bidichk、bodyclose、depguard、dupl、errcheck、exhaustruct_v5、forbidigo、forcetypeassert、gocheckcompilerdirectives、gocritic、govet、ineffassign、mirror、modernize、nakedret、nilnil、nolintlint、perfsprint、revive、staticcheck、testifylint、unconvert、unparam、unused、usestdlibvars、usetesting、wastedassign)。

depguard 是这套配置里最有"架构含量"的部分——它把第 1 章的分层规则变成了机器强制。主规则 main 禁止的包每一条都对应一个历史决定:

        main:
          deny:
            - pkg: encoding/json
              desc: use gitea's modules/json instead of encoding/json
            - pkg: github.com/unknwon/com
              desc: use gitea's util and replacements
            - pkg: io/ioutil
              desc: use os or io instead
            - pkg: golang.org/x/exp
              desc: it's experimental and unreliable
            - pkg: gitea.dev/modules/git/internal
              desc: do not use the internal package, use AddXxx function instead
            - pkg: gopkg.in/ini.v1
              desc: do not use the ini package, use gitea's config system instead
            - pkg: gitea.com/go-chi/cache
              desc: do not use the go-chi cache package, use gitea's cache system
            - pkg: github.com/pkg/errors
              desc: use builtin errors package instead
            - pkg: gitea.com/go-chi/binding
              desc: use our wrapper

encoding/json 被禁是因为 modules/json 按 build tag 在标准库与 jsoniter 间切换(第 4/9 章);ini.v1 被禁是因为配置系统是自研的 modules/setting/config(第 2 章)——depguard 用错误描述把"为什么不能用"写给了未来的修改者。

第二条规则 migrations 才是真正的架构冻结器:

        migrations:
          files:
            - '**/modelmigration/**/*.go'
          deny:
            - pkg: gitea.dev/models
              desc: "migrations must not depend on the models package. HINT: MIGRATION-STRUCT-FROZEN"
            - pkg: gitea.dev/modules/structs
              desc: "migrations must not depend on modules/structs. HINT: MIGRATION-STRUCT-FROZEN"

MIGRATION-STRUCT-FROZEN 的含义(呼应第 4 章):迁移代码必须能在未来的模型结构变化后继续编译运行,所以它禁止依赖当前的 models 包和 modules/structs 结构定义——迁移只能用自己局部定义的结构,否则今天改一个字段,历史迁移就会坏。这条规则由 golangci-lint 按文件路径精确作用于 modelmigration/,是"用工具冻结架构边界"的范例。

其余值得注意的规则:

  • forbidigo 禁 exec.CommandContext("use our wrapper for graceful termination"——对应第 6 章的 gitcmd 包装)以及裸 fmt.Print*;
  • nolintlint 要求 require-explanation: true、require-specific: true——注释豁免必须写原因、必须指明 linter,与 AGENTS.md"use the narrowest scope with a trailing comment giving the reason"完全一致;
  • exclusions 对 _test.go 放宽 dupl/errcheck/forbidigo/staticcheck/unparam(测试里重复与不检查错误是常态);
  • formatters 段启用 gci(import 分组顺序:standard → prefix(gitea.dev) → blank → default)+ gofumpt,这就是 AGENTS.md make fmt 的底层实现;
  • run.timeout: 10m 防止 lint 卡死。

15.5.2 tools/lint-go-all.go:版权头检查 + 双平台 lint

make lint-go 的实现不是一行命令,而是一个 Go 程序(go run tools/lint-go-all.go),它做两件事。

版权头检查——正则要求每个 .go 文件头符合规范、且跳过生成文件:

headerRE := regexp.MustCompile(`^(// (Copyright [^\n]+|All rights reserved\.)\n)*// Copyright \d{4} (The Gogs Authors|The Gitea Authors|Gitea Authors|Gitea)\.( All rights reserved\.)?\n(// (Copyright [^\n]+|All rights reserved\.)\n)*// SPDX-License-Identifier: [\w.-]+`)
generatedRE := regexp.MustCompile(`(?m)^// (Code|This file is) [Gg]enerated.*DO NOT EDIT`)

这解释了 AGENTS.md 里"Add the current year to copyright headers of new .go files"的可执行形式:没有 // Copyright <年份> The Gitea Authors. + // SPDX-License-Identifier: MIT 的文件会在此失败(<file>: missing or invalid copyright header)。它还解析 go.mod 的 ignore 段跳过 go 工具本身不处理的目录。

双平台 lint:

func main() {
	// 'go run' can not have distinct GOOS/GOARCH for its build and run steps,
	// so install a pre-compiled binary and run it for different target platforms.
	_, _ = os.Unsetenv("GOOS"), os.Unsetenv("GOARCH")
	…
	succeed = runCmd([]string{"GOOS=linux", "TAGS=" + lintTagsLinux}, "golangci-lint", append([]string{"run", "--build-tags=linux," + lintTagsLinux}, os.Args[1:]...)) && succeed

	if os.Getenv("CI") != "" {
		// only lint for other platforms when in CI, to keep local lint fast
		_, _ = fmt.Fprintln(os.Stdout, "lint for windows ...")
		succeed = runCmd([]string{"GOOS=windows", "TAGS=gogit"}, "golangci-lint", append([]string{"run", "--build-tags=windows,gogit"}, os.Args[1:]...)) && succeed
	}
	…

为什么要双平台:Gitea 有 //go:build windows、gogit 等条件编译文件(第 6 章讲过 git/go-git 双实现),只 lint linux 会漏掉另一支代码;CI 里还额外 lint bindata tag(构建产物嵌入,见 15.6.3)。本地默认只跑 linux 以"keep local lint fast",CI 才全开——与 AGENTS.md"lint what changed"的节制思路一致。

15.5.3 前端与模板 lint

  • ESLint(eslint.config.ts):除了数百条通用规则,最能体现 Gitea 意图的是自定义约束——裸 fetch 与 localStorage 被禁,必须走封装模块:

    {name: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'},
    {name: 'fetch', message: 'Use `modules/fetch.ts` instead.'},
    

    封装 fetch 才能统一注入 CSRF、错误处理(第 13 章),封装 localStorage 才能同步多标签页;还有仓库自研规则 tools/eslint-rules/unescaped-html-literal.ts(通过 'gitea': {rules: {'unescaped-html-literal': …}} 启用),防止模板/JS 里出现未转义的 HTML 字面量——这是 XSS 面的第一道门。

  • stylelint(stylelint.config.ts)管 CSS;模板 lint 走 lint-templates: .venv node_modules,其中 djlint/yamllint 经 uv 虚拟环境(.venv 依赖)执行,覆盖 templates/ 下的 Go template。

  • 其他:.spectral.yaml(OpenAPI 描述规范)、.yamllint.yaml(workflows 语法)、.markdownlint.yaml(文档格式)、lint-spell(拼写)、lint-json、lint-shell、lint-actions。

对应关系速查(## 注释即 make help 输出):

命令作用
make lint十个子 lint 全跑
make lint-gogolangci-lint + 版权头 + 双平台(tools/lint-go-all.go)
make lint-jsESLint(依赖 node_modules)
make lint-cssStylelint
make lint-templatesdjlint 等模板检查(.venv + node_modules)
make lint-fix / lint-js-fix / lint-go-fix自动修复对应问题
make checks一致性检查总入口(tidy/fmt/swagger/security 等)

15.6 构建体系

15.6.1 make help:真实输出的还原与方法说明

核实与纠正(第 2 处:本机无 make):AGENTS.md 要求"List development targets with make help",但本机是 Windows 且未安装 make(make : The term 'make' is not recognized),因此无法直接执行。替代方案是复刻 help 的实现——它只有两部分(Makefile help target):

help: Makefile ## print Makefile help information.
        @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf "  \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST)
        @printf "  \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright"
        @printf "  \033[36m%-46s\033[0m %s\n" "test-backend[#TestSpecificName]" "run unit test (sqlite only)"
        @printf "  \033[36m%-46s\033[0m %s\n" "test-integration[#TestSpecificName]" "run integration test for GITEA_TEST_DATABASE (sqlite, mysql, pgsql, mssql)"

awk 从 Makefile 提取所有"目标: 依赖 ## 说明"格式的行(正则 ^[0-9A-Za-z._-]+:.*?##),再手工补三个带 # 的目标——因为 test-backend#% 是模式规则,正则里没有 # 字符匹配不到。用 PowerShell 等价地跑这条正则,得到 56 个自动条目 + 3 个手工条目 = 59 个目标,即 make help 的完整输出(去色后)。这也解释了为什么 make test-backend#TestFoo 的说明只在 help 里、不在 Makefile 注释里。

15.6.2 make 目标速查表(按用途分组,## 说明为原文)

目标说明(## 原文)
helpprint Makefile help information.
build / frontend / backendbuild everything / build frontend files / build backend files
generate / generate-backendrun "go generate"(GOOS= GOARCH= CGO_ENABLED=0 go generate -tags …)
generate-swagger / generate-openapi3 / swagger-validateswagger spec 三件套
checks / checks-frontend / checks-backendrun various consistency checks(tidy/fmt/swagger/security 检查)
lintlint everything(10 个子 lint)
lint-frontend / lint-backend / lint-templates各文件族 lint(15.5 表)
lint-js / lint-css / lint-md / lint-yaml / lint-json / lint-swagger / lint-spell / lint-actions / lint-shell / lint-go单项 lint(对应 *-fix 变体自动修复)
test-backendtest backend files
test-frontendtest frontend files(= pnpm exec vitest)
test-e2etest end to end using playwright(手工 help 条目)
test-backend[#TestSpecificName]run unit test (sqlite only)(手工 help 条目)
test-integration[#TestSpecificName]run integration test for GITEA_TEST_DATABASE (sqlite, mysql, pgsql, mssql)
watch / watch-frontend / watch-backendwatch everything and continuously rebuild(后端用 air 热重载)
tidy / go-licensesrun go mod tidy / regenerate go licenses
deps / deps-frontend / deps-backend / deps-tools / deps-pyinstall dependencies(.venv 走 uv,前端走 pnpm)
clean / clean-alldelete backend and integration files / …frontend…
fmtformat the Go and template code
vite / svg / generate-emoji / generate-images / generate-manpage 等前端与生成物 target

(release-*、coverage、test-migration、test-check、unit-test-coverage、test-integration-compile、migrations.*、playwright 等没有 ##,不出现在 help 中,但都是实际可用入口——上表之外的完整清单可用同款正则自行提取。)

15.6.3 版本注入与 build tags

版本从哪来:Makefile 里 VERSION(文件名/分支用)与 GITEA_VERSION(内部展示用)是两个变量:

STORED_VERSION_FILE := VERSION
…
ifeq ($(GITHUB_REF_TYPE),tag)
        # convert tag "v1.2.3" to "1.2.3"
        VERSION ?= $(subst v,,$(GITHUB_REF_NAME))
        GITEA_VERSION ?= $(VERSION)
else ifeq ($(GITHUB_REF_TYPE),branch)
        …
        STORED_VERSION=$(shell cat $(STORED_VERSION_FILE) 2>/dev/null)
        ifneq ($(STORED_VERSION),)
                GITEA_VERSION ?= $(STORED_VERSION)
        else
                GITEA_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
        endif

三条分支:打 tag 构建用 tag 名;分支构建优先读 VERSION 文件(源码 tarball 里没有 .git,release-sources 打包时会写入它再删掉),否则 git describe;git describe 输出的 - 被 sed 换成 +(semver 允许 + 构建元数据,- 会被当预发布版本)。

注入通过 ldflags 完成:

LDFLAGS := $(LDFLAGS) -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)"
…
$(EXECUTABLE): $(GO_SOURCES) $(TAGS_PREREQ)
        CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build -v $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)' -o $@

main.go 里对应两个可被 -X 覆盖的变量(未注入时的默认值):

Version = "development" // program version for this build
Tags    = ""            // the Golang build tags
…
setting.AppVer = Version

formatBuiltWith() 再把 Go 版本和 Tags 拼成展示串( built with go1.x.y : bindata,gogit),第 1 章看到的启动日志"Gitea version: …"即源于此。-ldflags '-s -w' 剥掉符号表与调试信息以减小产物。

build tags 的三个常见组合(docs/build-source.md 原文摘录):

  • bindata:"Build a single monolithic binary, with all assets included. Required for distribution and production build." —— TAGS="bindata" make build;其机制是 modules/public/public_bindata.go(//go:build bindata + //go:embed bindata.dat,第 13 章已见),由 go generate 调 build/generate-bindata.go 生成 bindata.dat;
  • gogit:纯 Go 的 git 实现(免 git 二进制),Windows 发布产物默认带它;
  • 交叉编译示例:GOOS=linux GOARCH=arm64 TAGS="bindata" make build;扩展 LDFLAGS 注入任意变量:LDFLAGS='-X "module.Var1=Value1"' TAGS="bindata" make build。

TAGS 变更触发重编译的机关:TAGS_EVIDENCE 文件(.make_evidence/tags)记录上次构建的 TAGS,ifneq "$(TAGS)" "$(shell cat $(TAGS_EVIDENCE))" 不一致时把该文件列为 $(EXECUTABLE) 的先决条件——切换 tags 但源码没变时,二进制也强制重建。这是 Makefile 里少见的"状态证据"技巧。

时区数据:main_timezones.go 用 //go:build windows 引入 time/tzdata(Go 标准库的嵌入式时区库),Windows 没有系统 tzdata,不嵌入会导致时间转换错误;Docker 镜像则用 TAGS="bindata timetzdata" 达到同样目的(ENV TAGS="bindata timetzdata $TAGS",Dockerfile 与 Dockerfile.rootless 均是)。

15.6.4 跨平台发布产物(tools/build-release.sh)

RELEASE_PLATFORMS_DEFAULT=(
  linux/amd64 linux/arm-5 linux/arm-6 linux/arm64 linux/riscv64 \
  windows/amd64 windows/arm64 \
  darwin/amd64 darwin/arm64 \
  freebsd/amd64
)

RELEASE_PLATFORMS_GOGIT=(windows/amd64 windows/arm64)

九个默认平台 + 仅 Windows 双架构的 gogit 变体。gogit 只给 nightly 的原因写在脚本注释里:

# Only build with gogit for main-nightly, disable gogit for stable releases since there seems no real requirement for them.
# If no real requirement (no user feedbacks), we can completely remove gogit support in the future.

交叉编译用原生方式(CGO_ENABLED=0 GOOS=… GOARCH=… go build),且每个产物都被附加 bindata tag(RELEASE_TAGS="bindata $TAGS")、ldflags 加 -s -w。Makefile 侧的配套 target:release-binaries(全平台)、release-windows|linux|darwin|freebsd(单平台),产物先 release-copy 汇总、release-check 生成 sha256、release-compress 用 gxz -k -9 压缩(-k 保留原文件)。docs/release-management.md 记录的节奏(3–4 个月一个大版本、backport 规则、release-create-tag workflow)决定了这些 target 何时被触发。

一个容易被忽略的细节:.NOTPARALLEL 全局禁用了 make 并行,注释说明了原因——"backend 不依赖 frontend(为了源码 tarball 可以无 Node 构建)",若并行会引入未声明的顺序依赖。这也解释了 build: frontend backend 为何在最外层显式排序。

15.6.5 从 make build 到单二进制:资源嵌入链

完整链路(与第 13 章的前端构建衔接):

  1. frontend → pnpm exec vite build 产出 public/assets/{js,css,fonts} + .vite/manifest.json(FRONTEND_DEST 是目标文件,依赖 web_src/** 与 vite.config.ts、tailwind.config.ts、pnpm-lock.yaml);
  2. backend → generate-backend(go generate -tags '$(TAGS)' ./...,生成 bindata 与 charset 表等)→ $(EXECUTABLE);
  3. 无 bindata tag 时,二进制从磁盘 public/、templates/、options/ 读资源(开发模式方便热改);有 bindata 时走 //go:embed 进二进制(生产/分发要求)。

BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* modules/options/bindata.* modules/templates/bindata.* 列出所有生成的嵌入文件,make clean 会删掉它们。


15.7 部署形态

15.7.1 四种形态对比

维度普通 Docker(Dockerfile)rootless Docker(Dockerfile.rootless)二进制 + init 脚本(contrib/service/)源码 make build
进程模型s6-svscan 管 gitea + openssh 两服务dumb-init 单进程直接 gitea websystemd 等 9 种 init 系统托管开发机直接跑
运行身份root 启动 → su-exec $USER 降权全程 USER 1000:1000(git)User=git(systemd 降权)当前用户
SSH 端口22(EXPOSE 22 3000)2222(EXPOSE 2222 3000,非特权)22 或自定自定
工作目录/data(VOLUME ["/data"],GITEA_CUSTOM=/data/gitea)/var/lib/gitea(GITEA_WORK_DIR、GITEA_APP_INI=/etc/gitea/app.ini)WorkingDirectory=/var/lib/gitea/ + --config /etc/gitea/app.ini源码树
配置注入environment-to-ini(GITEA__section__KEY 环境变量 → config edit-ini --apply-env)同左app.ini 直接编辑custom/conf/app.ini
资源嵌入TAGS="bindata timetzdata"同左视构建命令默认可不带
适用通用生产、多服务编排安全敏感、共享宿主机裸机/VM 长期运行开发

15.7.2 普通镜像:三阶段 Dockerfile 逐段

# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.27-alpine3.24 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY --exclude=.git/ . .
RUN make frontend

frontend-build 强制 $BUILDPLATFORM:多架构构建时目标平台走 QEMU 模拟,而 node 生态在模拟下极慢且易出问题——前端产物是平台无关的,在构建机原生跑一次即可复用给所有架构。

FROM docker.io/library/golang:1.27-alpine3.24 AS build-env
ARG GITEA_VERSION
ARG TAGS=""
ENV TAGS="bindata timetzdata $TAGS"
…
COPY go.mod go.sum ./
RUN go mod download
# Use COPY instead of bind mount as read-only one breaks makefile state tracking …
# ".git" directory is mounted separately later only for version data extraction.
COPY --exclude=.git/ . .
COPY --from=frontend-build /src/public/assets public/assets

# Build gitea, .git mount is required for version data
RUN --mount=type=cache,target="/root/.cache/go-build" \
    --mount=type=bind,source=".git/",target=".git/" \
    make backend

三个讲究:依赖层单独 go mod download(go.mod 不变则缓存命中);COPY 排除 .git 但 bind-mount 单独挂回去——15.6.3 讲的 git describe 需要 .git,可整目录 COPY 又会破坏构建缓存/许可,于是只在跑 make backend 那一条命令时以只读方式挂载;Go 构建缓存用 --mount=type=cache 跨次构建复用。

FROM docker.io/library/alpine:3.24 AS gitea
EXPOSE 22 3000
RUN apk --no-cache add bash ca-certificates curl gettext git linux-pam openssh s6 sqlite su-exec gnupg
RUN addgroup -S -g 1000 git && adduser -S -H -D -h /data/git -s /bin/bash -u 1000 -G git git && echo "git:*" | chpasswd -e
COPY --from=build-env /tmp/local /
COPY --from=build-env /go/src/gitea.dev/gitea /app/gitea/gitea
ENV USER=git
ENV GITEA_CUSTOM=/data/gitea
VOLUME ["/data"]
# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK, search this hint keyword for more information
ENTRYPOINT ["/usr/bin/entrypoint"]
CMD ["/usr/bin/s6-svscan", "/etc/s6"]

运行时只带必要系统包(git、openssh、s6、su-exec)。docker/root/ 提供全套运行脚本:

  • usr/bin/entrypoint:按 USER/USER_UID/USER_GID 调整 passwd/group(支持自定义运行用户与宿主机 uid 对齐),创建 /data/{gitea/conf,gitea/log,git,ssh},最后 exec /usr/bin/s6-svscan /etc/s6;
  • etc/s6/gitea/run:exec su-exec $USER /usr/local/bin/gitea web——降权发生在服务脚本里,s6 本身以 root 跑以便管理 sshd;
  • etc/s6/openssh/run + etc/templates/sshd_config:容器内置 git-shell SSH 服务(挂 22 端口);
  • usr/local/bin/environment-to-ini:一行转发——exec /app/gitea/gitea config edit-ini --in-place --apply-env "$@",把 GITEA__section__KEY 形式的环境变量写进 app.ini(cmd/config.go 的 --apply-env:"Apply all GITEA__* variables from the environment to the config."),实现"不进容器改配置文件"的容器化配置管理。

# HINT: HEALTH-CHECK-ENDPOINT: don't use HEALTHCHECK 这行注释是刻意的——对应 15.8.4 里 healthz 源码的警告:不要用 Docker HEALTHCHECK 重启 Gitea,迁移期间健康检查长时间不"pass"会导致重启风暴。

15.7.3 rootless 镜像的原理

Dockerfile.rootless 与普通镜像共享前两个阶段,差异全在运行阶段:

FROM docker.io/library/alpine:3.24 AS gitea-rootless
EXPOSE 2222 3000
RUN apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-keygen
…
RUN mkdir -p /var/lib/gitea /etc/gitea
RUN chown git:git /var/lib/gitea /etc/gitea
COPY --from=build-env --chown=root:root /go/src/gitea.dev/gitea /app/gitea/gitea
# git:git
USER 1000:1000
ENV GITEA_WORK_DIR=/var/lib/gitea
ENV GITEA_CUSTOM=/var/lib/gitea/custom
ENV GITEA_TEMP=/tmp/gitea
ENV TMPDIR=/tmp/gitea
ENV GITEA_APP_INI=/etc/gitea/app.ini
ENV HOME="/var/lib/gitea/git"
VOLUME ["/var/lib/gitea", "/etc/gitea"]
WORKDIR /var/lib/gitea
ENTRYPOINT ["/usr/bin/dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"]

rootless 的四条实现要点:

  1. USER 1000:1000 从入口起就不是 root——容器内无 su-exec 降权环节,即使进程被攻破也只是普通用户;代价是无法绑定 <1024 端口,所以 SSH 改为 2222(EXPOSE 2222 3000),HTTP 保持 3000;
  2. dumb-init 作为 PID1(替代 s6):负责信号转发与僵尸进程回收——单进程模型下 s6 反而多余;
  3. 二进制 --chown=root:root,数据目录 git:git:二进制对运行用户只读,防篡改;数据卷可写;
  4. docker-setup.sh 在 entrypoint 里先行,把宿主机挂进来的 volume 权限修成 1000:1000 可写(rootless 容器最常见的启动失败就是 volume 属主不对)。

docker/manifest.rootless.tmpl 与 manifest.tmpl 是多架构 manifest 清单模板,manifest.tmpl 覆盖 linux/amd64、linux/arm64、linux/riscv64 三平台(riscv64 出现在官方镜像里是 Gitea 的一个特色)。

15.7.4 二进制部署与 init 系统

裸机部署的资产在 contrib/service/,覆盖 9 种 init 系统:systemd、sysvinit、openrc(gentoo)、freebsd、openbsd、sunos、launchd(macOS)、openwrt、supervisor。systemd 单元的骨架(contrib/service/systemd/gitea.service):

[Service]
RestartSec=2s
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea

要点:GITEA_WORK_DIR 只在这一处设置(呼应第 2 章"WORK_PATH 只能来自 config 或统一来源"的告警逻辑);ExecStart 显式 --config 避免依赖默认查找路径;文件里还留着成体系的注释样板——数据库依赖(Wants=postgresql.service)、socket activation、LimitNOFILE("if you have repos with lots of files and get a HTTP 500")、绑定特权端口的 CAP_NET_BIND_SERVICE——每条注释都对应一类真实故障,是运维经验的文档化。

15.7.5 反向代理:ROOT_URL 与可信代理链

把 Gitea 放到 nginx/caddy 后面时有两件事必须配置对,源码各有一处对应。

其一,[server] ROOT_URL 决定生成的绝对链接(克隆地址、回调、Webhook 目标)。它必须是外部可访问的完整 URL。

其二,可信代理与转发头。modules/setting/security.go:

ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1)
…
ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",")
if len(ReverseProxyTrustedProxies) == 0 {
	ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"}
}

默认只信任回环地址、默认转发深度 1(一层代理)。这两个默认值是安全边界:不配置就等于"只信本机反代",公网直连时伪造的 X-Forwarded-For 不会被采信——否则任何攻击者都能伪造来源 IP 绕过限流/审计。应用侧(routers/common/middleware.go):

if setting.ReverseProxyLimit > 0 && len(setting.ReverseProxyTrustedProxies) > 0 {
	handlers = append(handlers, ForwardedHeadersHandler(setting.ReverseProxyLimit, setting.ReverseProxyTrustedProxies))
}
…
func ForwardedHeadersHandler(limit int, trustedProxies []string) func(h http.Handler) http.Handler {
	opt := proxy.NewForwardedHeadersOptions().WithForwardLimit(limit).ClearTrustedProxies()
	for _, n := range trustedProxies {
		if !strings.Contains(n, "/") {
			opt.AddTrustedProxy(n)
		} else {
			opt.AddTrustedNetwork(n)
		}
	}
	return proxy.ForwardedHeaders(opt)
}

chi-middleware/proxy 的转发头处理器:按条目区分单 IP(AddTrustedProxy)与 CIDR(AddTrustedNetwork),ClearTrustedProxies 清掉库的默认信任表、只信显式配置,WithForwardLimit 限制转发链长度。多一层代理(CDN → nginx → gitea)时必须把 REVERSE_PROXY_LIMIT 调成实际层数,否则真实客户端 IP 会算错——这是反代部署最常见的排障点。

中间件的装载顺序也有讲究(ProtocolMiddlewares 注释 "the order is important"):ChiRoutePathHandler → RequestContextHandler(含 panic 恢复)→ SecurityHeadersHandler → ForwardedHeadersHandler → NewRequestInfoHandler → AccessLogger → (非 prod)ViteDevMiddleware。转发头处理排在 panic 恢复之后、请求信息解析之前,保证后续所有环节(包括错误页)拿到的都是修正后的客户端地址。

15.7.6 cookie 与 HTTPS

反代终止 TLS 时,Gitea 必须知道外层是 HTTPS 才会给会话 cookie 打 Secure 标(否则浏览器拒绝携带 cookie,表现为"登录后仍跳登录页")。modules/setting/session.go 的默认逻辑:

SessionConfig.Secure = sec.Key("COOKIE_SECURE").MustBool(strings.HasPrefix(strings.ToLower(AppURL), "https://"))

默认值不是写死的 false,而是跟随 ROOT_URL 的协议。app.example.ini 里 ;COOKIE_SECURE = 处于注释状态——保持默认即可在正确配置 ROOT_URL=https://… 时自动安全;只有外层代理做了奇怪的 URL 重写时才需要显式覆盖。


15.8 升级与迁移运维

Gitea 把运维能力做成了二进制子命令而不是外部脚本:备份(dump)、体检(doctor)、单仓库迁移(dump-repo/restore-repo)、数据库升级(migrate)、存储搬迁(migrate-storage)、在线管理(manager)全部躺在 cmd/ 目录里,每个都能在源码中逐行对照。监控面则挂在根路由上(/metrics、/api/healthz)与独立的 pprof 端口。本节按"备份 → 体检 → 恢复 → 升级 → 搬家 → 在线操作 → 监控"的运维时序展开。

15.8.1 gitea dump:一次全实例打包的 14 个开关

核实与纠正(第 3 处):cmd/dump.go 的 newDumpCommand() 实际注册的是 14 个 flag(不是流传中的 11 个)。全部 flag 与 Usage 原文:

flag别名默认值Usage(原文摘录)
--file-fgitea-dump-{time}.zipName of the dump file which will be created… Supply '-' for stdout. See type for available types.
--verbose-VfalseShow process details
--quiet-qfalseOnly display warnings and errors
--tempdir-tos.TempDir()Temporary dir path
--database-d空Specify the database SQL syntax: sqlite3, mysql, mssql, postgres
--skip-repository-RfalseSkip the repository dumping
--skip-log-LfalseSkip the log dumping
--skip-custom-dir—falseSkip custom directory
--skip-lfs-data—falseSkip LFS data
--skip-attachment-data—falseSkip attachment data
--skip-package-data—falseSkip package data
--skip-index—falseSkip bleve index data
--skip-db—falseSkip database
--type—zipDump output format, default to "zip", supported types: + strings.Join(dump.SupportedOutputTypes, ", ")

打包顺序就是 runDump(cmd/dump.go:105)的书写顺序,逐段看仓库与 LFS 部分:

	if cmd.IsSet("skip-repository") && cmd.Bool("skip-repository") {
		log.Info("Skip dumping local repositories")
	} else {
		log.Info("Dumping local repositories... %s", setting.RepoRootPath)
		if err := dumper.AddRecursiveExclude("repos", setting.RepoRootPath, nil); err != nil {
			fatal("Failed to include repositories: %v", err)
		}

		if cmd.IsSet("skip-lfs-data") && cmd.Bool("skip-lfs-data") {
			log.Info("Skip dumping LFS data")
		} else if !setting.LFS.StartServer {
			log.Info("LFS isn't enabled. Skip dumping LFS data")
		} else if err := storage.LFS.IterateObjects("", func(objPath string, object storage.Object) error {
			...
			return dumper.AddFileByReader(object, info, path.Join("data", "lfs", objPath))
		}); err != nil {

三个细节:(1) 每个 skip 判断都写成 cmd.IsSet(...) && cmd.Bool(...) 的双条件——CLI 框架里未设置的 bool flag 读出 false,与"显式传了 --skip-lfs-data=false"无法区分,IsSet 把两者分开,日志才不会误导;(2) LFS 段有独立的第三分支 !setting.LFS.StartServer——没开 LFS 服务时即使没传 skip 也主动跳过,因为 storage.LFS 可能未初始化,直接 IterateObjects 会失败;(3) LFS 对象不是打包原始目录,而是走 storage 抽象 IterateObjects 逐对象读流(AddFileByReader),所以本地/S3/Minio 后端的 LFS 数据都能被 dump——dump 面向的是 storage 接口而非文件系统路径。

数据库段(cmd/dump.go:185):

	if cmd.Bool("skip-db") {
		// Ensure that we don't dump the database file that may reside in setting.AppDataPath or elsewhere.
		dumper.GlobalExcludeAbsPath(setting.Database.Path)
		log.Info("Skipping database")
	} else {
		tmpDir := cmd.String("tempdir")
		...
		dbDump, err := os.CreateTemp(tmpDir, "gitea-db.sql")
		...
		targetDBType := setting.DatabaseType(cmd.String("database"))
		if targetDBType != "" && targetDBType != setting.Database.Type {
			log.Info("Dumping database %s => %s...", setting.Database.Type, targetDBType)
		} else {
			log.Info("Dumping database...")
		}

		if err := db.DumpDatabase(dbDump.Name(), targetDBType); err != nil {
			fatal("Failed to dump database: %v", err)
		}

		if err = dumper.AddFileByPath("gitea-db.sql", dbDump.Name()); err != nil {

--database 的语义是跨方言转储:源库是 MySQL、传 --database postgres 就产出 PostgreSQL 语法的 gitea-db.sql(db.DumpDatabase 在 models/db/engine_dump.go:13),这是"换库迁移"的官方路径之一。skip-db 分支里的 GlobalExcludeAbsPath(setting.Database.Path) 是防呆——sqlite 的库文件通常就住在 data/ 下,不排除掉会把"跳过数据库"变成"明文把库文件打进 data 段"。

data 目录段(cmd/dump.go:244)先构造排除表再整体打包:

		var excludes []string
		if setting.SessionConfig.OriginalProvider == "file" {
			var opts session.Options
			if err = json.Unmarshal([]byte(setting.SessionConfig.ProviderConfig), &opts); err != nil {
				return err
			}
			excludes = append(excludes, opts.ProviderConfig)
		}

		if cmd.IsSet("skip-index") && cmd.Bool("skip-index") {
			excludes = append(excludes, setting.Indexer.RepoPath)
			excludes = append(excludes, setting.Indexer.IssuePath)
		}

		excludes = append(excludes, setting.RepoRootPath)
		excludes = append(excludes, setting.LFS.Storage.Path)
		excludes = append(excludes, setting.Attachment.Storage.Path)
		excludes = append(excludes, setting.Packages.Storage.Path)
		excludes = append(excludes, setting.RepoArchive.Storage.Path)
		excludes = append(excludes, setting.Log.RootPath)
		if err := dumper.AddRecursiveExclude("data", setting.AppDataPath, excludes); err != nil {

排除表的动机一句话:这些路径要么已经在别的段打过包(repos、LFS、attachments、packages、log),要么是本段内需要按 skip 开关单独控制的(index、file session 目录),留在 data 段里会造成重复归档或绕过 skip。dump 收尾把产物 chmod 0600(cmd/dump.go:322),因为归档里含 app.ini(SECRET_KEY、INTERNAL_TOKEN)与明文 SQL。

输出格式由 modules/dump/dumper.go 的 SupportedOutputTypes 给出,共 9 种:

var SupportedOutputTypes = []string{"zip", "tar", "tar.sz", "tar.gz", "tar.xz", "tar.bz2", "tar.br", "tar.lz4", "tar.zst"}

--file - 时写 stdout(runDump 开头 outFile := os.Stdout,其余分支才 os.Create + 绝对路径化 + 存在性检查——存在即 fatal("Dump file %q exists"),绝不覆盖已有备份)。归档内顶层结构与打包段一一对应:repos/、data/lfs/、data/attachments/、data/packages/、gitea-db.sql、app.ini、custom/、data/(剩余)、log/。

15.8.2 gitea doctor:13 个检查项与"默认什么都不跑"

gitea doctor 有三个子命令(cmd/doctor.go:31):check(体检/修复)、recreate-table(重建表)、convert(字符集/列类型转换)。检查项本身是注册表模式,services/doctor/doctor.go:21 的结构体:

// Check represents a Doctor check
type Check struct {
	Title                      string
	Name                       string
	IsDefault                  bool
	Run                        func(ctx context.Context, logger log.Logger, autofix bool) error
	AbortIfFailed              bool
	SkipDatabaseInitialization bool
	Priority                   int
	InitStorage                bool
}

// Register registers a command with the list
func Register(command *Check) {
	Checks = append(Checks, command)
}

全仓 Register(&Check{ 共 13 处,分布:dbconsistency.go 1、storage.go 6、misc.go 4、repository.go 1、lfs.go 1(另有 cmd/doctor_test.go 的测试注册不计入)。13 项清单:

#NameTitle源文件
1check-db-consistencyCheck consistency of databaseservices/doctor/dbconsistency.go:270
2hooksCheck if hook files are up-to-date and executableservices/doctor/misc.go:197
3recalculate-stars-numberRecalculate Stars number for all userservices/doctor/misc.go:204
4check-git-daemon-export-okCheck git-daemon-export-ok filesservices/doctor/misc.go:211
5check-commit-graphsCheck commit-graphsservices/doctor/misc.go:218
6delete-orphaned-reposDeleted all content related to orphaned reposservices/doctor/repository.go:71
7gc-lfsGarbage collect LFSservices/doctor/lfs.go:17
8storagesCheck if there are orphaned storage filesservices/doctor/storage.go:210
9storage-attachments…orphaned attachments in storageservices/doctor/storage.go:220
10storage-lfs…orphaned lfs files in storageservices/doctor/storage.go:230
11storage-avatars…orphaned avatars in storageservices/doctor/storage.go:240
12storage-archives…orphaned archives in storageservices/doctor/storage.go:250
13storage-packages…orphaned package blobs in storageservices/doctor/storage.go:260

核实与纠正(第 4 处):13 处 Register(&Check{ 全部写死 IsDefault: false。而 runDoctorCheck(cmd/doctor.go:210)的默认分支只收集 check.IsDefault 的项——所以裸跑 gitea doctor check 的实际结果是选中 0 项,输出 All done (checks: 0).,不会碰数据库。想真的体检必须显式 --run <name> 或 --all。--default flag 的 Usage 写着 Run the default checks (if neither --run or --all is set, this is the default behaviour)——描述与代码一致,但"default 集合"当前为空,这是个容易踩的空语义。

选择逻辑全文(cmd/doctor.go:194):

	var checks []*doctor.Check
	if cmd.Bool("all") {
		checks = make([]*doctor.Check, len(doctor.Checks))
		copy(checks, doctor.Checks)
	} else if cmd.IsSet("run") {
		addDefault := cmd.Bool("default")
		runNamesSet := container.SetOf(cmd.StringSlice("run")...)
		for _, check := range doctor.Checks {
			if (addDefault && check.IsDefault) || runNamesSet.Contains(check.Name) {
				checks = append(checks, check)
				runNamesSet.Remove(check.Name)
			}
		}
		if len(runNamesSet) > 0 {
			return fmt.Errorf("unknown checks: %q", strings.Join(runNamesSet.Values(), ","))
		}
	} else {
		for _, check := range doctor.Checks {
			if check.IsDefault {
				checks = append(checks, check)
			}
		}
	}
	return doctor.RunChecks(ctx, colorize, cmd.Bool("fix"), checks)

runNamesSet.Remove + 尾部 len(runNamesSet) > 0 检查是一个惯用手法:跑完还剩名字就说明用户拼错了检查名,直接 unknown checks: "xxx" 报错而不是静默跳过。--list 用 tabwriter 输出三列(Default/Name/Title),默认项前打 *(cmd/doctor.go:177)——当前 13 行的 Default 列全空,直观暴露了上面的纠正点。

执行引擎 RunChecks(services/doctor/doctor.go:83)有两个设计点:

	for i, check := range checks {
		if !dbIsInit && !check.SkipDatabaseInitialization {
			// Only open database after the most basic configuration check
			if err := initDBSkipLogger(ctx); err != nil {
				logger.Error("Error whilst initializing the database: %v", err)
				logger.Error("Check if you are using the right config file. You can use a --config directive to specify one.")
				return nil
			}
			dbIsInit = true
		}
		if !storageIsInit && check.InitStorage {
			...
		}
		logger.Info("\n[%d] %s", i+1, check.Title)
		if err := check.Run(ctx, loggerStep, autofix); err != nil {
			if check.AbortIfFailed {
				logger.Critical("FAIL")
				return err
			}
			logger.Error("ERROR")
		} else {
			logger.Info("OK")
		}
	}
	logger.Info("\nAll done (checks: %d).", len(checks))

(1)懒初始化:数据库/存储直到遇到第一个需要它的检查才打开——SkipDatabaseInitialization 为 true 的"最基本配置检查"可以脱离 DB 跑,配置文件写错时给出的是人话提示(--config directive)而非 xorm 报错栈;(2)失败分级:普通失败打 ERROR 继续跑(体检要尽量给出全部问题),只有 AbortIfFailed 的关键项才 Critical("FAIL") 并中止(后续检查依赖它,继续跑没意义)。此外每个 check 的输出走专用 doctorCheckLogger(直接 fmt.Fprintf(os.Stdout)),不进默认日志——--log-file 不传时控制台只有检查结果,不被 INFO 日志刷屏。

排序 SortChecks(doctor.go:128):

func SortChecks(checks []*Check) {
	sort.SliceStable(checks, func(i, j int) bool {
		if checks[i].Priority == checks[j].Priority {
			return checks[i].Name < checks[j].Name
		}
		if checks[i].Priority == 0 {
			return false
		}
		return checks[i].Priority < checks[j].Priority
	})
}

规则两条:同 Priority 按 Name 字典序(SliceStable 保证输入序在完全相同时不被打乱);Priority == 0 永远排最后(if checks[i].Priority == 0 { return false }——0 号即使与非 0 号比较也不抢先)。13 项全部显式设了 Priority,按数值分组即实际输出顺序:

Priority检查项(组内按字典序)语义
1gc-lfs、storage-archives、storage-attachments、storage-avatars、storage-lfs、storage-packages、storages存储/对象层(最底层,先查)
3check-db-consistency数据库一致性
4delete-orphaned-repos仓库删除(依赖一致性结果)
6hooks、recalculate-stars-numberhook 文件与计数缓存
8check-git-daemon-export-okgit-daemon 导出标记
9check-commit-graphscommit-graph 文件(最上层,最后查)

即 gitea doctor check --all 的执行序是 gc-lfs → 6 个 storage-* → check-db-consistency → delete-orphaned-repos → hooks → recalculate-stars-number → check-git-daemon-export-ok → check-commit-graphs——先保证字节层面完好,再校验数据库,最后才做派生数据(计数、graph),依赖方向清晰。

check-db-consistency 是唯一的"检查组"——它内部跑 prepareDBConsistencyChecks()(dbconsistency.go:79)的 20 余个子项,每项一对 Counter/Fixer 函数,例如:

		{
			// find labels without existing repo or org
			Name:    "Orphaned Labels without existing repository or organisation",
			Counter: issues_model.CountOrphanedLabels,
			Fixer:   asFixer(issues_model.DeleteOrphanedLabels),
		},
		...
		// find releases without existing repository
		genericOrphanCheck("Orphaned Releases without existing repository",
			"release", "repository", "`release`.repo_id=repository.id"),

还有方言专属项——只有 PostgreSQL 才追加序列检查:

	if setting.Database.Type.IsPostgreSQL() {
		consistencyChecks = append(consistencyChecks, consistencyCheck{
			Name:         "Sequence values",
			Counter:      db.CountBadSequences,
			Fixer:        asFixer(db.FixBadSequences),
			FixedMessage: "Updated",
		})
	}

Counter/Fixer 成对的结构让"只看不改"与"修复"共用同一段检测逻辑:不带 --fix 时只跑 Counter 打印计数,带 --fix 才执行 Fixer。

15.8.3 recreate-table 与 convert:结构性修复

gitea doctor recreate-table [TABLE]... 处理的是"表结构定义随版本变化、旧列遗留"问题,其 Description 直接给出运维红线:

The database definitions Gitea uses change across versions, sometimes changing default values and leaving old unused columns.

This command will cause Xorm to recreate tables, copying over the data and deleting the old table.

You should back-up your database before doing this and ensure that your database is up-to-date first.

两句话约束:先备份(对应 15.8.1 的 dump)、先确保 schema 是最新的(对应 15.8.5 的 migrate)。实现链(cmd/doctor.go:128)三步:

	beans, err := db.NamesToBean(names...)
	if err != nil {
		return err
	}
	recreateTables := base.RecreateTables(beans...)

	return db.InitEngineWithMigration(context.Background(), func(ctx context.Context, x db.EngineMigration) error {
		if err := modelmigration.EnsureUpToDate(ctx, x); err != nil {
			return err
		}
		return recreateTables(x)
	})

NamesToBean(models/db/engine.go:132)把表名参数映射到 XORM bean,空参数即全部表;迁移函数内部先 EnsureUpToDate 再重建——保证不会基于旧定义去"重建"出一个过时的表,这与第 4 章讲的版本化迁移入口是同一套机制。--debug 会把 SQL 日志开到 DEBUG 级(setting.Database.LogSQL = debug),出问题可拿到每条 CREATE/INSERT/DROP。

gitea doctor convert(cmd/doctor_convert.go)是另一个窄工具,只服务两类历史包袱:

	switch {
	case setting.Database.Type.IsMySQL():
		if err := db.ConvertDatabaseTable(); err != nil {
			...
		}
		cprintln(cmd, "Converted successfully, please confirm your database's character set is now utf8mb4")
	case setting.Database.Type.IsMSSQL():
		if err := db.ConvertVarcharToNVarchar(); err != nil {
			...
		}
		cprintln(cmd, "Converted successfully, please confirm your database's all columns character is NVARCHAR now")
	default:
		cprintln(cmd, "This command can only be used with a MySQL or MSSQL database")
	}

MySQL 老库 utf8→utf8mb4、MSSQL varchar→nvarchar(两者都影响索引长度与 emoji 存储),完成后还要人工到库里确认——工具只负责发起转换,验证交给人。

15.8.4 单仓库迁移对:dump-repo / restore-repo

gitea dump 是全实例快照,而"把一个仓库搬到另一套 Gitea"由一对命令完成。gitea dump-repo(cmd/dump_repo.go:26)反向利用第 4 章讲过的迁移框架——它不是导出本地数据,而是以迁移器身份从远端拉取:

	var (
		serviceType structs.GitServiceType
		cloneAddr   = cmd.String("clone_addr")
		serviceStr  = cmd.String("git_service")
	)

	if strings.HasPrefix(strings.ToLower(cloneAddr), "https://github.com/") {
		serviceStr = "github"
	} else if strings.HasPrefix(strings.ToLower(cloneAddr), "https://gitlab.com/") {
		serviceStr = "gitlab"
	} else if strings.HasPrefix(strings.ToLower(cloneAddr), "https://gitea.com/") {
		serviceStr = "gitea"
	}
	if serviceStr == "" {
		return errors.New("git_service missed or clone_addr cannot be recognized")
	}

clone_addr 能从 URL 前缀猜出服务类型(github/gitlab/gitea),猜不出才要求 --git_service;--units 支持 wiki, issues, labels, releases, release_assets, milestones, pull_requests, comments,不传则全开(cmd/dump_repo.go:135 的 opts.Wiki = true … opts.PullRequests = true 八连赋值)。调用入口是 migrations.DumpRepository(ctx, repoDir, ownerName, opts)(:186),与 Web 端"迁移仓库"共用 services/migrations 的上传器实现。

写盘前有一段防呆注释值得原样引用(cmd/dump_repo.go:172):

	// the repo_dir will be removed if error occurs in DumpRepository
	// make sure the directory doesn't exist or is empty, prevent from deleting user files
	repoDir := cmd.String("repo_dir")
	if exists, err := util.IsExist(repoDir); err != nil {
		return fmt.Errorf("unable to stat repo_dir %q: %w", repoDir, err)
	} else if exists {
		if isDir, _ := util.IsDir(repoDir); !isDir {
			return fmt.Errorf("repo_dir %q already exists but it's not a directory", repoDir)
		}
		if dir, err := os.ReadDir(repoDir); len(dir) > 0 {
			return fmt.Errorf("repo_dir %q is not empty", repoDir)
		}
	}

失败路径会删除 repo_dir,所以目录里但凡有一个文件就拒绝执行——典型的"把破坏性操作的前置条件在入口处证伪"。

gitea restore-repo(cmd/restore_repo.go)恢复方向则不直连数据库,而是调用运行中实例的 internal API:

func runRestoreRepository(ctx context.Context, c *cli.Command) error {
	setting.MustInstalled()
	var units []string
	if s := c.String("units"); s != "" {
		units = strings.Split(s, ",")
	}
	extra := private.RestoreRepo(
		ctx,
		c.String("repo_dir"),
		c.String("owner_name"),
		c.String("repo_name"),
		units,
		c.Bool("validation"),
	)
	return handleCliResponseExtra(extra)
}

modules/private/restore_repo.go:23 把它变成一个 HTTP POST:

func RestoreRepo(ctx context.Context, repoDir, ownerName, repoName string, units []string, validation bool) ResponseExtra {
	reqURL := setting.LocalURL + "api/internal/restore_repo"

	req := newInternalRequestAPI(ctx, reqURL, "POST", RestoreParams{
		RepoDir:    repoDir,
		...
	})
	req.SetReadWriteTimeout(0) // since the request will spend much time, don't timeout

服务端在 routers/private/internal.go:99 注册 r.Post("/restore_repo", RestoreRepo),落到 routers/private/restore_repo.go:34 的 migrations.RestoreRepository(...)。为什么绕一圈 HTTP:恢复逻辑要走完整 services 层(建仓库、写 issue/label/release、刷索引、发 notify),CLI 进程直接写库会绕过这些副作用;而 internal API 正是第 9 章讲过的、依赖 INTERNAL_TOKEN 的本机通道。SetReadWriteTimeout(0) 那行注释 since the request will spend much time, don't timeout 也说明恢复是长任务,读写超时必须关掉。--validation 开关对应 RestoreParams.Validation,加载前对文件内容做 sanity check。

15.8.5 升级流程:gitea migrate 与三分支决策(回扣第 4 章)

gitea migrate 的全部实现只有 20 行(cmd/migrate.go:26):

func runMigrate(ctx context.Context, c *cli.Command) error {
	if err := initDB(ctx); err != nil {
		return err
	}

	log.Info("AppPath: %s", setting.AppPath)
	...
	if err := db.InitEngineWithMigration(context.Background(), versioned_migration.Migrate); err != nil {
		log.Fatal("Failed to initialize ORM engine: %v", err)
		return nil
	}

	return nil
}

它的 Description 点明存在意义:This is a command for migrating the database, so that you can run "gitea admin create user" before starting the server.——先迁移后起服的场景(初始化实例、或关闭自动迁移的生产环境)。什么时候必须手动跑?第 4 章 4.7 节详细拆过的 migrateWithSetting(routers/common/db.go:43)决定:

func migrateWithSetting(ctx context.Context, x db.EngineMigration) error {
	if setting.Database.AutoMigration {
		return versioned_migration.Migrate(ctx, x)
	}

	if current, err := modelmigration.GetCurrentDBVersion(x); err != nil {
		return err
	} else if current < 0 {
		// execute migrations when the database isn't initialized even if AutoMigration is false
		return versioned_migration.Migrate(ctx, x)
	} else if expected := modelmigration.ExpectedDBVersion(); current != expected {
		log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+
			`You can set "database.AUTO_MIGRATION" to true or migrate manually by running "gitea [--config /path/to/app.ini] migrate"`, current, expected)
	}
	return nil
}

三分支语义(第 4 章 4.7 已展开,此处只回收结论):AUTO_MIGRATION=true(默认)启动即迁移;未初始化的库即使关了开关也迁移(否则装不上);已初始化且版本落后则 log.Fatal 拒绝启动,提示语给出两条出路——开开关或跑 gitea migrate。多实例并发启动的打架问题由 services/versioned_migration 先抢全局锁 globallock.Lock(ctx, "gitea_versioned_migration") 解决(第 4 章 4.7 的时序图)。

一个规范的升级操作序列(sqlite 单机为例):

# 1) 进入维护状态并停服(manager 面板子命令,见 15.8.7)
gitea manager shutdown
# 2) 全量备份:仓库 + 数据库 SQL + app.ini + custom + data + log
gitea dump --file gitea-dump-before-upgrade.zip
# 3) 替换二进制(保留旧文件以便回滚),确认版本注入
./gitea --version
# 4) 关闭自动迁移的环境先手动迁移(默认开启则由启动流程代劳)
gitea migrate
# 5) 体检:默认 0 项,必须显式指定
gitea doctor check --all
# 6) 起服并核对监控面(第 3 章 3.2.5)
gitea web
curl -fsS http://127.0.0.1:3000/api/healthz

注意第 5 步——依据 15.8.2 的纠正,--all 不是可选项而是必需项。

15.8.6 gitea migrate-storage:8 类文件 × 3 后端

存储后端从本地盘换到 S3 由 cmd/migrate_storage.go:28 处理,flag 定义直接给出能力边界:

			&cli.StringFlag{
				Name:    "type",
				Aliases: []string{"t"},
				Value:   "",
				Usage:   "Type of stored files to copy.  Allowed types: 'attachments', 'lfs', 'avatars', 'repo-avatars', 'repo-archivers', 'packages', 'actions-log', 'actions-artifacts'",
			},
			&cli.StringFlag{
				Name:    "storage",
				Aliases: []string{"s"},
				Value:   "",
				Usage:   "New storage type: local (default), minio or azureblob",
			},

即 8 类文件(attachments、lfs、avatars、repo-avatars、repo-archivers、packages、actions-log、actions-artifacts)× 3 种目标后端(local/minio/azureblob,minio 一组 5 个专属 flag:--minio-endpoint/-access-key-id/-secret-access-key/-bucket/-location,外加 --minio-base-path 与 --path)。它是拷贝而非搬迁(Description:Copies stored files from storage configured in app.ini to parameter-configured storage)——旧数据仍在原地,切换 app.ini 前可反复校验,切换后旧目录留作回滚。这个命令也解释了 15.8.1 中 dump 为什么必须走 storage 抽象:同一份数据存在三种后端上,dump/migrate/restore 都只认 storage.Xxx.IterateObjects 这一个口。

15.8.7 gitea manager:不重启的运维动作

cmd/manager.go 注册 5 个子命令,全部通过 internal API 作用于正在运行的进程(cmd/manager.go:18 Usage:Manage the running gitea process):

子命令行号Usage(原文摘录)关键 flags
shutdown:34Gracefully shutdown the running process--debug
restart:47Gracefully restart the running process - (not implemented for windows servers)--debug
reload-templates:60Reload template files in the running process--debug
flush-queues:73Flush queues in the running process--timeout、--non-blocking、--debug
processes:95Display running processes within the current process--flat、--no-system、--stacktraces、--json、--cancel

restart 的 Usage 主动声明 Windows 不实现(Windows 上没有无中断 exec 的常规手段);flush-queues 背后正是第 1 章 1.6.4 提到的 queue.GetManager().FlushAll(ctx, timeout);processes 能看 goroutine 运行树并按 PID cancel(仅限非 system 进程)——这是第 10 章讲的 process.GetManager() 的运维视图。

15.8.8 监控三件套:/metrics、/api/healthz、pprof(回扣第 3 章 3.2.5)

第 3 章 3.2.5 已从路由角度讲过三者的挂载位置,这里补齐运维视角。挂载代码(routers/web/web.go:286):

	if setting.Metrics.Enabled {
		prometheus.MustRegister(metrics.NewCollector())
		routes.Get("/metrics", append(mid, Metrics)...)
	}
	...
	routes.Get("/api/healthz", healthcheck.Check)

(1)Prometheus /metrics:collector 是第 1 章横切机制的一员(modules/metrics),启用由 [metrics] ENABLED 控制。抓取鉴权在 routers/web/metrics.go:16:

func Metrics(resp http.ResponseWriter, req *http.Request) {
	if setting.Metrics.Token == "" {
		promhttp.Handler().ServeHTTP(resp, req)
		return
	}
	header := req.Header.Get("Authorization")
	if header == "" {
		http.Error(resp, "", http.StatusUnauthorized)
		return
	}
	got := []byte(header)
	want := []byte("Bearer " + setting.Metrics.Token)
	if subtle.ConstantTimeCompare(got, want) != 1 {
		http.Error(resp, "", http.StatusUnauthorized)
		return
	}
	promhttp.Handler().ServeHTTP(resp, req)
}

Token 空 = 匿名可抓(适合内网);设了 token 就连比较都用 subtle.ConstantTimeCompare——即使判断逻辑本身有分支,字节比较这步不泄漏时序信息(15.9 会看到同一手法用在密码验证上)。运维口径:Prometheus 侧配 authorization: {credentials: <token>, type: Bearer}。

(2)健康检查 /api/healthz:遵循 IETF 草案 draft-inadarei-api-health-check(routers/web/healthcheck/check.go:26 引用其 §3.1),三态 pass/fail/warn,ToHTTPStatus() 把 fail 映射到 424 Failed Dependency、pass/warn 映射 200:

func (s status) ToHTTPStatus() int {
	if s == pass || s == warn {
		return http.StatusOK
	}
	return http.StatusFailedDependency
}

检查项只有两个:checkDatabase(db.GetEngine(ctx).Ping(),sqlite 额外 os.Stat 库文件——文件被删了 ping 未必能发现)与 checkCache(cache.GetCache().Ping()),且只在 setting.InstallLock 后才跑(未安装实例恒 pass,避免安装向导被探针打断)。handler 上的运维警告是源码里最长的一段注释之一(check.go:68),值得逐句读:

// HINT: HEALTH-CHECK-ENDPOINT: there is no clear definition about what "health" means.
// In most cases, end users don't need to check such endpoint, because even if database is down,
// Gitea will reover after database is up again. Sysop should monitor database and cache status directly.
//
// And keep in mind: this health check should NEVER be used as a "restart" trigger, for example: Docker's "HEALTHCHECK".
// * If Gitea is upgrading and migrating database, there will be a long time before this endpoint starts to return "pass" status.
// In this case, if the checker restarts Gitea just because it doesn't get "pass" status in short time,
// the instance will be restarted again and again before the migration finishes and the situation just goes worse.

即:/api/healthz 是"现在能不能服务"的观测点,不是"该不该重启"的判据。用它做容器 HEALTHCHECK 会在数据库迁移窗口触发重启风暴,把一次慢升级演变成起不来的事故。正确姿势是监控 DB/Cache 本体 + 给迁移期留足探针超时。

(3)pprof:开关 setting.EnablePprof = sec.Key("ENABLE_PPROF").MustBool(false)(modules/setting/server.go:302),开启后 cmd/web.go:244 起独立监听:

func servePprof() {
	mux := http.NewServeMux()
	mux.HandleFunc("/debug/pprof/", pprof.Index)
	mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
	mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
	mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
	mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
	mux.Handle("/debug/fgprof", fgprof.Handler())
	...
	// The pprof server is for debug purpose only, it shouldn't be exposed on public network. At the moment, it's not worth introducing a configurable option for it.
	log.Info("Starting pprof server on localhost:6060")
	server := &http.Server{Addr: "localhost:6060", Handler: mux, ReadHeaderTimeout: 10 * time.Second}

地址写死 localhost:6060,连配置项都没有(注释明说"目前不值得引入配置项")——安全性靠绑定回环保证,任何容器/主机上的远程访问都要自己做端口转发,转发即是暴露,责任在运维侧。ReadHeaderTimeout: 10 * time.Second 是防 slowloris 的底线。第 3 章 Q9 解释了它为什么不进 chi 路由(pprof 的长响应过 QoS/session 链反而可能被丢,且主站中间件 bug 不该波及诊断面),两处合起来是同一个设计决定的两面。


15.9 安全基线运维

安全配置全部集中在 app.ini 的 [security] 段(custom/conf/app.example.ini:437 起,注释文档化到 521 行)与 [server] 段的协议部分。本节只讲"部署后必须核对的项 + 源码级的真实实现",逐条附 app.example.ini 行号便于对照。

15.9.1 [security] 关键项坐标表

配置键app.example.ini 行代码读取点(modules/setting/security.go)默认值/行为
INSTALL_LOCK442setting.MustInstalled() 等处false,安装完成后置 true,之后 /install 拒绝访问
SECRET_KEY / SECRET_KEY_URI446 / 450loadSecurityFrom:147空则回落到内置默认(见 15.9.2)
INTERNAL_TOKEN / INTERNAL_TOKEN_URI453 / 456:206已安装且为空时自动生成并回写 app.ini(:207-211)
REVERSE_PROXY_LIMIT475:1601——只信一层 X-Forwarded-For
REVERSE_PROXY_TRUSTED_PROXIES478:162空则 127.0.0.0/8,::1/128(:163-165)
MIN_PASSWORD_LENGTH481:1678
DISABLE_GIT_HOOKS493:169true(默认禁用自定义 hook)
ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET499:171true
PASSWORD_COMPLEXITY504:213空 → off
PASSWORD_HASH_ALGO507:175空 → pbkdf2,非法值直接 log.Fatal(:176-178)
PASSWORD_CHECK_PWN510:180false(不查 haveibeenpwned)
SUCCESSFUL_TOKENS_CACHE_SIZE514:18120(API token 的 pbkdf2 验证结果 LRU 缓存)
DISABLE_QUERY_AUTH_TOKEN519(DisableQueryAuthToken)false——禁止 URL query 传 token 是未来才默认开启的行为
TWO_FACTOR_AUTH—:197-204空/enforced,其他值 log.Fatal

注意 PASSWORD_HASH_ALGO 与 TWO_FACTOR_AUTH 都是校验失败即 Fatal 的项——配置写错在启动期就暴露,而不是带着错误静默降级。

15.9.2 SECRET_KEY / INTERNAL_TOKEN:loadSecret 与那个"著名"的默认值

两个密钥支持双写法:值直接放 ini,或用 *_URI 指向外部文件(K8s Secret 场景)。两者互斥,由 loadSecret(modules/setting/security.go:83)统一裁决:

// loadSecret load the secret from ini by uriKey or verbatimKey, only one of them could be set
// If the secret is loaded from uriKey (file), the file should be non-empty, to guarantee the behavior stable and clear.
func loadSecret(sec ConfigSection, uriKey, verbatimKey string) string {
	// don't allow setting both URI and verbatim string
	uri := sec.Key(uriKey).String()
	verbatim := sec.Key(verbatimKey).String()
	if uri != "" && verbatim != "" {
		log.Fatal("Cannot specify both %s and %s", uriKey, verbatimKey)
	}
	// if we have no URI, use verbatim
	if uri == "" {
		return verbatim
	}
	tempURI, err := url.Parse(uri)
	...
	switch tempURI.Scheme {
	case "file":
		buf, err := os.ReadFile(tempURI.RequestURI())
		...
		val := strings.TrimSpace(string(buf))
		if val == "" {
			// The file shouldn't be empty, otherwise we can not know whether the user has ever set the KEY or KEY_URI
			...
			log.Fatal("Failed to read %s (%s): the file is empty", uriKey, tempURI.RequestURI())
		}
		return val
	// only file URIs are allowed
	default:
		log.Fatal("Unsupported URI-Scheme %q (%q = %q)", tempURI.Scheme, uriKey, uri)

四个可辩护的设计决策:(1) 同时给两个值 = Fatal,避免"以为改了 URI 实际读的是旧值";(2) 只允许 file: scheme,http:// 等一律拒绝——密钥读取绝不走网络;(3) 文件空内容 = Fatal,注释解释了原因:安装期 token 生成逻辑会把值写回 INTERNAL_TOKEN,如果允许空文件,系统无法区分"用户没设置"与"设置成空",可能造成 URI 与明文并存的矛盾状态;(4) TrimSpace 允许文件带尾换行(echo x > file 的自然产物)。

核实与纠正(第 5 处):SECRET_KEY 为空时的回落值不是随机生成,而是一个硬编码字符串:

	SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
	if SecretKey == "" {
		// FIXME: https://github.com/go-gitea/gitea/issues/16832
		// Until it supports rotating an existing secret key, we shouldn't move users off of the widely used default value
		SecretKey = "!#@FDEWREWR&*("
	}

即所有未设置 SECRET_KEY 的实例共享同一个默认密钥(用它加密的 2FA secret 等数据在实例间理论上可互解)。注释给出了不改成随机值的理由:issue #16832 尚未支持"轮换既有密钥",此刻改成随机值会让升级用户的既有加密数据瞬间不可解——安全现状是被"无法平滑轮换"这个能力缺口锁死的。运维对策:新装实例务必在安装页/配置里设置独立 SECRET_KEY(或 SECRET_KEY_URI 指向权限 0600 的文件);INTERNAL_TOKEN 则会自动生成,风险仅在于它随 app.ini 落盘,这正是 15.8.1 里 dump 产物要 chmod 0600 的原因之一。

15.9.3 密码哈希的真实实现(纠正 PASSWORD_ITERATION_NUMBER)

核实与纠正(第 6 处):流传中的配置项 PASSWORD_ITERATION_NUMBER 在本仓库中不存在(全树 grep 无匹配,唯一命中是本章引文)。哈希迭代次数不是 [security] 段的独立配置,而是内嵌在算法规格字符串里,形如 pbkdf2$50000$50(算法$迭代数$keyLen)。想调整强度,改 PASSWORD_HASH_ALGO 的取值即可,没有第二个旋钮。

真实的配置面在 modules/auth/password/hash/setting.go:22 的别名表:

var aliasAlgorithmNames = map[string]string{
	"argon2":    "argon2$2$65536$8$50",
	"bcrypt":    "bcrypt$10",
	"scrypt":    "scrypt$65536$16$2$50",
	"pbkdf2":    "pbkdf2_v2", // pbkdf2 should default to pbkdf2_v2
	"pbkdf2_v1": "pbkdf2$10000$50",
	// The latest PBKDF2 password algorithm is used as the default since it doesn't
	// use a lot of memory and is safer to use on less powerful devices.
	"pbkdf2_v2": "pbkdf2$50000$50",
	// The pbkdf2_hi password algorithm is offered as a stronger alternative to the
	// slightly improved pbkdf2_v2 algorithm
	"pbkdf2_hi": "pbkdf2$320000$50",
}

var RecommendedHashAlgorithms = []string{
	"pbkdf2",
	"argon2",
	"bcrypt",
	"scrypt",
	"pbkdf2_hi",
}

hashAlgorithmToSpec(:45)对别名做循环展开(for has { algorithmName = alias; ... }),所以 PASSWORD_HASH_ALGO=pbkdf2 最终落成 pbkdf2$50000$50;注释明确要求未来调参改这张表,不要改 argon2.go 等实现文件——参数与算法解耦。默认算法名 DefaultHashAlgorithmName = "pbkdf2"(:13)。

但旧数据不走这条展开路径。hash.go:118 的 Parse 注释是全书少见的反复强调:

// NOTE: No de-aliasing is done in this function, thus any specification which does not
// contain a configuration will use the default values for that hasher. These are not
// necessarily the same values as those obtained by dealiasing. This allows for
// seamless backwards compatibility with the original configuration.
//
// To further labour this point, running `Parse("pbkdf2")` does not obtain the
// same algorithm as setting `PASSWORD_HASH_ALGO=pbkdf2` in app.ini, nor is it intended to.
// A user that has `password_hash_algo='pbkdf2'` in the db means get the original, unconfigured algorithm
// Users will be migrated automatically as they log-in ...

配套还有 hash.go:116 的常量与其警告:

// DO NOT COALESCE THESE VALUES
const defaultEmptyHashAlgorithmSpecification = "pbkdf2"

三个"pbkdf2"各是不同东西:(a) 空 PASSWORD_HASH_ALGO 的默认算法(经 dealias → pbkdf2$50000$50);(b) 数据库里存量 password_hash_algo='' 或 'pbkdf2' 的用户(不 dealias → NewPBKDF2Hasher("") 的 pbkdf2.go:44 默认 iter: 10_000, keyLen: 50,带注释 THESE VALUES MUST NOT BE CHANGED OR BACKWARDS COMPATIBILITY WILL BREAK);(c) 新写入的规格串(完整带参数)。区分的意义:改默认参数只影响新密码,老用户登录时按其行内记录的旧规格验证,登录成功后由其他代码把完整规格写回该行("Users will be migrated automatically as they log-in")——渐进式再哈希,无需停机批量迁移。

验证侧的恒定时间比较(hash.go:63):

func (algorithm *PasswordHashAlgorithm) VerifyPassword(providedPassword, hashedPassword, salt string) bool {
	// Some PasswordSaltHashers have their own specialised compare function that takes into
	// account the stored parameters within the hash. e.g. bcrypt
	if verifier, ok := algorithm.PasswordSaltHasher.(PasswordVerifier); ok {
		return verifier.VerifyPassword(providedPassword, hashedPassword, salt)
	}

	// Compute the hash of the password.
	providedPasswordHash, err := algorithm.Hash(providedPassword, salt)
	if err != nil {
		log.Error("passwordhash: %v.Hash(): %v", algorithm.Specification, err)
		return false
	}

	// Compare it against the hashed password in constant-time.
	return subtle.ConstantTimeCompare([]byte(hashedPassword), []byte(providedPasswordHash)) == 1
}

两层分流:bcrypt 这类把参数编码在 hash 字符串里的算法自带比较器(接口断言 PasswordVerifier),其余统一重算 + subtle.ConstantTimeCompare。盐格式还兼容新旧两种(hash.go:45:10 字节二进制旧格式 vs 32+ 字节 hex 新格式,len(salt) == 10 分流)。与 15.8.8 的 metrics token 校验同源——凡是"拿用户输入和秘密比对"的路径,全书统一用 crypto/subtle。

15.9.4 HTTPS 的三种落地与 cookie 策略

[server] 段决定 TLS 形态(modules/setting/server.go),关键约束在启动期校验:

	if protocolCfg != "https" && EnableAcme {
		log.Fatal("ACME could only be used with HTTPS protocol")
	}
	...
		Protocol = HTTPS
		if EnableAcme {
			AcmeURL = sec.Key("ACME_URL").MustString("")
			...
		} else {
			CertFile = sec.Key("CERT_FILE").String()
			KeyFile = sec.Key("KEY_FILE").String()
			if len(CertFile) > 0 && !filepath.IsAbs(CertFile) {
				CertFile = filepath.Join(CustomPath, CertFile)
			}
			if len(KeyFile) > 0 && !filepath.IsAbs(KeyFile) {
				KeyFile = filepath.Join(CustomPath, KeyFile)
			}
		}

三种方式的运维差异:

方式配置要点适用备注(源码依据)
内置 HTTPSPROTOCOL=https + CERT_FILE/KEY_FILE单机、证书自管相对路径自动 Join(CustomPath)——证书默认放 custom/
ACME 自动签PROTOCOL=https + ENABLE_ACME + ACME_URL/EMAIL/ACCEPTTOS公网直达 80/443PROTOCOL != https 时 Fatal;HTTP-01 回调由 cmd/web_acme.go:124 注释说明"all traffic coming into HTTP will be redirected to HTTPS automatically (LE HTTP-01 validation happens here)"
反代终止 TLSGitea 保持 PROTOCOL=http,外层 nginx/caddy 做 TLS生产主流必须配 ROOT_URL=https://…;REVERSE_PROXY_TRUSTED_PROXIES 见 15.7.5

反代模式下 Gitea 自身"看不见"TLS,因此 cookie 的 Secure 标依赖 ROOT_URL 推断(15.7.6 已摘录 session.go:65):ROOT_URL 写成 http:// 会导致 Secure cookie 永不下发、写了 https:// 又在代理剥头时全部失效——ROOT_URL 在此不是"展示用的地址",而是安全策略的输入。app.example.ini:2034 的 ;COOKIE_SECURE = 保持注释即可让默认推断生效。

此外反代场景还有一个默认值在守门:REVERSE_PROXY_LIMIT=1(security.go:160,对应 app.example.ini:475)——只采信 X-Forwarded-For 的最后一跳,配合 REVERSE_PROXY_TRUSTED_PROXIES 默认仅回环,直连用户的伪造头不会被当真;REVERSE_PROXY_AUTHENTICATION_USER 那组反代认证头(X-WEBAUTH-USER)则只有在信任链配置正确时才可信(15.7.5 已展开,此处不重复)。

15.9.5 默认即安全的几个开关(附注释原文)

最后把 [security] 段里"注释比配置本身更重要"的三项列出来,它们的 app.example.ini 注释就是威胁模型说明:

  1. DISABLE_GIT_HOOKS = true(:493)——注释直陈后果链:Custom git hooks can be used to perform arbitrary code execution on the host operating system. → 改配置与数据库 → users can gain Gitea administrator privileges → 以 Gitea OS 用户身份操作主机。还要注意那句 Setting this to true does not change existing hooks in git repos; adjust it if necessary——关开关不清理存量 hook,收紧权限后需要人工核查既有仓库。
  2. ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET = true(:499)——环境不完整时拒绝 push(NOT RECOMMENDED 才关闭),防的是"绕过 Gitea 校验直接改仓"。
  3. DISABLE_QUERY_AUTH_TOKEN = false(:519)——禁止把 API token 放 URL query(防日志/缓存泄漏明文),注释预告 In future releases, this will become the default behavior,当前默认关闭是兼容性妥协。

运维核对清单(新装/季度审计各过一遍):SECRET_KEY 独立非默认 → ROOT_URL 与真实协议一致 → REVERSE_PROXY_TRUSTED_PROXIES 只含真实代理地址 → DISABLE_GIT_HOOKS 保持 true → INSTALL_LOCK 已 true(安装页不可再被打开)→ /metrics 设了 TOKEN 或仅内网可达 → pprof 未做公网端口转发。


15.10 全书总结

15.10.1 15 章知识回扣

用第 1 章 1.8 的导航表做骨架,标注每章在本章哪里被"回收":

章一句话核心在第 15 章的回收点
1 总体架构目录/分层/请求生命周期/横切机制,"每层可独立测试"的伏笔15.1 测试分层与分层规则同构;15.2 fixtures 直接测 services 绕开 routers(1.3.5 的承诺兑现)
2 启动流程与配置体系LoadSettings 分段加载、InitWebInstalled 时序15.4 test-e2e.sh 生成 app.ini + INSTALL_LOCK;15.9 全部安全项都由 loadSecurityFrom 读入
3 Web 路由与中间件chi 路由、append(mid, …) 惯用法、3.2.5 监控组件15.8.8 /metrics、/api/healthz、pprof 三件套完整回收(含 Q9 的 pprof 独立监听)
4 数据库层与 ORMInitEngineWithMigration 三分支、globallock 迁移锁15.8.5 gitea migrate 与 migrateWithSetting 直接引第 4 章结论;15.8.3 recreate-table 复用 EnsureUpToDate
5 权限体系Permission 计算、repo unit 授权15.4 集成测试的 loginUser/MakeRequest 会话正是权限断言的载体;15.9 反代认证头的信任边界
6 Git 底层封装go-git/gitcmd 双实现、bindata/gogit 构建 tag15.6.3 bindata/gogit tag 的重建机制 TAGS_EVIDENCE;15.4 CI 矩阵 bindata×gogit 两列
7 Issue 体系models/issues + notify 事件流15.8.2 dbconsistency 对 Issue/IssueLabels/TrackedTimes 的孤儿检查(Counter/Fixer 成对)
8 Pull Request合并、冲突、pull_service15.3.3 pull_merge_test.go 的 require.Eventually 用真实合并链讲"等确定条件"
9 REST API v1NormalRoutes、toHandlerProvider、swagger15.5 generate-swagger 是 PR 合规 job 的一环(API 改动必须重生成 spec)
10 异步基础设施队列/缓存/Cron、FlushAll、process.GetManager15.3.1 队列 backoff 测试的时序构造;15.8.7 manager flush-queues/processes 的运维面
11 搜索与索引bleve/mysql/elasticsearch indexer15.8.1 dump 的 --skip-index 单独排除两个索引路径;15.4 CI 的 meilisearch/elasticsearch service 容器
12 通知与 Webhook11 个 notifier 的进程内多播15.8.4 restore-repo 走 services 层才会触发 notify——CLI 直写库做不到,这是绕 HTTP 的动机之一
13 前端工程web_src 渐进增强、vite 构建15.5.3 vitest/eslint/stylelint;15.6.5 bindata 嵌入的前端产物链
14 Actions 与包注册/api/actions、/api/packages15.8.6 migrate-storage 的 actions-log/actions-artifacts/packages 三类文件;15.8.2 对 Runner/Variable/Secret 归属一致性的 doctor 子项
15 测试与部署运维本章—

三点元观察:其一,测试分层不是独立话题——1.3.5"每层可替换"的架构收益,兑换形式就是 15.2 里 fixtures 单测与集成测试可以各自只加载半套系统;其二,运维命令几乎都是既有 services 能力的 CLI 皮(restore-repo→migrations.RestoreRepository、flush-queues→FlushAll、migrate→versioned_migration.Migrate),理解了前 14 章的 services 层,cmd/ 只是参数解析;其三,安全没有"安全模块",它是散落的纪律——crypto/subtle 三处复用、log.Fatal 配置校验、默认值注释里的威胁模型,全都嵌在普通代码里。

15.10.2 全书架构总图

flowchart TB
    subgraph DELIVERY[&#34;交付与验证(第 15 章)&#34;]
        T[&#34;测试五层:单元 / 集成 / e2e / vitest / migration<br/>CI 四库矩阵 + 分片 + lint 六 job&#34;]
        B[&#34;构建:make 59 目标<br/>VERSION 三分支注入 / bindata·gogit·timetzdata&#34;]
        D[&#34;部署:Docker·rootless·二进制·反代<br/>dump / doctor / migrate / manager&#34;]
    end

    subgraph PRESENT[&#34;表现层(第 3、9、13 章)&#34;]
        R[&#34;chi 路由 + 中间件链<br/>append(mid, handler)&#34;]
        A[&#34;REST API v1 / swagger&#34;]
        F[&#34;web_src 渐进增强 + 模板&#34;]
    end

    subgraph BIZ[&#34;业务层(第 5、7、8、10、12、14 章)&#34;]
        P[&#34;权限 Permission&#34;]
        I[&#34;Issue / PR / notify 多播&#34;]
        AS[&#34;队列 / 缓存 / cron / globallock&#34;]
        AC[&#34;Actions / Package Registry&#34;]
    end

    subgraph DATA[&#34;数据与资源层(第 4、6、11 章)&#34;]
        M[&#34;models/* + xorm + 版本化迁移&#34;]
        G[&#34;modules/git:go-git / gitcmd&#34;]
        IX[&#34;indexer:bleve / mysql / ES&#34;]
        ST[&#34;storage 抽象:local / minio / azureblob&#34;]
    end

    subgraph CORE[&#34;核心横切(第 1、2 章)&#34;]
        C[&#34;modules/setting 配置分段加载&#34;]
        L[&#34;log / process / graceful&#34;]
    end

    T -.测试对象.-> PRESENT & BIZ & DATA
    B -.产物.-> D
    R --> P --> I --> M
    A --> I
    F --> R
    I --> AS
    AC --> M
    M --> IX
    I --> ST
    C --> R & M & AS
    L --> R & AS
    D -.运行与运维.-> CORE

15.10.3 二次开发路径建议

按三类常见诉求给"章节序列 + 落地动作",每条都以本章的验证手段收尾:

路径 A:加一个 REST API 端点(只读聚合类) 读 9(路由与 swagger 注册)→ 3(中间件链与 append(mid, …) 的取舍)→ 5(unit 权限判断)→ 4(查询与事务)。落地动作:routers/api/v1/api.go 挂路由 → services/convert 出 DTO → make generate-swagger。测试策略:集成测试用 tests/integration 的 NewRequest(t, "GET", "/api/v1/...") + MakeRequest 断言状态码与 JSON 字段——不要为聚合查询写 e2e。合规:make lint-go + make fmt,PR 描述按工作区规则控制在 1000 字符内、附 UI 截图(若同时动了前端)。

路径 B:加一个业务功能(带新表/新页面) 读 4(迁移与 modelmigration 的结构冻结纪律——新表的 struct 一旦进迁移就别改列)→ 1(分层规则:routers 不写 SQL)→ 7(一个完整业务域的参照实现:模型/服务/事件)→ 13(模板与 web_src/js/features 的初始化函数模式)。测试策略三层:models/unittest 用 fixtures 测查询(MainTest + TestOptions.FixtureFiles)、services 单测用 sqlite 内存库测流程、一条集成测试走完整请求(loginUser + MakeRequest);时序/并发用 15.3.1 的手法(mockBackoffDuration 式的时间注入 + require.Eventually 等确定条件)。合规:.golangci.yml 的 depguard 规则集——modelmigration 不得依赖 models(HINT: MIGRATION-STRUCT-FROZEN),新代码别往冻结区伸手。

路径 C:换部署形态/存储后端(运维向) 读 2(loadSettings 如何分段吃配置)→ 15.7(四形态对比与反代链)→ 15.8(dump/doctor/migrate-storage 的能力边界)。落地动作:先 gitea dump 留回滚点 → gitea migrate-storage --type lfs --storage minio --minio-... 拷贝 → 核对 → 改 app.ini 切换;反代则按 15.7.5/15.9.4 检查 ROOT_URL/TRUSTED_PROXIES/COOKIE_SECURE 三件套。验证:/api/healthz 观测(不用作重启触发器,15.8.8 的警告)、/metrics 抓取鉴权、必要时 ENABLE_PPROF=true 看 goroutine。

三条路径共同的"提交前清单"(来自 docs/development.md 与工作区 AGENTS.md,也是 15.5 lint 体系的日常形态):

make fmt                  # .go 改动后必跑
make tidy                 # go.mod 改动后必跑
make generate-swagger     # API 改动后必跑
make lint-go              # 或 lint-js / lint-css / lint-templates,只跑改动面
go test -run '^TestName$' ./modulepath/   # 单个 Go 测试
pnpm exec vitest <path-filter>            # 单个前端测试
GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e   # 单个 e2e

15.10.4 收束:把"Never assume"当作交付标准

回到本章开头的方法论。这 15 章里被验证纠正的说法——集成测试的 264 vs 262、集成测试并无 build tag、make help 实为 59 目标、gitea dump 实为 14 个 flag、doctor check 默认 0 项、SECRET_KEY 默认值是硬编码而非随机、PASSWORD_ITERATION_NUMBER 不存在——没有一个是靠印象解决的,全部来自 grep、数文件、读 flag 注册表这类几分钟的动作。对一个 44 万行 Go 源码的仓库,源码永远是唯一可信的文档;本章的测试与 lint 体系,本质上是把这条个人纪律固化成了机器纪律:人会想当然,test-check 那样的元测试、nolintlint 的 require-explanation、depguard 的架构 deny 规则不会。

全书到此收束。15 章的路线从 cmd/main.go 的第一行配置加载,走到 .github/workflows 的最后一次 lint——中间是路由、数据库、权限、Git、Issue、PR、API、队列、搜索、通知、前端、Actions,以及证明它们成立的测试与把它们送上生产的部署。地图(第 1 章 1.8)已经全部走完,剩下的路,从你自己的第一个 PR 开始。