Ghost 私有特性标志实战:在 labs.js 与 Admin UI 中注册并验证一个 Private Feature Flag
本文基于 Ghost 仓库的 Agent 技能文档 add-private-feature-flag,完整讲解如何在 Ghost 中新增一个私有(developer experiments)特性标志:从后端 PRIVATE_FEATURES 注册、Admin 设置页的 UI 开关,到单测与 API 快照更新的全流程。读完后你可以独立为 Ghost 新增一个 Labs 标志,并理解标志值的解析优先级、可写键白名单的强制校验,以及各测试体系中标志默认值的差异。
一、私有标志的定位:它出现在哪里、谁能看到
Ghost 使用特性标志(通常称为 Labs flags)来在功能尚未对所有人开放时先行合并代码、提供 beta 能力,或在不移除代码的前提下关闭某功能。私有标志(private flag)的展示规则由 Admin 的 Labs 设置页决定:
- 私有标志显示在 Labs 设置的 Private features 标签页下;
- 该标签页只有在 developer experiments(开发者实验)启用时才会渲染。
这一规则可以在 Admin 源码中得到印证。Labs 设置页组件 中,Private features 的 TabsTrigger 与 TabsContent 都被 config.enableDeveloperExperiments 条件包裹(见 labs.tsx):
<TabsList>
<TabsTrigger value="labs-beta-features">Beta features</TabsTrigger>
{config.enableDeveloperExperiments && (
<TabsTrigger value="labs-private-features">Private features</TabsTrigger>
)}
</TabsList>
因此私有标志面向的是开发者实验场景,而非面向最终用户的功能开关。完整的决策依据(何时该用 Labs 标志、标志的三阶段模型、读取方式、提升与清理流程)以仓库中的正式指南 feature flag 指南 为准,技能文档在动手前也明确要求先阅读该指南。
二、新增私有标志的三步流程
技能文档 .agents/skills/add-private-feature-flag/SKILL.md 给出的标准步骤如下。
步骤 1:在 ghost/core/core/shared/labs.js 注册标志
打开 ghost/core/core/shared/labs.js,将标志名(camelCase 字符串)加入 PRIVATE_FEATURES 数组:
// These features are considered private they live in the private tab of the labs settings page
// Which is only visible if the developer experiments flag is enabled
const PRIVATE_FEATURES = [
'automations',
'automationRunAnalytics',
'stripeAutomaticTax',
// ... 现有标志
// 'myNewFeature', ← 在这里新增,注意 camelCase
];
labs.js 是该文件的唯一注册源,数组之外的关键导出还有两个,它们决定了后续所有校验与读取行为:
module.exports.GA_KEYS = [...GA_FEATURES];
module.exports.WRITABLE_KEYS_ALLOWLIST = [...PUBLIC_BETA_FEATURES, ...PRIVATE_FEATURES];
GA_FEATURES:始终返回true的标志,用于 GA 后的短暂过渡期(当前为automationAnalytics、tagDetailsReact,见 labs.js#L30);PUBLIC_BETA_FEATURES:公开 beta 标志,用户可自由开关(当前含superEditors、editorExcerpt、additionalPaymentMethods、navigationIcons,见 labs.js#L33-L38);WRITABLE_KEYS_ALLOWLIST:两个可写列表的并集,是判断一个标志是否"已注册"的权威集合。
步骤 2:在 Admin 设置页添加 UI 开关
打开 apps/admin/src/settings/advanced/labs/private-features.tsx,向 features 数组追加一条 Feature 条目,flag 字段必须与步骤 1 中 labs.js 里的字符串完全一致:
type Feature = {
title: string;
description: string;
flag: string;
limitName?: string;
};
const features: Feature[] = [
// ...
{
title: 'My new feature',
description: 'Enables ...',
flag: 'myNewFeature', // 必须与 labs.js 中 PRIVATE_FEATURES 里的字符串一致
},
];
该文件组件 AlphaFeatures 会为每个条目渲染一个 LabItem + FeatureToggle,并用 limiter 过滤受订阅计划限制的特性(limitName 可选字段即用于此)。开关的实际写入逻辑在 feature-toggle.tsx 中:
- 从全局 settings 读出
labs设置(一个 JSON 字符串),解析为布尔映射; - 切换时调用
editSettings([{ key: 'labs', value: JSON.stringify({ ...labs, [flag]: newValue }) }])把新值合并回同一个labs设置; - 成功后立即用 react-query 的
setQueriesData同步更新本地 config 缓存中的config.labs[flag],保证前端立即反映新状态。
步骤 3:运行测试并更新 config API 快照
技能文档给出的验证命令:
# 单元测试(labs 服务本身)
cd ghost/core && pnpm test:single test/unit/shared/labs.test.js
# 更新并审查两个 API 快照
cd ghost/core && pnpm test:single test/e2e-api/admin/config.test.js -u
cd ghost/core && pnpm test:single test/e2e-api/admin/settings.test.js -u
执行后需要人工审查两份快照 diff,确认只新增了你自己这个标志,没有夹带其他变化。test:single 脚本会按路径自动选择 vitest 配置(test/unit/* 用默认配置,test/e2e-api/* 用带数据库的 vitest.config.db.ts),见 ghost/core/package.json 中的 test:single 定义。
三、为什么快照一定会变:测试体系下的标志默认值
新增一个默认关闭的标志,为什么会导致 config / settings 快照变化?labs.js 文件头部注释和 labs.test.js 都解释了这一机制:
- Ghost Core 的
integration、e2e、e2e-api等数据库后端测试在 fixture 初始化时会执行enableAllLabsFeatures,把WRITABLE_KEYS_ALLOWLIST(即PUBLIC_BETA_FEATURES+PRIVATE_FEATURES)全部强制为true,以便覆盖所有被标志门控的代码路径; - 因此只要你的标志进入了
PRIVATE_FEATURES,config API 的响应快照就会多出这个 key,即使它在生产环境默认是关闭的。
各测试体系的 Labs 默认值差异(来自 feature-flags 指南):
| 测试体系 | setup 后的默认值 |
|---|---|
| Ghost Core 单元测试 | 不强制开启任何标志,测试内自行 stub 需要的值 |
Ghost Core integration / legacy(testUtils.setup()) |
所有已注册的 private 与 public beta 标志强制开启 |
Ghost Core e2e / e2e-api / e2e-isolated(fixtureManager.init()) |
同上,全部强制开启 |
| React Admin 单测与验收测试(共享 test-data fixture) | labsDefaults 中 key 默认关闭,需显式传 labs 覆盖 |
| Ember Admin 测试(Mirage) | labs 默认为空对象,用 enableLabsFlag / disableLabsFlag 调整 |
顶层 Playwright(e2e/) |
使用新站点自身的值,只有 test.use({labs: ...}) 传入的标志被改变 |
这也意味着:GA_FEATURES 中的标志在所有运行时(含测试)默认开启,直到被移除或被配置覆盖。
四、源码纵深:标志值如何解析,白名单如何强制
解析优先级
labs.js 的 getAll()(labs.js#L69-L92)按以下顺序叠加各来源,后者优先:
stored Labs setting(数据库中的 labs 设置)
→ GA default(GA_FEATURES 强制 true)
→ remote override(远程覆盖清单,自托管默认不启用)
→ config.labs(本地配置文件,始终最高优先级)
源码中这段逻辑的注释写得很直白:config.labs > remote > GA > DB。另有两点细节值得注意:
labs.members是特殊值,始终由members_signup_access设置重算(!== 'none'即为true),不受标志列表控制;- 远程覆盖清单是稀疏的:缺失的 key 不发表意见,布尔值对所有使用该清单的实例生效,
{value, percent}条目则按"标志名 + 站点 UUID"的稳定分桶做近似百分比灰度。
isSet(flag) 就是 !!(getAll()[flag] === true) 的严格判断(labs.js#L102-L106);labs.test.js 中的用例逐一验证了上述优先级,例如 config.labs 钉住 false 时,即使数据库设置与远程覆盖都是 true,isSet 仍返回 false。
白名单强制:标志名必须在三处完全一致
新增标志必须"在 labs.js、private-features.tsx 与快照中同名"这一要求,背后是两层服务端强制校验,二者都引用 WRITABLE_KEYS_ALLOWLIST:
- Settings 模型校验:settings.js 的
labs校验器会解析 JSON,任何一个不在白名单内的 key 都直接抛ValidationError("Settings lab value cannot have value other then ..."); - API 输入序列化过滤:settings 输入序列化器 在
key === 'labs'时按白名单过滤,未注册的 key 会被静默丢弃。
也就是说,如果只加了 UI 开关而忘了注册后端标志,开关操作会被服务端拒绝或丢弃;反过来只注册后端标志而不同步 UI,则该标志不会出现在 Labs 页面。标志字符串的一致性由校验器兜底,而不是靠开发者自觉——这也是快照必须同步更新的原因。
读取标志的配套 API
注册完成后,门控代码按运行环境选用对应 API(均出自 feature-flags 指南):
- Ghost Core 服务端:
labs.isSet('myFeature'); - 需要整条 API 路由在关闭时返回 404:
labs.enabledMiddleware('myFeature')(实现见 labs.js#L153-L160,未开启时抛NotFoundError); - 主题模板辅助函数:从
@labs.myFeature读取计算值;必须报告"功能被禁用"错误的 helper 用labs.enabledHelper(...); - React Admin:
useFeatureFlag(来自@tryghost/admin-x-framework/hooks),它读取服务端在 Admin config 响应中计算好的值,响应缺失或加载中时返回false; - 旧版 Ember Admin:
feature服务,this.feature.get('myFeature')。
指南特别强调:门控决策要放在"拥有该行为"的边界上——隐藏按钮保护不了服务端端点,拒绝端点也不等于给了 Admin 一个可用的禁用态。
五、备注:无需迁移、命名规范与 beta 变体
技能文档 Notes 部分的要点,结合源码补充说明如下:
- 无需数据库迁移:所有 Labs 值(private + public beta)存储在同一个 JSON 格式的
labs设置里,Admin 的FeatureToggle写入的也正是这个单一设置项; - 命名规范:标志是 camelCase 字符串(如
welcomeEmailDesignCustomization、machinePayments),且必须在labs.js、private-features.tsx与快照三处完全一致; - 公开 beta 标志走另一条路:如果功能要面向所有用户可见(而非仅限开发者实验),应把 key 加入
labs.js的PUBLIC_BETA_FEATURES,并把开关加到 beta-features.tsx; - 完整生命周期:
private 或 public beta → GA → 删除标志与旧分支。GA 阶段把 key 移入GA_FEATURES(默认开启且不再可写)、移除 Admin 开关、更新快照;确认所有受支持的部署都能运行开启行为后,尽快删除标志、禁用分支以及只为该路径而存在的测试。
六、操作清单(速查)
| 序号 | 操作 | 文件 / 命令 |
|---|---|---|
| 1 | 将 camelCase 标志名加入 PRIVATE_FEATURES |
ghost/core/core/shared/labs.js |
| 2 | 在 features 数组追加 {title, description, flag} 条目 |
apps/admin/src/settings/advanced/labs/private-features.tsx |
| 3 | 运行 labs 单元测试 | cd ghost/core && pnpm test:single test/unit/shared/labs.test.js |
| 4 | 更新 config / settings API 快照并审查 diff | cd ghost/core && pnpm test:single test/e2e-api/admin/config.test.js -u && pnpm test:single test/e2e-api/admin/settings.test.js -u |
| 5 | 门控服务端与浏览器两侧行为,并为开/关两态各补测试 | 参照 feature-flags 指南 |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00