macOS 应用写完之后,怎么交到用户手里(三):让官网跟着版本一起更新

本文最后更新于 2026年10月14日 晚上

上一篇《从手动流程到一条命令》把本机发布串起来了:准备版本、测试、签名、公证、附加票据,最后生成安装包和更新清单。

应用能发出去了,另一个问题就冒出来了:官网还得跟着改。

发一个新构建,要修改中文首页、英文首页、两份下载页、两份更新日志。版本号改了,包大小可能没改;下载链接换了,搜索引擎读取的元数据还指着旧包。页面都能打开,里面的信息却对不上。

我这次做的事,就是让 SSHGuard 的官网和应用读取同一份发布数据。网站继续用简单的 HTML 和 CSS,Cloudflare 只负责补上容易漏改的版本信息。

这篇按实际项目讲清楚:Pages、R2 和 Pages Functions 分别做什么,为什么不用再加一份 JSON 清单,以及下一次发布到底还要手动做哪些事。

下文的部署域名、桶名、绑定名和项目专属路径均使用示例值,不代表实际后台配置。public、functions、_routes.json 等 Cloudflare 约定名称保留;使用时请将示例替换为自己的配置。

一、官网需要更新的,其实只有一小部分

先把内容分开看。

产品介绍、截图、安装指南和隐私说明,属于网站内容。它们并不会每次发版都变化,继续留在网站仓库里维护就好。

版本号、构建号、包大小、下载链接和更新日志,属于发布内容。应用打包时已经知道这些信息,再到官网手填一次,很容易出现偏差。

所以这次没有重新搭建网站,也没有引入后台管理系统。只让中文和英文的三个页面读取发布数据:

页面 自动同步什么
首页 版本、包大小、最低系统要求、下载链接和相关结构化元数据
下载页 版本、包大小、最低系统要求、ZIP 和 SHA-256 链接
更新日志 对应语言、对应构建的更新说明

其他页面、截图和 CSS 继续按原来的方式发布。

二、Cloudflare 的几个名字,到底各管什么

第一次接触这些服务时,很容易把它们当成同一件事。我实际用到的是下面三个:

服务 在这个项目里的用途
Cloudflare Pages 部署官网的 HTML、CSS、图片和脚本
Cloudflare R2 保存安装包、校验文件、更新说明和 appcast
Pages Functions 在用户访问指定页面时,读取发布数据并填进 HTML

网站地址是:

1
https://apps.example.com/demo-app/

安装包和应用更新源在:

1
https://downloads.example.com/demo-app/

两个域名分别给用户看页面和下载文件。官网不会把 ZIP 读进函数再转发一次,下载按钮直接指向 R2 的公开地址。

Pages Functions 运行在 Cloudflare 的 Workers 运行时上,这个项目不用再单独建立一个 Worker 服务。R2 绑定让函数可以直接访问桶里的对象。Cloudflare 的绑定说明

部署网站时,Pages 从 GitHub 拉取代码并编译函数。用户每次打开网页,运行的是已经部署好的函数,不会再去 GitHub 读取代码。

三、网站还是静态页面,版本信息在返回前填进去

以下载页为例,网站仓库里仍然有一份正常的 HTML。它里面有标题、安装说明和最后一次核实的版本信息。

用户访问页面时,函数先取得这份静态响应,再读取 R2 中的 appcast,找到最新正式版本,替换指定位置,最后返回完整页面。

sequenceDiagram
    participant U as 用户浏览器
    participant P as Pages 与 Functions
    participant R as R2

    U->>P: 打开 SSHGuard 下载页
    P->>P: 取得静态 HTML
    P->>R: 读取 appcast,或使用有效缓存
    R-->>P: 版本、包大小、下载地址
    P->>P: 校验数据,替换指定内容
    P-->>U: 返回完整 HTML
    U->>R: 点击下载,直接获取 ZIP

这里的“替换”用的是 Cloudflare 的 HTMLRewriter。它可以按选择器找到 HTML 中的元素,修改文字、属性或正文。HTMLRewriter 文档

页面里的标记很简单,例如:

1
2
<h2 data-release-title>SSHGuard 1.0.0 (5)</h2>
<a data-release-zip href="旧版的真实下载地址">下载 macOS 版</a>

