diff --git a/.agents/skills/xstats-release/SKILL.md b/.agents/skills/xstats-release/SKILL.md new file mode 100644 index 0000000..b0ad50e --- /dev/null +++ b/.agents/skills/xstats-release/SKILL.md @@ -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. +--- + + + + +# 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. diff --git a/.agents/skills/xstats-release/agents/openai.yaml b/.agents/skills/xstats-release/agents/openai.yaml new file mode 100644 index 0000000..5b9f927 --- /dev/null +++ b/.agents/skills/xstats-release/agents/openai.yaml @@ -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." diff --git a/.agents/skills/xstats-release/references/runbook.md b/.agents/skills/xstats-release/references/runbook.md new file mode 100644 index 0000000..f16a6b5 --- /dev/null +++ b/.agents/skills/xstats-release/references/runbook.md @@ -0,0 +1,155 @@ + + + +# 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2356c64..8ea5291 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6f2ee53..a7c6e91 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/Scripts/release_all.sh b/Scripts/release_all.sh new file mode 100755 index 0000000..b9c27dc --- /dev/null +++ b/Scripts/release_all.sh @@ -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} 完整发布完成" diff --git a/Scripts/release_all_test.sh b/Scripts/release_all_test.sh new file mode 100755 index 0000000..a7bd3cf --- /dev/null +++ b/Scripts/release_all_test.sh @@ -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 "一键发布脚本测试通过" diff --git a/Taskfile.yml b/Taskfile.yml index 3d0c093..d8d3b2b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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