feat(release): 新增一键发布任务

This commit is contained in:
ysicing committed 2026-09-22 13:55:54 +08:00
1 parent b0f490a564
commit 8eb4cc8781
8 files changed
+449 -1

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
---
name: xstats-release
description: Execute, resume, or verify the XStats macOS client release workflow, including semver metadata, arm64 Developer ID signing, Apple notarization, c-ip object storage, regional update APIs, GitHub Release, Homebrew tap, and CI. Use for an actual XStats app release or a partial-release recovery; do not use for XStats Server development or server-image delivery.
---
<!-- Copyright (C) 2026 ysicing -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# XStats Release
Release the XStats macOS client without letting its binary, Git tag, object-storage artifacts, update manifests, or Homebrew cask drift apart.
Before acting, read the repository `AGENTS.md`, the release section of `DEVELOPMENT.md`, `Taskfile.yml`, and `Scripts/release.sh` / `Scripts/publish_release.sh`. Treat the repository scripts as the current implementation; use [references/runbook.md](references/runbook.md) for the operational sequence and recovery checks.
## Boundaries
- Work in the repository containing this skill. Confirm it is the intended XStats checkout rather than assuming a fixed absolute path.
- This skill covers the macOS app release only. Do not change the separate XStats Server repository, server image workflow, Cloudflare policy, Kubernetes workloads, or storage topology merely because a release touches their endpoints.
- A request to inspect or prepare a release does not authorize commit, push, tag creation, uploads, API writes, Homebrew changes, or other external mutation. Require the user's explicit release or delivery instruction before those actions.
- Do not create a PR unless the user explicitly requests one.
- Never print, commit, persist in project files, or repeat signing credentials, storage keys, or `XSTATS_RELEASE_TOKEN`. Read secrets from an already authorized environment or credential store.
## Release invariants
- Public versions are semver from the first formal `CHANGELOG.md` heading; date-style versions are historical test data.
- Release artifacts contain only arm64 binaries.
- Public distribution uses `Developer ID Application`, hardened runtime, secure timestamps, notarization, stapling, and Gatekeeper acceptance. `Apple Development` and `SKIP_NOTARIZE=1` are not public-release substitutes.
- The notarization keychain profile is `XStats` unless the user explicitly selects another valid profile.
- Upload through the MinIO alias `c-ip` to `c-ip/oss/apps/macOS/XStats`; public downloads use `https://c.ysicing.net/oss/apps/macOS/XStats/`.
- Publish the same appcast to the global and China release APIs. The manifest must remain last: packages first, then GitHub Release, then update APIs.
- The Homebrew target is `ysicing/homebrew-tap`, installed as `ysicing/tap/xstats`.
- `dist/release-provenance.json` binds artifacts to the build-time commit, version, build number, and version-file hashes. Do not bypass it or rebuild provenance manually.
## Execution shape
1. Establish a clean, current source baseline and pass repository tests/CI.
2. Prepare only release metadata, then let `task release` advance `project.yml`, build, sign, notarize, staple, and generate `dist/`.
3. Verify the installed app and generated artifacts before committing release metadata.
4. Commit and push only the allowed metadata, then run the provenance verifier.
5. Run the idempotent publish script and verify every external destination independently.
When the user explicitly authorizes the complete release, `task release-all` is the canonical one-command entry and includes commit, push, and every external publication. Use `task release` for build-only work and `task publish` to resume an already built and pushed release.
Stop rather than weakening a safety check when the source changed after the build, the tag points elsewhere, notarization is not accepted, public hashes differ, an API returns anything other than its expected success, or a credential is unavailable.
## Partial-release handling
- Determine the last proven stage before retrying. Object uploads, GitHub Release updates, API publication, and tap updates are separate facts.
- Run `task publish` only after confirming the same provenance record and release commit; it invokes the idempotent publisher without rebuilding.
- A Cloudflare response with `cf-mitigated: challenge` is an edge failure, not an application authentication failure. Use an already authorized clean egress when available; do not solve the challenge interactively or broadly disable protections.
- If object upload fails, confirm the target is `c-ip`. Do not silently substitute `c`, `cos`, `home`, `ggy`, another bucket, or GitHub assets.
- Never report completion from a successful build, upload, or tag alone. Completion requires the final verification checklist in the runbook.
@@ -0,0 +1,7 @@
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
interface:
display_name: "XStats Release"
short_description: "Build, notarize, publish, and verify XStats releases"
default_prompt: "Use $xstats-release to publish and verify the next XStats macOS release."
@@ -0,0 +1,155 @@
<!-- Copyright (C) 2026 ysicing -->
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# XStats macOS release runbook
Use this runbook only for an actual release, resuming a partial release, or verifying one. Re-read the repository scripts first; if they disagree with this file, investigate the drift before mutating anything.
## 1. Preflight
From the XStats repository:
```bash
git status -sb
git fetch origin main
git rev-parse HEAD
git rev-parse origin/main
task test
./Scripts/version.sh
```
Confirm:
- `HEAD` is the intended release source and is pushed before release metadata is prepared.
- Existing working-tree changes are understood and limited to the release task.
- The requested tag and GitHub Release do not already exist, unless this is an intentional idempotent resume.
- The top changelog section contains the intended release content.
Check external prerequisites without exposing credentials:
```bash
security find-identity -v -p codesigning
xcrun notarytool history --keychain-profile XStats --output-format json
mc stat c-ip/oss
gh auth status
```
The signing identity must be `Developer ID Application` with its private key. Merely having a valid `Apple Development` identity or a working notary profile is insufficient.
For the one-command path, `XSTATS_RELEASE_TOKEN` must already be available through an authorized environment or credential store. `task release-all` refuses to start when the token is absent, the branch is not `main`, `HEAD` differs from `origin/main`, or the release tag already exists.
## 2. Prepare version metadata
- Replace the first `## 未发布` heading with `## X.Y.Z · YYYY-MM-DD`.
- Update the release badge in all four README files.
- Do not manually change `project.yml`; `Scripts/version.sh release`, invoked by `task release`, writes the semver and increments the build number once.
- Do not commit these metadata changes yet. They are allowed inputs to the provenance preflight.
Run:
```bash
python3 Scripts/release_provenance.py prepare
git diff --check
task release
```
`task release` defaults to the `XStats` notary profile. Capture both Apple submission IDs and require `Accepted` for the app and DMG.
When the user explicitly authorizes commit, push, and all external publication, the canonical automated path is instead:
```bash
task release-all
```
It runs `task test`, performs the same release build, stages only allowed release metadata, commits and pushes it, then invokes `task publish`. After it returns, continue with the final verification section rather than repeating the manual commit or publish steps.
## 3. Verify local artifacts
Expected files:
```text
dist/XStats-X.Y.Z-AppleSilicon.dmg
dist/XStats-X.Y.Z-AppleSilicon.zip
dist/appcast.json
dist/xstats.rb
dist/release-provenance.json
```
`install_local.sh` moves the finished app to `/Applications`, so verify architecture and signatures there rather than assuming the DerivedData app still exists:
```bash
lipo -archs /Applications/XStats.app/Contents/MacOS/XStats
lipo -archs /Applications/XStats.app/Contents/MacOS/XStatsHelper
lipo -archs /Applications/XStats.app/Contents/PlugIns/XStatsWidget.appex/Contents/MacOS/XStatsWidget
codesign --verify --deep --strict --verbose=2 /Applications/XStats.app
spctl -a -vv /Applications/XStats.app
xcrun stapler validate dist/XStats-X.Y.Z-AppleSilicon.dmg
spctl -a -t open --context context:primary-signature -vv dist/XStats-X.Y.Z-AppleSilicon.dmg
```
All architectures must be `arm64`; both app and DMG must report notarized Developer ID acceptance. Validate the appcast JSON, artifact sizes, and SHA-256 values.
## 4. Commit release metadata
Stage only the intended release metadata, normally:
```text
project.yml
CHANGELOG.md
README.md
README.en.md
README.ja.md
README.ko.md
```
Include `Assets/readme/activity.svg` and `Assets/readme/activity.zh.svg` only if this release actually changed them. Verify the staged list, commit with `chore(release): 发布 X.Y.Z`, and push only when authorized.
After pushing:
```bash
python3 Scripts/release_provenance.py verify dist/release-provenance.json X.Y.Z BUILD
```
The verifier must pass before uploads or tag creation.
## 5. Publish
Provide `XSTATS_RELEASE_TOKEN` through an authorized environment or credential store; never place it in a command transcript, repository file, or chat response. Then run the Task wrapper:
```bash
task publish
```
The script must use these defaults:
```text
MC_TARGET=c-ip/oss/apps/macOS/XStats
DOWNLOAD_BASE=https://c.ysicing.net/oss/apps/macOS/XStats
TAP=ysicing/homebrew-tap
```
Its required order is:
1. Verify provenance, clean worktree, and `HEAD == origin/main`.
2. Upload DMG and ZIP to `c-ip`.
3. Download each public `c.ysicing.net` URL and compare SHA-256.
4. Create or update `vX.Y.Z` GitHub Release, refusing to move an existing mismatched tag.
5. Publish the appcast to both regional APIs.
6. Update `Casks/xstats.rb` in `ysicing/homebrew-tap`.
If manual recovery is unavoidable, preserve this order. Do not publish an appcast that references an unavailable object.
## 6. Final verification
Prove all of the following from live state:
- `git status -sb` is clean and local `main` matches `origin/main`.
- The release tag resolves to the intended release metadata commit.
- GitHub Release is neither draft nor prerelease and contains the notarized DMG with the expected digest.
- Both public object URLs return bytes matching the local DMG and ZIP SHA-256 values.
- Both regional update-check endpoints return the released version/build and `c.ysicing.net` URLs. Reuse one fixed validation installation ID instead of creating many statistics rows.
- The remote Homebrew cask matches `dist/xstats.rb`; `brew info --cask ysicing/tap/xstats` shows the released version and arm64/macOS requirements.
- The release commit and any immediate release-workflow fix commits have green GitHub Actions runs.
- Temporary port forwards, credential aliases, debug pods, and staging directories created by this run are removed.
Report separate evidence for signing/notarization, object storage, GitHub Release, both update APIs, Homebrew, CI, and Git cleanliness. If any item is missing, call the release partial rather than complete.
+3 -1
View File
@@ -57,7 +57,9 @@ write the online-update zip and `appcast.json`, and write a Homebrew cask (`auto
whose URLs point at the object storage prefix `https://c.ysicing.net/oss/apps/macOS/XStats`.
The release records the source commit plus version-file hashes in `dist/release-provenance.json`;
publishing accepts only a clean descendant that changed release metadata, so the Git tag cannot
silently include source different from the packaged binaries. The helper derives its client
silently include source different from the packaged binaries. `task release-all` runs tests, builds,
commits and pushes only release metadata, then publishes every external target; `task publish`
resumes the idempotent external half without rebuilding. The helper derives its client
requirement from its own signing team at run time, so no team ID is hard-coded.
## Sampling
+14
View File
@@ -111,6 +111,20 @@ NOTARY_PROFILE=XStats task release
`publish_release.sh` 会验证最终提交只改了允许的版本元数据,且工作区、版本文件与构建记录一致,
避免安装包与 Git tag 对应源码不一致。
准备好 CHANGELOG 正式标题和四个 README 的版本徽章后,可用一个命令执行完整流程:
```bash
# 需要提前通过安全环境提供 XSTATS_RELEASE_TOKEN;该命令会 commit、push 并写入所有发布目标
task release-all
```
它会依次运行测试、签名公证构建、精确暂存版本元数据、创建并推送 release commit,
再上传对象存储、创建 GitHub Release、发布双区域清单并更新 Homebrew tap。
如果 release commit 已推送后外部发布失败,修复外部问题后运行 `task publish` 续跑,
不要再次运行 `task release-all`,否则会重复推进构建号。
需要逐阶段审阅或手工恢复时,按下面的分步流程执行。
打包完成后按顺序执行:
```bash
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
# 一键执行完整发版:测试 → 签名公证构建 → 提交推送版本元数据 → 发布外部制品。
# 调用这个脚本即授权本次 release commit、push、GitHub Release、对象存储、版本 API 与 Homebrew 写入。
set -Eeuo pipefail
cd "$(dirname "$0")/.."
if [ -z "${XSTATS_RELEASE_TOKEN:-}" ]; then
echo "error: XSTATS_RELEASE_TOKEN 不能为空,一键发布尚未开始。" >&2
exit 1
fi
BRANCH="$(git symbolic-ref --quiet --short HEAD)" \
|| { echo "error: 当前不是可发布的分支检出。" >&2; exit 1; }
[ "$BRANCH" = main ] \
|| { echo "error: 一键发布只能从 main 执行,当前是 ${BRANCH}。" >&2; exit 1; }
git fetch --quiet origin main
HEAD_SHA="$(git rev-parse HEAD)"
REMOTE_SHA="$(git rev-parse origin/main)"
[ "$HEAD_SHA" = "$REMOTE_SHA" ] \
|| { echo "error: HEAD 与 origin/main 不一致,请先处理未推送或远端更新。" >&2; exit 1; }
HEADING="$(grep -m1 '^## ' CHANGELOG.md || true)"
if [[ "$HEADING" =~ ^##[[:space:]](0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)[[:space:]]·[[:space:]][0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
else
echo "error: CHANGELOG.md 顶部不是正式 semver 版本标题:${HEADING:-(没有二级标题)}" >&2
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "error: v${VERSION} 已存在;若是恢复中断的发布,请运行 task publish。" >&2
exit 1
fi
RECOVERY=""
on_error() {
local status=$?
case "$RECOVERY" in
push)
echo "error: release commit 已创建但尚未确认推送成功;请先运行 git push origin main。" >&2
;;
publish)
echo "error: release commit 已推送;请修复外部问题后运行 task publish 续跑,不要再次执行 release-all。" >&2
;;
esac
exit "$status"
}
trap on_error ERR
# 在投入签名和公证时间前拒绝任何未提交源码;版本元数据仍可按发布流程保持未提交。
python3 Scripts/release_provenance.py prepare >/dev/null
task test
task release
BUILT_VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
[ "$BUILT_VERSION" = "$VERSION" ] \
|| { echo "error: 构建版本 $BUILT_VERSION 与 CHANGELOG 版本 $VERSION 不一致。" >&2; exit 1; }
# 构建后再次检查,防止构建工具或并行编辑把源码变化混入 release commit。
python3 Scripts/release_provenance.py prepare >/dev/null
RELEASE_FILES=(
project.yml
CHANGELOG.md
README.md
README.en.md
README.ja.md
README.ko.md
Assets/readme/activity.svg
Assets/readme/activity.zh.svg
)
git add -- "${RELEASE_FILES[@]}"
if git diff --cached --quiet; then
echo "error: 没有可提交的发布元数据。" >&2
exit 1
fi
git commit -m "chore(release): 发布 ${VERSION}"
RECOVERY=push
git push origin main
RECOVERY=publish
./Scripts/publish_release.sh
RECOVERY=""
echo "✅ XStats ${VERSION} 完整发布完成"
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
# Copyright (C) 2026 ysicing
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$PWD"
[ -f Scripts/release_all.sh ] || { echo "缺少 Scripts/release_all.sh" >&2; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
setup() {
rm -rf "${WORK:?}/repo" "${WORK:?}/bin"
mkdir -p "$WORK/repo/Scripts" "$WORK/repo/Assets/readme" "$WORK/bin"
cp "$ROOT/Scripts/release_all.sh" "$WORK/repo/Scripts/release_all.sh"
chmod +x "$WORK/repo/Scripts/release_all.sh"
cat > "$WORK/repo/project.yml" <<'YAML'
settings:
base:
MARKETING_VERSION: "0.7.0"
CURRENT_PROJECT_VERSION: "110"
YAML
cat > "$WORK/repo/CHANGELOG.md" <<'LOG'
# 更新日志
## 0.8.0 · 2026-09-22
- 示例。
LOG
for file in README.md README.en.md README.ja.md README.ko.md; do
printf '# XStats\n' > "$WORK/repo/$file"
done
: > "$WORK/repo/Assets/readme/activity.svg"
: > "$WORK/repo/Assets/readme/activity.zh.svg"
: > "$WORK/log"
cat > "$WORK/bin/git" <<'SH'
#!/bin/bash
set -euo pipefail
echo "git $*" >> "$RELEASE_ALL_TEST_LOG"
case "${1:-}" in
symbolic-ref) printf '%s\n' "${FAKE_BRANCH:-main}" ;;
rev-parse) printf '%s\n' "${FAKE_SHA:-abc123}" ;;
ls-remote) [ "${FAKE_TAG_EXISTS:-0}" = 1 ] ;;
diff) [ "${3:-}" != --quiet ] ;;
esac
SH
cat > "$WORK/bin/task" <<'SH'
#!/bin/bash
set -euo pipefail
echo "task $*" >> "$RELEASE_ALL_TEST_LOG"
if [ "${1:-}" = release ]; then
sed -i '' -E 's/(MARKETING_VERSION: *)"[^"]+"/\1"0.8.0"/' project.yml
sed -i '' -E 's/(CURRENT_PROJECT_VERSION: *)"[^"]+"/\1"111"/' project.yml
fi
SH
cat > "$WORK/bin/python3" <<'SH'
#!/bin/bash
set -euo pipefail
echo "python3 $*" >> "$RELEASE_ALL_TEST_LOG"
printf 'abc123\n'
SH
cat > "$WORK/repo/Scripts/publish_release.sh" <<'SH'
#!/bin/bash
set -euo pipefail
echo publish >> "$RELEASE_ALL_TEST_LOG"
SH
chmod +x "$WORK/bin/git" "$WORK/bin/task" "$WORK/bin/python3" \
"$WORK/repo/Scripts/publish_release.sh"
}
expect_failure() {
local message="$1"
shift
if "$@" >"$WORK/stdout" 2>"$WORK/stderr"; then
echo "错误地成功:$message" >&2
exit 1
fi
}
setup
expect_failure "缺少发布 token" env -u XSTATS_RELEASE_TOKEN \
PATH="$WORK/bin:$PATH" RELEASE_ALL_TEST_LOG="$WORK/log" \
"$WORK/repo/Scripts/release_all.sh"
grep -q 'XSTATS_RELEASE_TOKEN' "$WORK/stderr" \
|| { cat "$WORK/stderr" >&2; exit 1; }
[ ! -s "$WORK/log" ] || { echo "缺少 token 时不应执行任何命令" >&2; exit 1; }
setup
expect_failure "非 main 分支" env XSTATS_RELEASE_TOKEN=test FAKE_BRANCH=feature \
PATH="$WORK/bin:$PATH" RELEASE_ALL_TEST_LOG="$WORK/log" \
"$WORK/repo/Scripts/release_all.sh"
grep -q 'main' "$WORK/stderr" || { cat "$WORK/stderr" >&2; exit 1; }
grep -qx 'git symbolic-ref --quiet --short HEAD' "$WORK/log"
setup
env XSTATS_RELEASE_TOKEN=test PATH="$WORK/bin:$PATH" RELEASE_ALL_TEST_LOG="$WORK/log" \
"$WORK/repo/Scripts/release_all.sh"
cat > "$WORK/expected" <<'LOG'
git symbolic-ref --quiet --short HEAD
git fetch --quiet origin main
git rev-parse HEAD
git rev-parse origin/main
git ls-remote --exit-code --tags origin refs/tags/v0.8.0
python3 Scripts/release_provenance.py prepare
task test
task release
python3 Scripts/release_provenance.py prepare
git add -- project.yml CHANGELOG.md README.md README.en.md README.ja.md README.ko.md Assets/readme/activity.svg Assets/readme/activity.zh.svg
git diff --cached --quiet
git commit -m chore(release): 发布 0.8.0
git push origin main
publish
LOG
diff -u "$WORK/expected" "$WORK/log"
echo "一键发布脚本测试通过"
+11
View File
@@ -63,6 +63,16 @@ tasks:
cmds:
- ./Scripts/release.sh
publish:
desc: 发布已构建且已提交推送的制品
cmds:
- ./Scripts/publish_release.sh
release-all:
desc: 测试、构建、公证、提交推送并完成全部发布
cmds:
- ./Scripts/release_all.sh
stop:
desc: 停止正在运行的 App
cmds:
@@ -74,6 +84,7 @@ tasks:
cmds:
- bash Scripts/version_test.sh
- bash Scripts/arm64_release_test.sh
- bash Scripts/release_all_test.sh
- python3 Scripts/release_provenance_test.py
- python3 Scripts/publish_api_test.py
- task: test-api