函数只找这些明确的标记,不靠“第三个链接”“第二段文字”这样的顺序去猜位置。后面调整页面布局,也不容易误改其他内容。

首页的 JSON-LD 也要一起更新。它是提供给搜索引擎的结构化信息;用户看不到,并不代表可以留着旧版本。版本、地址和大小会从同一份数据填入,避免正文和元数据各说各话。

浏览器拿到的已经是完整 HTML。这个版本同步功能不依赖浏览器 JavaScript,关闭 JavaScript 也能看到更新后的版本和下载链接。

四、为什么没有再加一个 releases.json

最开始考虑过给官网单独生成一份 releases.json。

它当然能工作:软件读取 appcast,官网读取 JSON。但两份文件都要记录版本、包大小和下载链接,多了一处生成、上传和核对的工作。对当前项目来说,收益不大。

SSHGuard 已经通过 Sparkle 检查更新,现有的 appcast.xml 本来就包含这些信息:

  • 构建号和对外显示的版本号;
  • 最低系统要求;
  • 安装包的完整下载地址和字节数;
  • 更新包签名;
  • 更新说明地址。

Sparkle 的 appcast 使用具体 URL 指向安装包和说明,并不要求它们放在同一个目录。Sparkle 的发布说明

当前就共用这份清单。官网读取其中的元数据,再按说明链接取得正文。

不过,共用也有一个前提:不能随手裁剪 appcast 的历史条目。 官网用它列历史,删除条目后,官网也会少掉对应版本。

如果以后需要稳定版、测试版分别发通知,或者应用清单只保留少量可升级版本,官网却要保留完整历史,那时再拆一个专门的历史索引会更合适。现在先不增加这层维护。

五、更新说明改用双语 Markdown

之前发布脚本把中文 Markdown 转成 HTML,供软件显示更新说明。现在 Sparkle 2.10.0 已能显示基本 Markdown,而 SSHGuard 最低支持 macOS 14,满足其运行条件。Sparkle 从 2.9 开始提供这项支持,要求 macOS 12 及以上。Sparkle 文档

因此新版本的说明直接用:

1
2
notes.zh.md
notes.en.md

应用按语言读取说明,官网也读取同一份内容。官网把 Markdown 转成自己的 HTML,使用网站现有的排版。

这并不意味着英文会凭空出现。准备版本时,脚本会创建中英文两份草稿,发布前都需要补全和审校。这里只自动化文件生成和数据同步,函数运行时不会替用户机器翻译。

说明只使用基本标题、段落、列表、分隔线和行内代码。版本日志用不着完整的 Markdown 插件体系,也不应该把里面的任意 HTML 当作代码执行。

还有一个之前踩过的坑:UTF-8 文件本身正确,浏览器仍可能显示乱码。 上传 Markdown 时,把对象的响应类型设为:

1
Content-Type: text/markdown; charset=utf-8

官网通过函数读取后会自行渲染,但直接打开说明地址、以及软件读取说明时,也应该给出正确的内容类型和编码。

六、每个版本放在自己的目录里

安装包、校验文件和双语说明按版本放在一起,发布时上传这一组文件即可。下面以构建 6 为例,不代表它已经发布:

1
2
3
4
5
6
7
8
demo-app/
├── appcast.xml
└── releases/
└── 1.0.0-6/
├── SSHGuard-1.0.0-6.zip
├── SSHGuard-1.0.0-6.zip.sha256
├── notes.zh.md
└── notes.en.md

appcast.xml 的地址固定,每次发布更新它的内容。安装包和说明使用具体的版本地址,发布后不覆盖成另一份内容。

应用和官网都按清单中的完整 URL 找文件,不需要遍历桶,也不需要递归查找版本目录。

七、绑定 R2,不需要把访问密钥放进网页

在 Pages 项目中给 R2 配置一个绑定:

1
2
变量名:APP_RELEASES
桶名称:demo-app-releases

函数中就可以这样读取:

1
const object = await context.env.APP_RELEASES.get('demo-app/appcast.xml');

APP_RELEASES 是示例绑定名,demo-app-releases 是示例桶名。两者可以不同,但变量名大小写必须与代码一致。绑定修改后需要重新部署才能生效。R2 绑定步骤

这段代码在 Cloudflare 上运行,浏览器不会收到 R2 访问密钥,也不需要为了读取版本清单去配置浏览器跨域请求。

