开发指南
本文档面向希望参与 pt-tools 开发或从源码构建的开发者。
环境要求
| 依赖 | 版本要求 | 说明 |
|---|---|---|
| Go | 1.26.11 | 后端开发语言;以 go.mod 为准 |
| Node.js | 25.2.0 | 前端构建环境;以 .node-version 为准 |
| pnpm | 10.25.0 | 前端包管理器;以 packageManager 为准 |
安装依赖
Go:
# Linux(下载官方归档)
wget https://go.dev/dl/go1.26.11.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.11.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# macOS (使用 Homebrew)
brew install goNode.js 和 pnpm:
# 使用 nvm 安装 Node.js
nvm install 25.2.0
nvm use 25.2.0
# 安装仓库固定的 pnpm 版本
npm install -g pnpm@10.25.0从源码构建
克隆仓库
git clone https://github.com/sunerpy/pt-tools.git
cd pt-tools构建前端
cd web/frontend
pnpm install --frozen-lockfile
pnpm build
cd ../..构建产物会输出到 web/static/dist/,并通过 go:embed 嵌入 Go 二进制。
构建后端
go build -o pt-tools .使用 Makefile
推荐使用 Makefile 进行构建,它封装了完整的构建流程:
# 标准构建(前端 + 后端,输出 dist/pt-tools)
make build
# 带版本元数据的本机开发构建(会先格式化)
make build-local
# 仅构建前端
make build-frontend
# 完整本地门禁:工具链、格式、lint、测试与构建
make check后端单独编译可运行 go build -o dist/pt-tools .。所有二进制构建产物位于 dist/。
开发模式
开发模式下,前端和后端可以分别启动,支持热重载:
终端 1 - 启动前端开发服务器:
cd web/frontend
pnpm dev前端开发服务器默认运行在 http://localhost:5173。
终端 2 - 启动后端服务:
go run main.go web --port 8080也可以运行 make run-dev:该目标先生成前端生产产物,再在默认 8080 端口启动后端;它不提供前端热更新。
后端服务运行在 http://localhost:8080,Vite 开发服务器会把 /api 与 /logout 代理到该端口。
开发环境配置:
- 前端会自动代理 API 请求到后端
- 修改前端代码后会自动热重载
- 修改后端代码需要重新运行
技术架构
项目结构
pt-tools/
├── cmd/ # CLI 命令定义 (Cobra)
├── config/ # 日志配置 (Zap)
├── core/ # 运行时生命周期、配置存储、迁移
├── global/ # 全局单例(logger, DB)
├── internal/ # 统一站点接口、RSS 处理器、过滤器
├── models/ # GORM 模型定义
├── scheduler/ # RSS 任务调度器
├── site/v2/ # 站点驱动和定义
│ └── definitions/ # 各站点具体实现
├── thirdpart/ # 第三方集成
│ └── downloader/ # 下载器接口
├── utils/ # 工具函数
├── web/ # HTTP 服务和 API
│ ├── api_*.go # API 处理器
│ └── frontend/ # Vue 3 前端项目
├── tools/
│ └── browser-extension/ # 浏览器扩展 (TypeScript + Vue 3)
├── main.go # 程序入口
├── Makefile # 构建脚本
└── go.mod # Go 模块定义性能优化
pt-tools 采用了多项性能优化措施:
| 优化项 | 说明 |
|---|---|
| 内存缓存 | 两级缓存减少数据库访问 |
| 熔断器 | 自动检测和隔离故障站点 |
| 连接池 | HTTP 连接复用,减少连接开销 |
| 持久化限流 | SQLite 存储滑动窗口,重启后限流状态不丢失 |
| 并发控制 | 合理的并发数避免被站点封禁 |
贡献指南
欢迎贡献代码或提交问题!
提交 Issue
在提交 Issue 前,请:
- 搜索现有 Issue,避免重复
- 使用清晰的标题描述问题
- 提供详细的复现步骤
- 附上相关日志或截图
Issue 地址:GitHub Issues
提交 Pull Request
- Fork 仓库到你的账户
- 从最新
main创建分支:git switch -c feat/your-feature - 运行
make check - 使用 Conventional Commit 提交:
git commit -m "feat(scope): describe the change" - 推送分支并创建面向
main的 Pull Request
PR 地址:GitHub Pull Requests
添加新站点支持
没有编程经验? 请参考 请求新增站点支持(无需编程经验),你只需要提供站点页面数据,维护者会帮你完成适配。
添加新 PT 站点只需创建 一个定义文件,系统会自动完成以下工作:
- 从
SiteDefinitionRegistry生成运行时元数据 - 在
SiteRegistry中注册站点 - API 返回
is_builtin: true标识为内置站点 - 前端自动识别为不可删除的预置站点
无需手动编辑 models/enter.go、web/server.go 或前端代码。
站点定义模板:
package definitions
import (
v2 "github.com/sunerpy/pt-tools/site/v2"
)
var MySiteDefinition = &v2.SiteDefinition{
// 必填字段
ID: "mysite", // 唯一标识符(小写)
Name: "MySite", // 显示名称
Schema: v2.SchemaNexusPHP, // 见下方「Schema 枚举」
URLs: []string{"https://mysite.com/"},
// 可选字段(有默认值)
AuthMethod: v2.AuthMethodCookie, // 见下方「AuthMethod 枚举」(根据 Schema 自动推断,通常无需设置)
RateLimit: 2.0, // 请求频率限制(默认 2.0 req/s,持久化存储)
RateBurst: 5, // 突发请求数(默认 5)
// 可选元数据
Aka: []string{"MS", "MySite别名"},
Description: "站点描述",
FaviconURL: "https://mysite.com/favicon.ico",
TimezoneOffset: "+0800",
// 站点特定选择器(覆盖 Schema 默认值)
Selectors: &v2.SiteSelectors{
// 搜索结果页免费图标选择器
DiscountIcon: "img.pro_free",
// 自定义免费关键词映射(可选,nil 时使用默认映射)
DiscountMapping: map[string]v2.DiscountLevel{
"custom_free": v2.DiscountFree,
},
},
// 详情页解析配置(用于 RSS 详情获取)
DetailParser: &v2.DetailParserConfig{
DiscountSelector: "h1 font", // 免费标签选择器
DiscountMapping: map[string]v2.DiscountLevel{ // CSS class → 免费等级
"free": v2.DiscountFree,
"twoupfree": v2.Discount2xFree,
},
TimeLayout: "2006-01-02 15:04:05", // 时间格式
},
// 用户信息解析配置
UserInfo: &v2.UserInfoConfig{...},
// 等级要求
LevelRequirements: []v2.SiteLevelRequirement{...},
}
func init() {
v2.RegisterSiteDefinition(MySiteDefinition)
}Schema 枚举(v2.Schema 类型,定义在 site/v2/types.go):
| 枚举常量 | 值 | 站点类型 | 默认 AuthMethod |
|---|---|---|---|
v2.SchemaNexusPHP | "NexusPHP" | NexusPHP 架构站点 | cookie |
v2.SchemaMTorrent | "mTorrent" | M-Team 等 | api_key |
v2.SchemaGazelle | "Gazelle" | Gazelle 架构站点 | cookie |
v2.SchemaUnit3D | "Unit3D" | Unit3D 架构站点 | api_key |
v2.SchemaHDDolby | "HDDolby" | HDDolby 专用 | cookie_and_api_key |
v2.SchemaRousi | "Rousi" | RousiPro 自定义 API | passkey |
AuthMethod 枚举(v2.AuthMethod 类型,定义在 site/v2/types.go):
| 枚举常量 | 值 | 说明 |
|---|---|---|
v2.AuthMethodCookie | "cookie" | 浏览器 Cookie 认证 |
v2.AuthMethodAPIKey | "api_key" | API Key 认证 |
v2.AuthMethodCookieAndAPIKey | "cookie_and_api_key" | Cookie + API Key 双重认证 |
v2.AuthMethodPasskey | "passkey" | Passkey 认证(用于 RSS/下载) |
添加步骤:
- 在
site/v2/definitions/创建<sitename>.go - 参考现有实现(如
hdsky.go、mteam.go) - 必须 创建
<sitename>_fixture_test.go,提供 fixture 数据证明解析逻辑正确(见下方「Fixture 测试要求」) - 运行测试:
go test ./site/v2/...(CI 会自动验证定义的完整性和正确性) - 更新站点列表文档:
docs/sites.md - 提交 PR
注意:旧版本需要手动更新
models/enter.go中的AllowedSiteGroups和前端的预置站点列表,现已不再需要。系统会从 Registry 自动识别内置站点。
自定义驱动(单文件模式):
如果站点架构与现有 Schema 不兼容,可以在 同一个文件 中实现完整的驱动逻辑。参考 definitions/rousipro.go 的实现:
package definitions
import (
"context"
"encoding/json"
"fmt"
v2 "github.com/sunerpy/pt-tools/site/v2"
"go.uber.org/zap"
)
var CustomSiteDefinition = &v2.SiteDefinition{
ID: "customsite",
Name: "CustomSite",
Schema: v2.Schema("CustomSite"), // 在 site/v2/types.go 中添加新 Schema 常量
URLs: []string{"https://customsite.com/"},
CreateDriver: createCustomDriver,
}
func init() {
v2.RegisterSiteDefinition(CustomSiteDefinition)
}
func createCustomDriver(config v2.SiteConfig, logger *zap.Logger) (v2.Site, error) {
var opts v2.CustomOptions
if err := json.Unmarshal(config.Options, &opts); err != nil {
return nil, err
}
driver := &customDriver{baseURL: config.BaseURL, apiKey: opts.APIKey}
return v2.NewBaseSite(driver, v2.BaseSiteConfig{
ID: config.ID,
Name: config.Name,
Kind: v2.SiteKind("custom"),
Logger: logger,
}), nil
}
// customDriver 实现 v2.Driver 接口
type customDriver struct {
baseURL string
apiKey string
}
// 实现 Driver 接口的方法...
func (d *customDriver) PrepareSearch(q v2.SearchQuery) (customReq, error) { ... }
func (d *customDriver) Execute(ctx context.Context, req customReq) (customRes, error) { ... }
func (d *customDriver) ParseSearch(res customRes) ([]v2.TorrentItem, error) { ... }
func (d *customDriver) GetUserInfo(ctx context.Context) (v2.UserInfo, error) { ... }
func (d *customDriver) PrepareDownload(id string) (customReq, error) { ... }
func (d *customDriver) ParseDownload(res customRes) ([]byte, error) { ... }当设置了 CreateDriver 时,系统会优先使用它而非基于 Schema 的驱动查找。这种模式的优势是 新站点只需要一个文件,所有驱动逻辑都包含在 definitions/<site>.go 中。
Fixture 测试要求
所有站点(无论是复用 NexusPHP 还是自定义驱动)都 必须 提供 fixture 测试,以便仓库 owner 通过审查实际数据来判断 PR 的正确性。
测试文件命名:definitions/<sitename>_fixture_test.go
FixtureSuite 注册(必须)
每个站点 必须 在 init() 中注册 FixtureSuite,包含三个必填测试函数:
| 字段 | 验证内容 | 说明 |
|---|---|---|
Search | 搜索/RSS 列表解析 | 标题、大小、免费状态、免费结束时间 |
Detail | 种子详情页解析 | 免费等级、HR 状态、大小 |
UserInfo | 用户信息解析 | 上传量、下载量、分享率、等级 |
TestAllSites_FixtureCoverage(在 definitions_validation_test.go 中)会自动遍历所有注册站点,检查是否有 FixtureSuite 且三个字段非 nil。缺少任一字段的新站点 CI 会直接失败。
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}辅助函数
测试框架(definitions/fixture_helper_test.go)提供以下辅助函数:
RequireNoSecrets(t, "fixture_name", data)
resp := DecodeFixtureJSON[ResponseType](t, "fixture_name", jsonString)
doc := FixtureDoc(t, "fixture_name", htmlString)隐私保护:RequireNoSecrets 自动扫描以下模式,匹配则测试失败:
| 检测内容 | 说明 |
|---|---|
c_secure_uid=... 等 NexusPHP cookie | 真实登录凭证 |
PHPSESSID=... | PHP 会话 ID |
passkey=<32+位hex> | 真实 passkey |
Bearer <32+位token> | 真实 API Token(FAKE_/TEST_ 前缀除外) |
规则:
- 所有 fixture 数据 必须 是 Go 字符串常量,定义在
_test.go文件中(不会编入生产二进制) - 用户名、ID 等应脱敏(使用虚构值)
- 凭证字段使用
FAKE_TEST_前缀
NexusPHP 站点(HTML fixture)
NexusPHP 站点通过 CSS 选择器解析 HTML 页面。参考 hdsky_fixture_test.go:
package definitions
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v2 "github.com/sunerpy/pt-tools/site/v2"
)
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}
// 脱敏的搜索页 HTML — 保留真实 DOM 结构,替换敏感数据
const mysiteSearchFixture = `<html><body>
<table class="torrents"><tbody>
<tr>
<td class="rowfollow"><img alt="Movie" /></td>
<td class="rowfollow">
<table class="torrentname"><tr><td class="embedded">
<a href="details.php?id=12345">Test.Movie.2025</a>
<img class="pro_free" src="pic/trans.gif" alt="Free"
onmouseover="domTT_activate(this, event, 'content', '<span title="2026-03-01 12:00:00">29天</span>')" />
</td></tr></table>
</td>
<td class="rowfollow"></td>
<td class="rowfollow"><span title="2025-01-15 08:30:00">1天前</span></td>
<td class="rowfollow">42.5 GB</td>
<td class="rowfollow">150</td>
<td class="rowfollow">10</td>
<td class="rowfollow">500</td>
</tr>
</tbody></table>
</body></html>`
func testMySiteSearch(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(mysiteSearchFixture))
}))
defer server.Close()
driver := v2.NewNexusPHPDriver(v2.NexusPHPDriverConfig{
BaseURL: server.URL, Cookie: "test=1", Selectors: def.Selectors,
})
driver.SetSiteDefinition(def)
res, err := driver.Execute(context.Background(), v2.NexusPHPRequest{Path: "/torrents.php", Method: "GET"})
require.NoError(t, err)
items, err := driver.ParseSearch(res)
require.NoError(t, err)
require.Len(t, items, 1)
assert.Equal(t, v2.DiscountFree, items[0].DiscountLevel)
assert.False(t, items[0].DiscountEndTime.IsZero())
}
func testMySiteDetail(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
doc := FixtureDoc(t, "detail", mysiteDetailFixture)
parser := v2.NewNexusPHPParserFromDefinition(def)
info := parser.ParseAll(doc.Selection)
assert.Equal(t, v2.DiscountFree, info.DiscountLevel)
assert.NotEmpty(t, info.TorrentID)
}
func testMySiteUserInfo(t *testing.T) {
def, ok := v2.GetDefinitionRegistry().Get("mysite")
require.True(t, ok)
driver := v2.NewNexusPHPDriver(v2.NexusPHPDriverConfig{
BaseURL: def.URLs[0], Cookie: "test=1",
})
driver.SetSiteDefinition(def)
doc := FixtureDoc(t, "index", mysiteIndexFixture)
for field, expected := range map[string]string{"id": "12345", "name": "TestUser"} {
sel := def.UserInfo.Selectors[field]
assert.Equal(t, expected, driver.ExtractFieldValuePublic(doc, sel))
}
}自定义驱动站点(JSON fixture)
自定义驱动站点使用 JSON API。参考 rousipro_fixture_test.go:
func init() {
RegisterFixtureSuite(FixtureSuite{
SiteID: "mysite",
Search: testMySiteSearch,
Detail: testMySiteDetail,
UserInfo: testMySiteUserInfo,
})
}
func testMySiteSearch(t *testing.T) {
resp := DecodeFixtureJSON[myResponse](t, "search", mySearchFixtureJSON)
driver := newMyDriver(myDriverConfig{BaseURL: "https://mysite.com", Passkey: "FAKE_TEST_KEY"})
items, err := driver.ParseSearch(resp)
require.NoError(t, err)
require.Len(t, items, 2)
assert.Equal(t, v2.DiscountFree, items[0].DiscountLevel)
}
func testMySiteDetail(t *testing.T) {
// 验证 JSON detail 解析 + 免费状态判断
}
func testMySiteUserInfo(t *testing.T) {
// 验证用户信息 JSON 解析
}提示:
TestCreateDriver_Smoke(在definitions_validation_test.go中)会自动遍历所有CreateDriver站点。新增自定义驱动时,在fakeOptionsForSchema()中添加对应 Schema 的 fake options 即可。
代码规范
运行代码检查
# 提交前推荐:执行工具链、格式、lint、race 测试和构建门禁
make check
# 需要分开定位问题时
make fmt-check
make lint
make unit-test
make build
# 修改后统一格式化
make fmtGo 代码规范
- 遵循 Effective Go 指南
- 使用
gofmt格式化代码 - 导入分组:标准库、第三方库、项目内部包
- 错误处理:不要忽略错误,适当包装错误信息
- 注释:导出的函数和类型必须有文档注释
前端代码规范
- 遵循 Vue 3 Composition API 风格
- 使用 TypeScript 类型注解
- 组件命名使用 PascalCase
- 使用 Oxlint、
vue-tsc与 Oxfmt;不要引入另一套格式化器
提交信息规范
<type>(<scope>): <subject>
<body>
<footer>Type 类型:
feat: 新功能fix: Bug 修复docs: 文档更新style: 代码格式(不影响功能)refactor: 重构test: 测试相关chore: 构建/工具相关
示例:
feat(site): 新增 NewSite 站点适配
- 增加 Cookie 认证与页面解析
- 补充搜索、详情和用户信息 fixture 测试
- 同步扩展站点清单与文档
Closes #123浏览器扩展开发与发布
本地开发
cd tools/browser-extension
pnpm install --frozen-lockfile
pnpm dev # watch 模式
pnpm build # 单次构建
cd ../..
make build-extension # 站点一致性检查 + 构建 + 打包 zipmake check-sites 会比较 Go 站点定义与扩展 KNOWN_SITES;新增站点时必须保持两处一致。详见扩展 README。
发布模型
浏览器扩展与 pt-tools 主程序使用同一个版本和同一个 Release,不再使用独立的 ext-v* tag:
- Conventional Commit 合入受保护的
main后,release-please 更新版本、CHANGELOG.md和扩展package.json。 - 合并 release PR 后,同一次
Releaseworkflow 创建 draft,并行构建 Go 归档、扩展zip/crx与手机 App 的 APK;三者都成功以后才推容器镜像(镜像一推就公开了)。 - 所有承诺产物和
checksums.txt校验通过后,workflow 才把 draft 切换为公开 Release;任一必需 job 失败都会保留 draft。 - 稳定版公开后,
Publish to Edge Add-ons作为发布后的副作用提交pt-tools-helper.zip;RC 不提交商店。
重要
不要手工创建 release tag,也不要通过 ext-v* tag 发布扩展。普通发版由 release-please 管理;workflow_dispatch 只用于重建一个已经存在且仍为 draft 的 tag,而且这个 tag 的提交必须在触发它的受保护分支上(是那个分支当前的提交或它的祖先):下游 job 会带着签名密钥和镜像仓库凭据跑 tag 里的代码。
预览版(RC)
release-please-config.json 根包里的 "prerelease": true 是预览版开关,与 "versioning": "prerelease"、"prerelease-type": "rc.1" 配合使用:
- 开启时,release PR 的版本带
-rc.N:从稳定版开始取下一个版本的rc.1(0.48.0→0.49.0-rc.1),之后只递增N(0.49.0-rc.1→0.49.0-rc.2)。draft 标为预发布,公开时不设为 Latest;安装说明使用.github/release-notes/rc-footer.md;不提交 Edge 商店;容器镜像只推版本号标签,不更新latest与主.次、主标签。 - 删除
"prerelease": true即回到稳定版:下一个 release PR 去掉后缀(0.49.0-rc.2→0.49.0),另外两项可以保留。 - 扩展 manifest 的版本只能从
rc.N(N 为 1–65534)推导(.github/scripts/extension-version.sh),所以prerelease-type必须是rc.1,不能写成rc或beta。 - 手机 App 的构建号也从版本推导(
.github/scripts/mobile-build-number.sh),rc.N的 N 不能超过 98,次版本与修订号不能超过 99,见下面的手机 App 发版。
Edge 商店发布需要仓库 Secrets:
| Secret | 用途 |
|---|---|
EDGE_PRODUCT_ID | Edge Add-ons 产品 ID |
EDGE_CLIENT_ID | Publish API 客户端 ID |
EDGE_API_KEY | Publish API 密钥 |
仓库变量 PUBLISH_EDGE=false 可在发布链验收时跳过商店提交;变量缺失或不是 false 时保持默认发布行为。Edge 提交不影响已经通过资产门禁并公开的 GitHub Release,但除「前一次 submission 仍在审核」之外的 API 或包错误仍会让该 job 失败,需单独排查。
托管 relay 与手机 App 的发版
默认 relay 地址
发版构建内置托管 relay 的地址(网页「远程访问」里因此会出现「填入托管 relay」):.goreleaser.yaml 与 Dockerfile 从仓库 Secret PT_TOOLS_RELAY_URL(wss:// 开头)读入 internal/remote.DefaultRelayURL,没设时为空。地址随二进制公开,不是秘密;放在 Secret 里只是为了不进仓库、不改代码就能换。
pt-tools version 的 Default relay: 一行显示 configured、none 或 invalid(不打印地址)。Release workflow 在 GoReleaser 之后检查 linux-amd64 归档:Secret 设了却不是 configured 时这个 job 失败,draft 不发布。
内置的 TMDB Key
发版构建内置一个 TMDB API Key,用户不填自己的 Key 也能识别、整理与订阅(和 Jellyfin、tinyMediaManager 一样):.goreleaser.yaml 从仓库 Secret PT_TOOLS_TMDB_KEY(v3 的 API Key 或 v4 的读取令牌)读入 internal/media/tmdb.BuiltinKey;Dockerfile 自己编译时用 BuildKit secret tmdb_key(docker.yml 从同一个 Secret 传),不用 build-arg,免得 Key 写进镜像历史与 provenance。没设时为空,用户要在「媒体识别」里填自己的 Key;本地 make build 不注入。
Key 随二进制分发,能被取出来,不当作秘密;放在 Secret 里是为了不进仓库、能换。用户填了自己的 Key 时用自己的;用户把 TMDB 接口地址换成镜像或中转时不用内置的(内置的只发给 api.themoviedb.org 与 api.tmdb.org)。
pt-tools version 的 TMDB key: 一行显示 configured、none 或 invalid(不打印 Key)。Release workflow 和默认 relay 一样检查 linux-amd64 归档:Secret 设了却不是 configured 时这个 job 失败,draft 不发布。
部署托管 relay
托管 relay 跑在自己的服务器上:Docker 里的 pt-tools relay serve(镜像 PT_MODE=relay),只听 127.0.0.1:<端口>,前面的反向代理做 TLS 并把 WebSocket 转过来(/ready、/healthz、/metrics 也经它)。负载均衡器从别的机器连过来时(例如 AWS ALB),用 --bind(workflow 里是变量 RELAY_BIND)把端口绑到服务器的私网地址,安全组只放行负载均衡器;客户端 IP 取负载均衡器写的头(ALB 是 X-Forwarded-For,relay 取最后一个地址),/metrics 不要对外,在负载均衡器上挡掉。scripts/deploy-relay.sh 在服务器上完成一次升级:拉镜像 → 旧容器排空并以 1012 关、改名成 <name>-previous → 起新容器 → 核对 /ready 与版本(容器被重启过也算失败)。这一段里出错或者被中断(包括 ssh 断开)都会换回旧容器;上一次部署没有收尾时拒绝再部署。第一次部署时 <name>-previous 是一个不会启动的占位容器,回滚就是删掉新容器、回到没有部署过的样子。同一个容器名上的部署、收尾、回滚与看门狗用 flock 排队(锁文件 /tmp/deploy-relay-<name>.lock),服务器上要有 util-linux 的 flock 与 setsid。
用 --keep-previous 部署时,脚本退出以前另起一个脱离 ssh 会话的看门狗:--pending-timeout 秒(默认 900,0 不起)以后这次部署仍然没有收尾,就换回旧容器(第一次部署时删掉新容器与占位容器)。它按容器 ID 只认这次部署的两个容器,已经收尾、回滚过或者又部署过就什么都不做;日志在 /tmp/deploy-relay-<name>.watchdog.<uid>.log。服务器在这期间重启时看门狗也不在了,要手动收尾。
Release 公开以后,Deploy the hosted relay job 调用 relay-deploy.yml:确认这个 tag 已经公开发布,按这次推送的镜像 digest 部署(部署脚本取自 workflow 所在的受保护提交,不取自 tag),旧容器先留着,从外面检查 https://<RELAY_HOSTNAME>/ready 与版本通过以后才删。部署做完却没有收尾(外面检查没过、收尾失败、这期间 workflow 被取消)时,最后一步换回旧容器;收尾在服务器上其实已经做完(之后这一步被取消、ssh 没有带回结果)时,这一步只给提示,不报失败。收尾时核对服务器上跑的仍是这次的镜像,看门狗已经换回旧容器时收尾失败。runner 中途丢失时由看门狗在 900 秒以后换回旧容器。也可以在受保护的 main、v1.0.0-rc 上手动运行 Relay Deploy,dry_run 只做检查、不连服务器。
部署用的 secret 放在 GitHub Environment hosted-relay 里,并在仓库设置里把这个 Environment 的部署分支限定为 main 与 v1.0.0-rc(需要的话再加审批);release.yml 不把仓库的 secret 传给它。
| 名称 | 放在哪里 | 用途 |
|---|---|---|
DEPLOY_RELAY | 仓库变量 | 为 true 时发版以后部署;默认不部署 |
DEPLOY_RELAY_PRERELEASE | 仓库变量 | 为 true 时预览版(RC)也部署;默认只部署稳定版 |
RELAY_HOSTNAME | 变量 | 对外的主机名,App 与 pt-tools 里填 wss://<它> |
RELAY_IMAGE、RELAY_PORT、RELAY_DRAIN_GRACE | 变量 | 镜像(默认 sunerpy/pt-tools)、服务器上听的端口(默认 8443)、排空时间(默认 5s) |
RELAY_BIND | 变量 | 端口绑定的地址,默认 127.0.0.1;负载均衡器在别的机器上时填服务器的私网地址 |
RELAY_CLIENT_IP_HEADER | 变量 | 反向代理写客户端 IP 的头(例如 X-Real-IP,AWS ALB 是 X-Forwarded-For),按 IP 限流时设 |
RELAY_MAX_CONNECTIONS、RELAY_DAILY_BYTES_PER_HOST | 变量 | 连接数上限、每台 pt-tools 每天的转发量;不设时用 relay 的默认值 |
RELAY_SSH_PORT | 变量 | 服务器的 ssh 端口,默认 22 |
RELAY_SSH_HOST、RELAY_SSH_USER | hosted-relay 的 secret | 服务器的地址与部署用户(要能用 docker) |
RELAY_SSH_KEY | hosted-relay 的 secret | 部署用的 ssh 私钥(只给这一个用途) |
RELAY_SSH_KNOWN_HOSTS | hosted-relay 的 secret | 服务器的主机密钥(ssh-keyscan -p <端口> <主机> 的输出),不接受陌生主机 |
「变量」可以是仓库变量,也可以放在 hosted-relay 里。在服务器上手动部署、收尾或回滚也用同一个脚本:
bash scripts/deploy-relay.sh --image sunerpy/pt-tools:v1.0.0-rc.19 --public-url wss://relay.example.com --client-ip-header X-Real-IP
bash scripts/deploy-relay.sh --finalize # 用 --keep-previous 部署过、检查通过以后删掉旧容器
bash scripts/deploy-relay.sh --rollback # 换回旧容器(第一次部署时删掉新容器)--finalize --image <部署时的 --image> 先核对这次部署仍待收尾(还有 <name>-previous)、<name> 跑的是这个镜像,不是时失败、不删旧容器(同一个镜像重新部署时,看门狗换回的旧容器镜像也一样,只看镜像分不出来)。--rollback 时没有 <name>-previous、<name> 正常在跑,说明没有待收尾的部署(已经收尾,或者看门狗已经换回),以 3 退出并打印正在跑的镜像。
手机 App 发版
App 和 pt-tools 主程序、浏览器扩展使用同一个版本和同一个 Release,不再单独打 mobile-v* tag(最早单独发布的 mobile-v1.0.0-rc.1 留作历史):
- release PR 同时改
apps/mobile/pubspec.yaml的version(release-please-config.json的extra-files,那一行带# x-release-please-version)。只改apps/mobile的提交也会让 pt-tools 发版、写进更新日志。 - 合并 release PR 后,
Releaseworkflow 的mobilejob 和归档、扩展并行:版本取 release tag,构建号由.github/scripts/mobile-build-number.sh推导;构建按 CPU 架构拆分的 release APK(arm64-v8a、armeabi-v7a、x86_64),用 Environmentmobile-release里的 keystore 签名;apksigner核对每个 APK 都是变量ANDROID_CERT_SHA256给的那张证书,aapt2核对 versionName 与 versionCode。 - APK 改名为
pt-tools-app-<ABI>.apk,由Assemble checksums传到 draft、写进checksums.txt,再和其他资产一起由Publish Release校验、生成构建来源证明以后公开。缺签名密钥、pubspec.yaml的版本和 release tag 对不上、构建或核对失败时,mobilejob 失败,整个 Release 停在 draft。
构建号 = 主版本×1000000 + 次版本×10000 + 修订号×100 + RC 序号(正式版记 99),例如 1.0.0-rc.28 → 1000028、1.0.0 → 1000099、1.0.1-rc.1 → 1000101。它随版本单调变大,同一个版本重建时不变;Android 只看它决定能不能覆盖安装(按 ABI 拆包时 Flutter 再加上 ABI×1000)。所以次版本、修订号不能超过 99,rc.N 的 N 不能超过 98。mobile-v1.0.0-rc.1 的构建号是 1,比之后的都小,可以直接覆盖安装。
PR 改到打包流程(.github/actions/build-android-release)、Android 工程、构建号脚本或 pubspec.yaml 时,Mobile Release 的 Verify (test-signed) 用现生成的密钥按同样的方式构建一遍,并跑构建号脚本的用例:不碰任何 secret、没有写权限(产物文件名带 -test-signed,不能发布)。
| 名称 | 放在哪里 | 用途 |
|---|---|---|
ANDROID_KEYSTORE_BASE64 | mobile-release 的 secret | keystore 文件的 base64(base64 -w0 release.jks) |
ANDROID_KEYSTORE_PASSWORD、ANDROID_KEY_ALIAS、ANDROID_KEY_PASSWORD | mobile-release 的 secret | keystore 口令、密钥别名与密钥口令 |
ANDROID_CERT_SHA256 | 变量 | 签名证书的 SHA-256(apksigner verify --print-certs 的写法),正式发版必须设好 |
mobile-release 的部署分支限定为 main 与 v1.0.0-rc(Release workflow 也只在这两个分支上运行)。生成一次 keystore 并妥善备份(丢了以后新版本装不上旧版本之上),再用 apksigner verify --print-certs 读出证书的 SHA-256 填进 ANDROID_CERT_SHA256。别名别用项目名这类日志里常见的词:secret 的值在 Actions 日志里会被换成 ***。
keytool -genkeypair -keystore release.jks -storetype PKCS12 -alias ptt-app-signing -keyalg RSA -keysize 4096 -validity 10000本机构建签名的 release APK:设好 ANDROID_KEYSTORE_PATH、ANDROID_KEYSTORE_PASSWORD、ANDROID_KEY_ALIAS、ANDROID_KEY_PASSWORD 以后在 apps/mobile 里运行 flutter build apk --release --split-per-abi;没有设时 release 构建用 debug 签名,只能自己装着试。
如有开发相关问题,欢迎在 GitHub Discussions 讨论。