绑定给了函数访问桶的能力,不等于桶已经公开。函数读取清单和说明使用绑定;用户下载 ZIP 使用公开域名。它们是两条不同的访问路径,公开下载仍要单独验证。

配置后来放进官网仓库的 wrangler.jsonc,生产绑定真实更新桶,预览配置指向隔离测试桶。预览桶需要事先创建并填入测试数据,不能只写一个名字就认为资源已经存在。

八、只让六个页面运行函数

官网还承载其他应用,没必要让所有请求都绕一遍 SSHGuard 的版本逻辑。

中英文各三个页面由 middleware 接入共享处理函数,再通过 public/_routes.json 限定调用范围。目录结构大致是:

1
2
3
4
functions/
├── _lib/releases.js
├── demo-app/_middleware.js
└── en/demo-app/_middleware.js

路由明确包括首页、下载页、更新日志页,以及它们没有尾斜杠和带 index.html 的入口别名。其他应用、指南、隐私页面、图片和 CSS 保持静态服务。

不能因为只在某个目录里放了函数,就认为所有子资源都不会调用它。函数调用范围需要明确配置。Pages Functions 路由文档

这样后续调整版本同步逻辑,影响范围也容易检查:就这六页。

九、缓存与回退:网页能打开,不代表同步成功

如果每次打开下载页都读取 R2,每次打开日志页又读取一批说明,重复工作会越来越多。

当前采用两种缓存时间:

数据 缓存时间 原因
appcast 索引 5 分钟 随正式发布变化,需要较快刷新
已发布版本说明 1 天 按版本固定,发布后原则上不覆盖

这是函数显式使用 Cache API 保存的数据缓存。R2 绑定的 get() 不会因为配置了一个 fetch 缓存参数就自动走相同机制。Cache API 的内容保存在各数据中心,不能保证全球同一时刻刷新。Cache API 文档

所以上传新清单后,某些地区的官网可能还短暂显示上一版。软件也在自己的检查时机读取更新源,不是清单上传成功后所有用户立即收到推送。

应用读取的公开 appcast 地址仍按更新源规则绕过 CDN 缓存;官网函数的五分钟内部缓存,是另一层缓存。两者不要混在一起看。

动态页面响应本身使用 no-store,避免生成的 HTML 又被长时间缓存。安装包使用独立的版本地址,适合采用较长缓存时间。

再说失败处理。读取不到清单、缺少英文说明、数据格式错误时,网站会保留整份静态页面,记录具体错误。不会只换新版标题,却留下旧版下载地址。

当前实现先把数据和替换后的完整 HTML 准备好,再返回响应,确保转换过程中出错时仍能使用原始页面。

判断是否真的同步成功,可以看响应头:

1
2
X-Release-Source: r2
X-Release-Build: 5

如果看到:

1
X-Release-Source: static-fallback

说明网站正在使用静态兜底。它能让下载页继续可用,但不保证显示最新版。静态兜底需要在网站维护时刷新,错误日志也要能查到原因。

十、这次真正踩到的坑:Functions 有依赖,构建命令不能继续留空

网站原来是纯静态 HTML,Pages 的构建命令可以留空。

加入 Functions 后,代码用了 fast-xml-parser 来解析 XML。本地已经安装依赖,所以本地编译和验证正常。云端第一次部署却报错:

1
2
No build command specified. Skipping build step.
Could not resolve "fast-xml-parser"

这两行放在一起,原因就很清楚了:这次云端没有执行依赖安装,编译函数时找不到解析器。

处理方式是在 Pages 的构建配置中设置:

设置 值
框架预设 无
构建命令 npm ci && npm run check
构建输出目录 public
根目录 仓库根目录,留空

npm ci 按锁文件安装依赖;npm run check 检查静态页面;之后 Pages 编译并发布 Functions。仅修改 README 不会自动改变控制台里原来留空的构建命令。Pages 构建配置

Node 版本也要明确。本项目锁定的本地工具要求 Node 22 及以上,可以在“变量和机密”中配置 NODE_VERSION,例如 22.16.0;如果选择已发布的 Node 24 版本,也应在云端实际验证一次。构建环境与版本设置

也不要按错误提示把 fast-xml-parser 标成 external 来绕过构建。函数运行时同样需要它,正确做法是安装并打包依赖。

另一个需要注意的地方是响应头:静态文件的 _headers 规则不会自动给 Functions 生成的响应补齐所有头。项目在函数响应中显式保留现有安全策略,并去掉转换后不再适用的长度和静态 ETag。Pages 响应头说明

十一、以后发布,网站和软件分别要做什么

可以把工作分成两条线。

网站代码发生变化时,例如调整布局、增加截图、修改函数逻辑,需要修改官网仓库,再通过 GitHub 与 Pages 部署。

普通软件发版时,网站函数已经部署,就只更新 R2 的发布数据,不需要为版本号再改六份 HTML。

flowchart TD
    subgraph SITE[官网代码更新]
        A[修改页面、截图或函数] --> B[提交并推送官网仓库]
        B --> C[Pages 安装依赖、检查并部署]
    end

    subgraph RELEASE[普通软件发版]
        D[准备并审校双语说明] --> E[本机测试、签名、公证和验证]
        E --> F[生成 ZIP、校验文件、说明和候选清单]
        F --> G[先上传本版文件到 R2]
        G --> H[验证公开文件]
        H --> I[最后发布 appcast]
        I --> J[官网按缓存窗口读取新版]
        I --> K[软件下次检查时发现新版]
        J --> L[核对网页并登记正式发布]
        K --> M[完成真实升级验收]
    end

本机操作现在集中到一个入口。下面以示例脚本 scripts/release.sh 演示操作顺序,实际入口名称按自己的项目替换:

1
2
3
4
5
6
7
8
# 1. 准备构建号与中英文草稿
bash scripts/release.sh --build 6

# 2. 审校说明、删除草稿标记、提交后预检
bash scripts/release.sh --dry-run

# 3. 构建、签名、公证并生成发布文件
bash scripts/release.sh

之后仍然手动上传 R2。先上传本版目录中的 ZIP、校验文件和两份说明,然后运行:

1
2
# 4. 核对公开产物;此时清单可以仍然是上一版
bash scripts/release.sh --verify

文件核对通过,再上传 appcast:

1
2
# 5. 清单上线后再次核对,通过才登记正式发布
bash scripts/release.sh --verify --record

--record 会更新仓库中的 docs/published-appcast.xml,需要审查后提交。它不是“上传成功就记一笔”,而是公开文件和完整清单与本地一致时才登记。

最后还要检查官网六页,以及用旧应用完成一次下载、安装、重启。函数响应正常、公开文件可下载、应用实际升级成功,是三件不同的事。

Shell 脚本负责串起工具和执行顺序,Python 处理版本与 XML。日常只使用统一入口,不需要逐个运行内部脚本,也没有必要为了语言一致把它们重写成一种语言。

十二、已经验证的和还需要真跑的

目前本地已经验证了:

  • 中英文六页使用同一版本、下载地址和包大小,首页 JSON-LD 同步;
  • 基本 Markdown 渲染和危险内容处理;
  • 日志每页十版,读取有数量及大小上限;
  • 说明缺失、清单损坏时保留完整静态页面;
  • 入口别名重定向正常,其他应用和静态资源保持原行为。

本地使用 Wrangler 与模拟 R2 验证。普通 Python 静态服务器只会展示兜底 HTML,不能用来判断 Functions 是否工作;本地模拟桶也不会自动读取生产数据。

初次云端部署还暴露了上面的依赖安装问题。生产环境是否已经正确绑定、函数是否真的返回动态数据,应以部署结果和响应头为准,不能因为页面能打开就认为全部完成。

构建 6 的目录只是本文示例。下一版双语说明在 Sparkle 中的实际展示、公开文件核对、旧版真实升级,都需要用正式产物再验一次。

做到这里,重复手填的版本信息基本移交给发布数据了。每次发布仍然需要认真写说明、验证用户拿到的包和完成升级验收;网站里那些“版本改了,链接忘了改”的工作,则不必再靠记忆。


macOS 应用写完之后,怎么交到用户手里(三):让官网跟着版本一起更新
https://iyuluo.com/2026/10/14/macOS-应用写完之后,怎么交到用户手里(三):让官网跟着版本一起更新/
作者
yuluo
发布于
2026年10月14日
许可协议