feat: add stable and beta Homebrew channels (#613)

* feat: add stable and beta Homebrew channels

* fix: align beta formula with tap conventions

* fix: use dedicated token for Homebrew PRs

* chore: minimize release token permissions

* docs: record Homebrew token setup

* docs: keep Homebrew automation token long-lived

* fix(verify): assert channel versions and prove homebrew coexistence

The six-channel verifier previously ran `dws version` without comparing
it to the version each channel advertises, so a stale or wrong binary
still reported PASS. It also uninstalled stable before installing beta,
which could not prove the keg-only beta coexists with stable.

- smoke() now takes an expected version and fails the channel on mismatch
- npm/homebrew derive the expected version from the package manager;
  curl/upgrade derive it from the latest GitHub release tag
- homebrew installs keg-only beta while stable stays installed, then
  asserts stable's version, binary SHA and PATH link are unchanged
- cleanup uninstalls both script-installed formulae, still refusing to
  touch a pre-existing user install
- add regression tests for version assertion and coexistence semantics

* style(formula): satisfy brew style for stable and beta formulae

- reword desc so it no longer starts with the formula name
- drop the unnecessary `require "fileutils"` and use the mixed-in cp_r
  instead of the FileUtils. qualifier

* fix(verify): compare channel versions exactly

* fix(homebrew): keep generated formulae style-clean

* fix(homebrew): sync formulae with latest releases
This commit is contained in:
修雨
2026-07-15 10:16:26 +08:00
committed by GitHub
parent 4e59f9aa7a
commit 3e9e76df2c
17 changed files with 1266 additions and 59 deletions
+41
View File
@@ -26,6 +26,19 @@ jobs:
with:
fetch-depth: 0
- name: Check Homebrew PR automation token
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
# Organization policy intentionally prevents the broad, built-in
# GITHUB_TOKEN from creating PRs. Keep Formula automation on a
# repository-scoped token instead of weakening that policy.
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -176,6 +189,34 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
- name: Open stable Homebrew formula PR
# Beta builds must never replace the stable Homebrew formula. Formula
# updates use the normal PR path instead of writing main from a tag job.
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ github.ref_name }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}"
- name: Open beta Homebrew formula PR
# Keep beta in a separately named, keg-only Formula so it cannot replace the
# stable dws link for ordinary Homebrew users.
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ github.ref_name }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}"
- name: Sync release to China OSS mirror
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
+4
View File
@@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
## [1.0.52] - 2026-07-14
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
+63
View File
@@ -0,0 +1,63 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-arm64.tar.gz"
sha256 "7164f2b0389ce0c3bc1d745b5c98082c1ef92c8547c9b123dcb4e83fe172f92e"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-amd64.tar.gz"
sha256 "6ebd48fb96009cf2a81eb0af15216ba050620db55470d5c9937467aa66558879"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-arm64.tar.gz"
sha256 "5f718244665c33a9327130874788d0fad36824ec29eb437ab82aa83e3d5a0579"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-amd64.tar.gz"
sha256 "e79abccc1e093b946be89282bd034ba60ab479cc8ee1a51001eb0d441c66125c"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-skills.zip"
sha256 "64c48271de89a94f9c184a475692e0e2f5e23bc0480c10824f717b21e3a83097"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+61
View File
@@ -0,0 +1,61 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+36
View File
@@ -93,6 +93,30 @@ How to pick:
npm install -g dingtalk-workspace-cli
```
Install the latest beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew** (macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
Install the keg-only Homebrew beta without replacing the stable Formula:
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
@@ -168,6 +192,18 @@ dws upgrade -y # skip confirmation prompt
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
### Six-channel post-release verification
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
<details>
<summary><strong>How it works</strong></summary>
+36
View File
@@ -93,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
npm install -g dingtalk-workspace-cli
```
安装最新 beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew**(macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
安装 Homebrew beta(keg-only,不覆盖稳定版):
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
@@ -165,6 +189,18 @@ dws upgrade -y # 跳过确认直接升级
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
### 六渠道发布后验证
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
<details>
<summary><strong>工作原理</strong></summary>
+63
View File
@@ -0,0 +1,63 @@
class __CLASS_NAME__ < Formula
desc "__DESCRIPTION__"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "__VERSION__"
license "Apache-2.0"
__KEG_ONLY_LINE__
on_macos do
if Hardware::CPU.arm?
url "__DARWIN_ARM64_URL__"
sha256 "__DARWIN_ARM64_SHA256__"
else
url "__DARWIN_AMD64_URL__"
sha256 "__DARWIN_AMD64_SHA256__"
end
end
on_linux do
if Hardware::CPU.arm?
url "__LINUX_ARM64_URL__"
sha256 "__LINUX_ARM64_SHA256__"
else
url "__LINUX_AMD64_URL__"
sha256 "__LINUX_AMD64_SHA256__"
end
end
resource "skills" do
url "__SKILLS_URL__"
sha256 "__SKILLS_SHA256__"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
__CHANNEL_CAVEAT__
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+7 -38
View File
@@ -1,5 +1,5 @@
class __CLASS_NAME__ < Formula
desc "DingTalk Workspace CLI"
desc "Install locally built DingTalk workspace CLI artifacts for verification"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
url "__ARCHIVE_URL__"
sha256 "__ARCHIVE_SHA256__"
@@ -12,8 +12,6 @@ __KEG_ONLY_LINE__
end
def install
require "fileutils"
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
@@ -28,44 +26,15 @@ __KEG_ONLY_LINE__
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
FileUtils.cp_r(Dir["*"], skill_dest)
cp_r(Dir["*"], skill_dest)
end
end
def post_install
require "fileutils"
skill_root = pkgshare/"skills/dws"
entries = Dir["#{skill_root}/*"]
return if entries.empty?
targets = [
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
parent_gate = dest.parent.parent
next if index > 0 && !parent_gate.directory?
FileUtils.rm_rf(dest)
FileUtils.mkdir_p(dest)
FileUtils.cp_r(entries, dest)
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
test do
+18
View File
@@ -62,6 +62,24 @@ make lint
git diff --check
```
## Homebrew Formula PR Automation
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
allow fine-grained personal access tokens to target this repository, so use a
classic personal access token owned by a maintainer or release-bot account with
only the `public_repo` scope. Do not reuse a broad developer token.
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
secret. Replace it immediately if it is exposed, its owner loses repository
access, or the release-bot ownership changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
## Handoff Checklist
Before handoff, include:
+70 -9
View File
@@ -128,6 +128,42 @@ render_homebrew_formula() {
"$ROOT/build/homebrew.rb.tmpl" > "$output_path"
}
render_homebrew_release_formula() {
class_name="$1"
version="$2"
release_url_base="$3"
darwin_amd64_sha="$4"
darwin_arm64_sha="$5"
linux_amd64_sha="$6"
linux_arm64_sha="$7"
skills_sha="$8"
keg_only_line="$9"
channel_caveat="${10}"
output_path="${11}"
description="Automate DingTalk workspace tasks from the terminal"
if [ "$class_name" = "DingtalkWorkspaceCliBeta" ]; then
description="$description (beta channel)"
fi
sed \
-e "s|__CLASS_NAME__|$class_name|g" \
-e "s|__DESCRIPTION__|$description|g" \
-e "s|__VERSION__|$version|g" \
-e "s|__DARWIN_AMD64_URL__|$release_url_base/dws-darwin-amd64.tar.gz|g" \
-e "s|__DARWIN_AMD64_SHA256__|$darwin_amd64_sha|g" \
-e "s|__DARWIN_ARM64_URL__|$release_url_base/dws-darwin-arm64.tar.gz|g" \
-e "s|__DARWIN_ARM64_SHA256__|$darwin_arm64_sha|g" \
-e "s|__LINUX_AMD64_URL__|$release_url_base/dws-linux-amd64.tar.gz|g" \
-e "s|__LINUX_AMD64_SHA256__|$linux_amd64_sha|g" \
-e "s|__LINUX_ARM64_URL__|$release_url_base/dws-linux-arm64.tar.gz|g" \
-e "s|__LINUX_ARM64_SHA256__|$linux_arm64_sha|g" \
-e "s|__SKILLS_URL__|$release_url_base/dws-skills.zip|g" \
-e "s|__SKILLS_SHA256__|$skills_sha|g" \
-e "s|__KEG_ONLY_LINE__|$keg_only_line|g" \
-e "s|__CHANNEL_CAVEAT__|$channel_caveat|g" \
"$ROOT/build/homebrew-release.rb.tmpl" > "$output_path"
}
stage_homebrew_formula() {
version="$1"
host_os="$(detect_os)"
@@ -136,16 +172,24 @@ stage_homebrew_formula() {
formula_dir="$DIST_DIR/homebrew"
archive_path="$DIST_DIR/dws-${host_os}-${host_arch}${archive_ext}"
release_url_base="$(resolve_release_base_url "$version")"
archive_name="$(basename "$archive_path")"
skills_name="$(basename "$DIST_DIR/dws-skills.zip")"
archive_sha="$(sha256_file "$archive_path")"
skills_sha="$(sha256_file "$DIST_DIR/dws-skills.zip")"
darwin_amd64="$DIST_DIR/dws-darwin-amd64.tar.gz"
darwin_arm64="$DIST_DIR/dws-darwin-arm64.tar.gz"
linux_amd64="$DIST_DIR/dws-linux-amd64.tar.gz"
linux_arm64="$DIST_DIR/dws-linux-arm64.tar.gz"
mkdir -p "$formula_dir"
if [ ! -f "$archive_path" ]; then
err "host archive missing for homebrew formula: $archive_path"
fi
for release_archive in "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64"; do
if [ ! -f "$release_archive" ]; then
err "release archive missing for Homebrew formula: $release_archive"
fi
done
render_homebrew_formula \
"DingtalkWorkspaceCliLocal" \
@@ -156,14 +200,31 @@ stage_homebrew_formula() {
' keg_only "Local verification formula to avoid linking conflicts"' \
"$formula_dir/dingtalk-workspace-cli-local.rb"
render_homebrew_formula \
"DingtalkWorkspaceCli" \
"$release_url_base/$archive_name" \
"$release_url_base/$skills_name" \
"$archive_sha" \
formula_class="DingtalkWorkspaceCli"
formula_path="$formula_dir/dingtalk-workspace-cli.rb"
keg_only_line=""
channel_caveat=""
case "$version" in
*-*)
formula_class="DingtalkWorkspaceCliBeta"
formula_path="$formula_dir/dingtalk-workspace-cli-beta.rb"
keg_only_line=' keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"'
channel_caveat=' This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.'
;;
esac
render_homebrew_release_formula \
"$formula_class" \
"$version" \
"$release_url_base" \
"$(sha256_file "$darwin_amd64")" \
"$(sha256_file "$darwin_arm64")" \
"$(sha256_file "$linux_amd64")" \
"$(sha256_file "$linux_arm64")" \
"$skills_sha" \
"" \
"$formula_dir/dingtalk-workspace-cli.rb"
"$keg_only_line" \
"$channel_caveat" \
"$formula_path"
}
# ---------- skills zip ----------
+66 -1
View File
@@ -6,6 +6,11 @@ FORMULA_SOURCE="${DWS_FORMULA_SOURCE:-$ROOT/dist/homebrew/dingtalk-workspace-cli
TAP_REPO_URL="${DWS_TAP_REPO_URL:-}"
TAP_BRANCH="${DWS_TAP_BRANCH:-main}"
TAP_FORMULA_PATH="${DWS_TAP_FORMULA_PATH:-Formula/dingtalk-workspace-cli.rb}"
TAP_GITHUB_TOKEN="${DWS_TAP_GITHUB_TOKEN:-}"
TAP_SSH_KEY="${DWS_TAP_SSH_KEY:-}"
PR_REPOSITORY="${DWS_TAP_PR_REPOSITORY:-}"
PR_BRANCH="${DWS_TAP_PR_BRANCH:-}"
PR_TITLE="${DWS_TAP_PR_TITLE:-chore: update Homebrew formula}"
COMMIT_MESSAGE="${DWS_TAP_COMMIT_MESSAGE:-chore: update dingtalk-workspace-cli formula}"
GIT_NAME="${DWS_GIT_NAME:-DWS Release Bot}"
GIT_EMAIL="${DWS_GIT_EMAIL:-dws-release-bot@example.com}"
@@ -58,15 +63,48 @@ checkout_tap_branch() {
}
need_cmd git
need_cmd ruby
need_env "DWS_TAP_REPO_URL" "$TAP_REPO_URL"
need_file "$FORMULA_SOURCE"
if grep -Eq '__[A-Z0-9_]+__' "$FORMULA_SOURCE"; then
err "formula contains unresolved template placeholders: $FORMULA_SOURCE"
fi
ruby -c "$FORMULA_SOURCE" >/dev/null || err "formula has invalid Ruby syntax: $FORMULA_SOURCE"
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-homebrew-publish-XXXXXX")"
cleanup() {
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT INT TERM
if [ -n "$TAP_SSH_KEY" ]; then
SSH_KEY_PATH="$TMP_ROOT/tap-deploy-key"
printf '%s\n' "$TAP_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
export GIT_SSH_COMMAND="ssh -i $SSH_KEY_PATH -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
export GIT_TERMINAL_PROMPT=0
fi
if [ -n "$TAP_GITHUB_TOKEN" ]; then
ASKPASS="$TMP_ROOT/git-askpass.sh"
printf '%s\n' \
'#!/bin/sh' \
'case "$1" in' \
' *Username*) printf "%s\n" "x-access-token" ;;' \
' *) printf "%s\n" "$DWS_TAP_GITHUB_TOKEN" ;;' \
'esac' > "$ASKPASS"
chmod 700 "$ASKPASS"
export DWS_TAP_GITHUB_TOKEN GIT_ASKPASS="$ASKPASS"
export GIT_TERMINAL_PROMPT=0
fi
if [ -n "$PR_REPOSITORY" ] || [ -n "$PR_BRANCH" ]; then
need_env "DWS_TAP_PR_REPOSITORY" "$PR_REPOSITORY"
need_env "DWS_TAP_PR_BRANCH" "$PR_BRANCH"
need_env "DWS_TAP_GITHUB_TOKEN" "$TAP_GITHUB_TOKEN"
need_cmd gh
fi
TAP_DIR="$TMP_ROOT/tap"
checkout_tap_branch "$TAP_REPO_URL" "$TAP_BRANCH" "$TAP_DIR"
@@ -85,7 +123,34 @@ cp "$FORMULA_SOURCE" "$DEST_PATH"
git config user.email "$GIT_EMAIL"
git add "$TAP_FORMULA_PATH"
git commit -m "$COMMIT_MESSAGE" >/dev/null
if [ -n "$PR_REPOSITORY" ]; then
git push --force-with-lease origin "HEAD:$PR_BRANCH" >/dev/null
pr_url="$(
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr list \
--repo "$PR_REPOSITORY" \
--head "$PR_BRANCH" \
--state open \
--json url \
--jq '.[0].url'
)"
if [ -z "$pr_url" ]; then
pr_url="$(
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr create \
--repo "$PR_REPOSITORY" \
--base "$TAP_BRANCH" \
--head "$PR_BRANCH" \
--title "$PR_TITLE" \
--body "Automated Homebrew Formula update. Merge after required checks pass."
)"
fi
say "Opened Homebrew formula PR: $pr_url"
exit 0
fi
git push origin "HEAD:$TAP_BRANCH" >/dev/null
)
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
if [ -z "$PR_REPOSITORY" ]; then
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
fi
+2 -2
View File
@@ -146,7 +146,7 @@ verify_brew() {
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
brew install -y "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
prefix="$(
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
@@ -154,7 +154,7 @@ verify_brew() {
)"
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
"$prefix/bin/dws" --help >/dev/null
verify_skill_targets "$brew_home"
need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md"
}
need_file "$DIST_DIR/dws-skills.zip"
+297 -9
View File
@@ -114,7 +114,7 @@ func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string {
return append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"DWS_PACKAGE_VERSION=v0.0.0-test",
"DWS_PACKAGE_VERSION=v0.0.0",
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
)
@@ -138,8 +138,25 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
archiveName = "dws-" + hostOS + "-" + hostArch + ".zip"
}
// Seed dist/ with fake goreleaser archives (simulate goreleaser output)
seedDistArtifacts(t, distDir, []string{archiveName})
// Seed every archive referenced by the public multi-platform Homebrew formula.
// The local verification formula still selects the current host archive.
targets := []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
}
foundHost := false
for _, target := range targets {
if target == archiveName {
foundHost = true
break
}
}
if !foundHost {
targets = append(targets, archiveName)
}
seedDistArtifacts(t, distDir, targets)
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3")
@@ -170,7 +187,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
for _, want := range []string{
"class DingtalkWorkspaceCliLocal < Formula",
"resource \"skills\" do",
"DingTalk Workspace CLI",
"Install locally built DingTalk workspace CLI artifacts for verification",
} {
if !strings.Contains(formulaText, want) {
t.Fatalf("formula missing %q:\n%s", want, formulaText)
@@ -185,7 +202,14 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
releaseFormulaText := string(releaseFormulaData)
for _, want := range []string{
"class DingtalkWorkspaceCli < Formula",
"https://downloads.example.com/dws/releases/v1.2.3/" + archiveName,
`desc "Automate DingTalk workspace tasks from the terminal"`,
`version "0.0.0"`,
"on_macos do",
"on_linux do",
"https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-amd64.tar.gz",
"https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-arm64.tar.gz",
"https://downloads.example.com/dws/releases/v1.2.3/dws-linux-amd64.tar.gz",
"https://downloads.example.com/dws/releases/v1.2.3/dws-linux-arm64.tar.gz",
"https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip",
} {
if !strings.Contains(releaseFormulaText, want) {
@@ -221,9 +245,17 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
}
}
for _, target := range expectedPackagedSkillTargets {
if !strings.Contains(releaseFormulaText, target) {
t.Fatalf("release formula missing %q:\n%s", target, releaseFormulaText)
for _, want := range []string{"Agent Skills are bundled", "dws skill setup"} {
if !strings.Contains(releaseFormulaText, want) {
t.Fatalf("release formula missing caveat %q:\n%s", want, releaseFormulaText)
}
}
if strings.Contains(releaseFormulaText, "Dir.home") {
t.Fatalf("release formula must not mutate the user's home directory:\n%s", releaseFormulaText)
}
for _, forbidden := range []string{`require "fileutils"`, "FileUtils.", "__DESCRIPTION__"} {
if strings.Contains(releaseFormulaText, forbidden) {
t.Fatalf("release formula contains forbidden text %q:\n%s", forbidden, releaseFormulaText)
}
}
@@ -237,6 +269,148 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
}
}
func TestCheckedInHomebrewFormulaIsStableAndSideEffectFree(t *testing.T) {
t.Parallel()
formulaPath := filepath.Join("..", "..", "Formula", "dingtalk-workspace-cli.rb")
data, err := os.ReadFile(formulaPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
}
formula := string(data)
versionPrefix := `version "`
versionStart := strings.Index(formula, versionPrefix)
if versionStart == -1 {
t.Fatal("checked-in Homebrew formula has no explicit version")
}
versionStart += len(versionPrefix)
versionEnd := strings.Index(formula[versionStart:], `"`)
if versionEnd == -1 {
t.Fatal("checked-in Homebrew formula has an invalid version declaration")
}
version := formula[versionStart : versionStart+versionEnd]
if strings.Contains(version, "-") {
t.Fatalf("checked-in Homebrew formula must be stable, got version %q", version)
}
releaseBase := "releases/download/v" + version + "/"
for _, required := range []string{
releaseBase + "dws-darwin-amd64.tar.gz",
releaseBase + "dws-darwin-arm64.tar.gz",
releaseBase + "dws-linux-amd64.tar.gz",
releaseBase + "dws-linux-arm64.tar.gz",
releaseBase + "dws-skills.zip",
"dws skill setup",
} {
if !strings.Contains(formula, required) {
t.Errorf("checked-in Homebrew formula is missing %q", required)
}
}
for _, forbidden := range []string{"-beta.", "Dir.home", "def post_install", `require "fileutils"`, "FileUtils."} {
if strings.Contains(formula, forbidden) {
t.Errorf("checked-in Homebrew formula contains forbidden text %q", forbidden)
}
}
}
func TestCheckedInHomebrewBetaFormulaIsSeparateAndKegOnly(t *testing.T) {
t.Parallel()
formulaPath := filepath.Join("..", "..", "Formula", "dingtalk-workspace-cli-beta.rb")
data, err := os.ReadFile(formulaPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
}
formula := string(data)
versionPrefix := `version "`
versionStart := strings.Index(formula, versionPrefix)
if versionStart == -1 {
t.Fatal("checked-in Homebrew beta formula is missing a version declaration")
}
versionStart += len(versionPrefix)
versionEnd := strings.Index(formula[versionStart:], `"`)
if versionEnd == -1 {
t.Fatal("checked-in Homebrew beta formula has an invalid version declaration")
}
version := formula[versionStart : versionStart+versionEnd]
if !strings.Contains(version, "-") {
t.Fatalf("checked-in Homebrew beta formula must be a prerelease, got version %q", version)
}
releaseBase := "releases/download/v" + version + "/"
for _, required := range []string{
"class DingtalkWorkspaceCliBeta < Formula",
`desc "Automate DingTalk workspace tasks from the terminal (beta channel)"`,
`keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"`,
releaseBase + "dws-darwin-amd64.tar.gz",
releaseBase + "dws-darwin-arm64.tar.gz",
releaseBase + "dws-linux-amd64.tar.gz",
releaseBase + "dws-linux-arm64.tar.gz",
releaseBase + "dws-skills.zip",
"This beta is keg-only",
} {
if !strings.Contains(formula, required) {
t.Errorf("checked-in Homebrew beta formula is missing %q", required)
}
}
for _, forbidden := range []string{"Dir.home", "def post_install", `require "fileutils"`, "FileUtils."} {
if strings.Contains(formula, forbidden) {
t.Errorf("checked-in Homebrew beta formula contains forbidden text %q", forbidden)
}
}
}
func TestPostGoreleaserBuildsVersionedBetaFormula(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
if err != nil {
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
}
distDir := filepath.Join(t.TempDir(), "dist")
seedDistArtifacts(t, distDir, []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
})
env := postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3-beta.4")
for i, value := range env {
if strings.HasPrefix(value, "DWS_PACKAGE_VERSION=") {
env[i] = "DWS_PACKAGE_VERSION=v1.2.3-beta.4"
}
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = env
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output)
}
formulaPath := filepath.Join(distDir, "homebrew", "dingtalk-workspace-cli-beta.rb")
data, err := os.ReadFile(formulaPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", formulaPath, err)
}
formula := string(data)
for _, required := range []string{
"class DingtalkWorkspaceCliBeta < Formula",
`desc "Automate DingTalk workspace tasks from the terminal (beta channel)"`,
`version "1.2.3-beta.4"`,
`keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"`,
"This beta is keg-only",
} {
if !strings.Contains(formula, required) {
t.Errorf("generated beta formula is missing %q", required)
}
}
if strings.Contains(formula, "__") {
t.Fatalf("generated beta formula contains an unresolved placeholder:\n%s", formula)
}
for _, forbidden := range []string{`require "fileutils"`, "FileUtils."} {
if strings.Contains(formula, forbidden) {
t.Fatalf("generated beta formula contains forbidden text %q:\n%s", forbidden, formula)
}
}
}
func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
t.Parallel()
@@ -335,7 +509,23 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
if hostOS == "windows" {
archiveName = "dws-" + hostOS + "-" + hostArch + ".zip"
}
seedDistArtifacts(t, distDir, []string{archiveName})
targets := []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
}
foundHost := false
for _, target := range targets {
if target == archiveName {
foundHost = true
break
}
}
if !foundHost {
targets = append(targets, archiveName)
}
seedDistArtifacts(t, distDir, targets)
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v0.0.0")
@@ -598,6 +788,10 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
"Publish verified Draft release",
"Publish stable to npm",
"Publish prerelease to npm beta",
"Open stable Homebrew formula PR",
"Open beta Homebrew formula PR",
"DingTalk-Real-AI/dingtalk-workspace-cli.git",
"secrets.GITHUB_TOKEN",
} {
if !strings.Contains(publishSection, required) {
t.Errorf("post-verification publication stage is missing %q", required)
@@ -605,6 +799,100 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
}
}
func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
if strings.Contains(workflow, "pull-requests: write") {
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
}
for _, required := range []string{
"Check Homebrew PR automation token",
"secrets.HOMEBREW_PR_TOKEN",
"HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases",
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
}
}
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
if start == -1 {
t.Fatal("release workflow is missing the stable Homebrew PR step")
}
end := strings.Index(workflow[start:], "- name: Mirror release to Gitee")
if end == -1 {
t.Fatal("release workflow is missing the post-Homebrew Gitee step")
}
section := workflow[start : start+end]
for _, required := range []string{
"github.repository_owner == 'DingTalk-Real-AI'",
"!contains(github.ref_name, '-')",
"./scripts/release/publish-homebrew-formula.sh",
"secrets.HOMEBREW_PR_TOKEN",
"DWS_TAP_PR_REPOSITORY",
"automation/homebrew-${{ github.ref_name }}",
} {
if !strings.Contains(section, required) {
t.Errorf("Homebrew publication step is missing %q", required)
}
}
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
t.Error("Homebrew Formula PRs must use the dedicated token so their CI is triggered")
}
stableNPM := strings.Index(workflow, "- name: Publish stable to npm")
if stableNPM == -1 || start > stableNPM {
t.Fatal("Homebrew PR creation must run before npm so a failure is safely rerunnable")
}
}
func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
start := strings.Index(workflow, "- name: Open beta Homebrew formula PR")
if start == -1 {
t.Fatal("release workflow is missing the beta Homebrew PR step")
}
end := strings.Index(workflow[start:], "- name: Sync release to China OSS mirror")
if end == -1 {
t.Fatal("release workflow is missing the post-Homebrew OSS step")
}
section := workflow[start : start+end]
for _, required := range []string{
"github.repository_owner == 'DingTalk-Real-AI'",
"contains(github.ref_name, '-')",
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
"Formula/dingtalk-workspace-cli-beta.rb",
"secrets.HOMEBREW_PR_TOKEN",
"automation/homebrew-beta-${{ github.ref_name }}",
} {
if !strings.Contains(section, required) {
t.Errorf("beta Homebrew PR step is missing %q", required)
}
}
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
t.Error("Homebrew beta Formula PRs must use the dedicated token so their CI is triggered")
}
}
func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) {
t.Parallel()
@@ -93,6 +93,83 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
}
}
func TestPublishHomebrewFormulaOpensPRWithoutWritingMain(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "publish-homebrew-formula.sh"))
if err != nil {
t.Fatalf("Abs(publish-homebrew-formula.sh) error = %v", err)
}
root := t.TempDir()
remoteDir := filepath.Join(root, "repo.git")
mustRun(t, root, "git", "init", "--bare", remoteDir)
oldFormula := "class OldFormula < Formula\nend\n"
seedTapRepo(t, remoteDir, "main", oldFormula)
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
newFormula := "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"
mustWriteFile(t, sourceFormula, []byte(newFormula), 0o644)
fakeBin := filepath.Join(root, "bin")
ghLog := filepath.Join(root, "gh.log")
mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/bin/sh
printf '%s\n' "$*" >> "$GH_LOG"
if [ "$1 $2" = "pr create" ]; then
printf '%s\n' 'https://github.example/pr/1'
fi
`), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"GH_LOG="+ghLog,
"DWS_TAP_REPO_URL="+remoteDir,
"DWS_TAP_BRANCH=main",
"DWS_FORMULA_SOURCE="+sourceFormula,
"DWS_TAP_GITHUB_TOKEN=test-token",
"DWS_TAP_PR_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli",
"DWS_TAP_PR_BRANCH=automation/homebrew-v1.2.3",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("publish-homebrew-formula.sh error = %v\noutput:\n%s", err, string(output))
}
if !strings.Contains(string(output), "Opened Homebrew formula PR: https://github.example/pr/1") {
t.Fatalf("publish output missing PR URL:\n%s", string(output))
}
mainClone := filepath.Join(root, "main-check")
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, mainClone)
mainFormula, err := os.ReadFile(filepath.Join(mainClone, "Formula", "dingtalk-workspace-cli.rb"))
if err != nil {
t.Fatalf("ReadFile(main formula) error = %v", err)
}
if string(mainFormula) != oldFormula {
t.Fatalf("publisher wrote main directly: %q", string(mainFormula))
}
prClone := filepath.Join(root, "pr-check")
mustRun(t, root, "git", "clone", "--branch", "automation/homebrew-v1.2.3", remoteDir, prClone)
prFormula, err := os.ReadFile(filepath.Join(prClone, "Formula", "dingtalk-workspace-cli.rb"))
if err != nil {
t.Fatalf("ReadFile(PR formula) error = %v", err)
}
if string(prFormula) != newFormula {
t.Fatalf("PR formula = %q, want %q", string(prFormula), newFormula)
}
ghCalls, err := os.ReadFile(ghLog)
if err != nil {
t.Fatalf("ReadFile(gh log) error = %v", err)
}
for _, want := range []string{"pr list", "pr create"} {
if !strings.Contains(string(ghCalls), want) {
t.Errorf("gh calls missing %q:\n%s", want, ghCalls)
}
}
}
func seedTapRepo(t *testing.T, remoteDir, branch, formulaContent string) {
t.Helper()
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package scripts
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func readVerifier(t *testing.T) string {
t.Helper()
path := filepath.Join("..", "..", "verify", "verify-all-channels.sh")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read verifier: %v", err)
}
return string(data)
}
func TestVerifyAllChannelsScriptContract(t *testing.T) {
script := readVerifier(t)
for _, channel := range []string{
"curl", "powershell", "npm-stable", "npm-beta", "homebrew", "dws-upgrade",
} {
if !strings.Contains(script, channel) {
t.Errorf("verifier does not include channel %q", channel)
}
}
for _, check := range []string{" version", " --help", "npm uninstall", "brew uninstall"} {
if !strings.Contains(script, check) {
t.Errorf("verifier does not include lifecycle check %q", check)
}
}
path := filepath.Join("..", "..", "verify", "verify-all-channels.sh")
cmd := exec.Command("bash", "-n", path)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bash -n failed: %v\n%s", err, output)
}
}
func runVerifierSmoke(t *testing.T, reportedVersion, expectedVersion string) (string, error) {
t.Helper()
script := readVerifier(t)
start := strings.Index(script, "smoke() {")
if start == -1 {
t.Fatal("verifier does not define smoke()")
}
endMarker := "\n}\n\n# Latest stable"
end := strings.Index(script[start:], endMarker)
if end == -1 {
t.Fatal("could not isolate smoke() from verifier")
}
smokeFunction := script[start : start+end+2]
fakeBinary := filepath.Join(t.TempDir(), "dws")
fakeScript := `#!/bin/sh
case "${1:-}" in
version) printf '{"version":"v%s"}\n' "$FAKE_DWS_VERSION" ;;
--help) exit 0 ;;
*) exit 1 ;;
esac
`
if err := os.WriteFile(fakeBinary, []byte(fakeScript), 0o755); err != nil {
t.Fatalf("write fake dws: %v", err)
}
cmd := exec.Command("bash", "-c", smokeFunction+"\nsmoke \"$1\" \"$2\"", "smoke-test", fakeBinary, expectedVersion)
cmd.Env = append(os.Environ(), "FAKE_DWS_VERSION="+reportedVersion)
output, err := cmd.CombinedOutput()
return string(output), err
}
// TestVerifyAssertsExpectedVersion guards the requirement that a wrong or stale
// version fails the channel instead of silently reporting PASS.
func TestVerifyAssertsExpectedVersion(t *testing.T) {
tests := []struct {
name string
reportedVersion string
expectedVersion string
wantErr bool
wantMessage string
}{
{name: "stable exact match", reportedVersion: "1.0.51", expectedVersion: "1.0.51"},
{name: "beta exact match", reportedVersion: "1.0.52-beta.4", expectedVersion: "v1.0.52-beta.4"},
{
name: "stable rejects beta with shared prefix",
reportedVersion: "1.0.51-beta.1",
expectedVersion: "1.0.51",
wantErr: true,
wantMessage: "version mismatch",
},
{
name: "missing expected version fails closed",
reportedVersion: "1.0.51",
wantErr: true,
wantMessage: "expected version is empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, err := runVerifierSmoke(t, tt.reportedVersion, tt.expectedVersion)
if tt.wantErr && err == nil {
t.Fatalf("smoke unexpectedly passed:\n%s", output)
}
if !tt.wantErr && err != nil {
t.Fatalf("smoke failed: %v\n%s", err, output)
}
if tt.wantMessage != "" && !strings.Contains(output, tt.wantMessage) {
t.Fatalf("smoke output missing %q:\n%s", tt.wantMessage, output)
}
})
}
script := readVerifier(t)
// Homebrew and npm must derive the expected version from the package
// manager rather than trusting the binary's self-report unconditionally.
for _, source := range []string{"brew list --versions", "npm view"} {
if !strings.Contains(script, source) {
t.Errorf("verifier missing authoritative version source %q", source)
}
}
}
// TestVerifyHomebrewCoexistence guards the requirement that beta installs
// alongside stable (keg-only) without disturbing the stable channel.
func TestVerifyHomebrewCoexistence(t *testing.T) {
script := readVerifier(t)
stableInstall := `brew install "$TAP/$PACKAGE"`
betaInstall := `brew install "$TAP/$PACKAGE-beta"`
stableIdx := strings.Index(script, stableInstall)
betaIdx := strings.Index(script, betaInstall)
if stableIdx < 0 || betaIdx < 0 {
t.Fatalf("expected both stable and beta installs; stableIdx=%d betaIdx=%d", stableIdx, betaIdx)
}
if betaIdx <= stableIdx {
t.Errorf("beta must be installed after stable to prove coexistence")
}
// Stable must remain installed when beta lands: no uninstall between them.
between := script[stableIdx+len(stableInstall) : betaIdx]
if strings.Contains(between, "brew uninstall") {
t.Errorf("stable is uninstalled before beta install; cannot prove coexistence")
}
for _, marker := range []string{
"stable version changed after beta install",
"stable binary changed after beta install",
"stable link changed after beta install",
} {
if !strings.Contains(script, marker) {
t.Errorf("verifier missing coexistence assertion %q", marker)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# 六渠道发布后验证
该目录用于发版质量保障 SOP 的 **T+1 线上回归**。脚本对每个可在当前主机运行的公开渠道执行:隔离环境清理、安装、**版本断言**(比对 `dws version` 输出与该渠道自身声明的期望版本,不一致即判 `FAIL`)、`dws --help` 冒烟、清理。
期望版本来源:Homebrew 取 `brew list --versions` 记录的 Formula 版本,npm 取 `npm view <pkg>@<tag> version`,curl 与 `dws upgrade` 取 GitHub `releases/latest` 的 tag。断言防止“装了旧版本却报 PASS”。
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
支持通过 `DWS_VERIFY_REPO=owner/repo` 验证 fork。输出状态含义:
- `PASS`:本机真实完成了安装、版本检查和冒烟。
- `FAIL`:公开渠道验证失败。
- `SKIP`:当前操作系统或依赖无法运行该渠道;不能计为通过,需由对应平台补测。
| 渠道 | macOS | Linux | Windows |
|---|---:|---:|---:|
| curl installer | ✅ | ✅ | — |
| PowerShell installer | — | — | ✅ |
| npm stable (`latest`) | ✅ | ✅ | ✅* |
| npm beta (`beta`) | ✅ | ✅ | ✅* |
| Homebrew | ✅ | ✅ | — |
| `dws upgrade` | ✅ | ✅ | ✅* |
`*` 当前总入口是 Bash;Windows 原生渠道由 PowerShell 安装器本身验证。Windows npm/upgrade 需要对应 Windows runner 补测,不能用非 Windows 上的 `pwsh` 结果代替。
Homebrew stable 与 keg-only beta Formula 都和代码位于同一个主仓库。由于这是自定义 remote,安装时必须显式指定仓库 URL。`homebrew` 步骤先装 stable 并记录其版本、二进制 SHA 与 `$(brew --prefix)/bin/dws` 链接指向,然后在 **stable 仍在安装状态下** 装 keg-only beta,再断言 stable 的版本、二进制 SHA 与链接三者均未被 beta 覆盖,且 PATH 上的 `dws` 仍解析到 stable,以此证明两渠道共存。脚本会拒绝在用户已装有任一 Formula 时运行;清理阶段只卸载脚本自己安装的 stable 与 beta 两个 Formula,并按需 untap,不动用户的预装版本。
在合并前用 PR head 的 Formula 验证时,通过 `DWS_VERIFY_HOMEBREW_TAP=<owner/repo>` 与 `DWS_VERIFY_HOMEBREW_REPO_URL=<fork git url>` 指向包含本 PR Formula 的 tap;npm/curl 渠道同理可用 `DWS_VERIFY_NPM_PACKAGE`、`DWS_VERIFY_REPO` 覆盖。
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
# Verify public DWS delivery channels without replacing the caller's dws.
#
# Every channel installs into an isolated location, asserts that the binary
# reports the version the channel advertises, runs a smoke test, then cleans
# up. A wrong or stale version fails the channel instead of reporting PASS.
set -uo pipefail
REPO="${DWS_VERIFY_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
TAP="${DWS_VERIFY_HOMEBREW_TAP:-DingTalk-Real-AI/dingtalk-workspace-cli}"
TAP_REPO_URL="${DWS_VERIFY_HOMEBREW_REPO_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}"
PACKAGE="${DWS_VERIFY_NPM_PACKAGE:-dingtalk-workspace-cli}"
ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-six-channel-XXXXXX")"
RESULTS="$ROOT/results"
mkdir -p "$RESULTS"
cleanup() { rm -rf "$ROOT"; }
trap cleanup EXIT INT TERM
pass() { printf 'PASS\t%s\t%s\n' "$1" "$2" > "$RESULTS/$1"; }
fail() { printf 'FAIL\t%s\t%s\n' "$1" "$2" > "$RESULTS/$1"; }
skip() { printf 'SKIP\t%s\t%s\n' "$1" "$2" > "$RESULTS/$1"; }
run_step() {
name="$1"
shift
printf '\n==> %s\n' "$name"
if "$@"; then
pass "$name" "install, version assertion and smoke test succeeded"
else
status=$?
fail "$name" "command failed with exit code $status"
fi
}
# smoke <binary> <expected_version>
# Runs `dws version --format json` and requires an exact version match. Missing
# package-manager metadata is a channel failure because it cannot prove that the
# installed binary came from the advertised channel.
smoke() {
local binary="$1"
local expected="${2:-}"
local version_output
local reported
test -x "$binary" || { printf 'binary not executable: %s\n' "$binary" >&2; return 1; }
if [[ -z "$expected" ]]; then
printf 'expected version is empty; cannot verify channel provenance\n' >&2
return 1
fi
version_output="$("$binary" version --format json 2>&1)" || {
printf 'dws version failed for %s\n' "$binary" >&2
return 1
}
printf 'version output: %s\n' "$version_output"
reported="$(
printf '%s\n' "$version_output" \
| sed -nE 's/.*"version"[[:space:]]*:[[:space:]]*"v?([^\"]+)".*/\1/p' \
| head -n1
)"
if [[ -z "$reported" ]]; then
printf 'could not parse version from: %s\n' "$version_output" >&2
return 1
fi
expected="${expected#v}"
if [[ "$reported" != "$expected" ]]; then
printf 'version mismatch: expected %s, got %s\n' "$expected" "$reported" >&2
return 1
fi
"$binary" --help >/dev/null
}
# Latest stable release tag drives the curl and dws-upgrade assertions.
resolve_latest_version() {
tag="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \
| grep -m1 '"tag_name"' \
| sed -E 's/.*"tag_name" *: *"([^"]+)".*/\1/')"
printf '%s' "${tag#v}"
}
LATEST_VERSION="$(resolve_latest_version)"
verify_curl() (
set -e
home="$ROOT/curl/home"
bin="$ROOT/curl/bin"
mkdir -p "$home" "$bin"
HOME="$home" DWS_INSTALL_DIR="$bin" DWS_NO_SKILLS=1 \
bash <(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/scripts/install.sh")
smoke "$bin/dws" "$LATEST_VERSION"
rm -f "$bin/dws"
)
verify_powershell() (
set -e
home="$ROOT/powershell/home"
bin="$ROOT/powershell/bin"
mkdir -p "$home" "$bin"
HOME="$home" DWS_INSTALL_DIR="$bin" DWS_NO_SKILLS=1 pwsh -NoLogo -NoProfile -Command \
"Invoke-RestMethod 'https://raw.githubusercontent.com/$REPO/main/scripts/install.ps1' | Invoke-Expression"
smoke "$bin/dws.exe" "$LATEST_VERSION"
rm -f "$bin/dws.exe"
)
verify_npm() (
set -e
tag="$1"
home="$ROOT/npm-$tag/home"
prefix="$ROOT/npm-$tag/prefix"
cache="$ROOT/npm-$tag/cache"
mkdir -p "$home" "$prefix" "$cache"
expected="$(HOME="$home" npm_config_cache="$cache" npm view "$PACKAGE@$tag" version 2>/dev/null | tail -n1)"
HOME="$home" npm_config_prefix="$prefix" npm_config_cache="$cache" \
npm uninstall -g "$PACKAGE" >/dev/null 2>&1 || true
HOME="$home" npm_config_prefix="$prefix" npm_config_cache="$cache" \
npm install -g "$PACKAGE@$tag"
smoke "$prefix/bin/dws" "$expected"
HOME="$home" npm_config_prefix="$prefix" npm_config_cache="$cache" \
npm uninstall -g "$PACKAGE" >/dev/null
)
verify_homebrew() (
set -e
installed_formulae=()
added_tap=0
cleanup_brew() {
for formula in "${installed_formulae[@]:-}"; do
[[ -n "$formula" ]] && brew uninstall "$formula" >/dev/null 2>&1 || true
done
[[ "$added_tap" == "1" ]] && brew untap "$TAP" >/dev/null 2>&1 || true
}
trap cleanup_brew EXIT INT TERM
# Never touch a Homebrew install the caller already owns.
for existing_formula in "$PACKAGE" "$PACKAGE-beta"; do
if brew list --formula "$existing_formula" >/dev/null 2>&1; then
printf 'Refusing to remove the existing Homebrew installation of %s.\n' "$existing_formula" >&2
return 1
fi
done
if ! brew tap | grep -Fx "$TAP" >/dev/null 2>&1; then
brew tap "$TAP" "$TAP_REPO_URL"
added_tap=1
fi
# Install stable and record its state before beta lands.
brew install "$TAP/$PACKAGE"
installed_formulae+=("$PACKAGE")
stable_version="$(brew list --versions "$PACKAGE" | awk '{print $2}')"
stable_bin="$(brew --prefix "$PACKAGE")/bin/dws"
linked_dws="$(brew --prefix)/bin/dws"
smoke "$stable_bin" "$stable_version"
stable_sha_before="$(shasum -a 256 "$stable_bin" | awk '{print $1}')"
stable_link_before="$(readlink "$linked_dws" 2>/dev/null || true)"
# Install keg-only beta alongside the still-present stable formula.
brew install "$TAP/$PACKAGE-beta"
installed_formulae+=("$PACKAGE-beta")
beta_version="$(brew list --versions "$PACKAGE-beta" | awk '{print $2}')"
smoke "$(brew --prefix "$PACKAGE-beta")/bin/dws" "$beta_version"
# Coexistence: the beta install must not disturb stable.
if ! brew list --formula "$PACKAGE" >/dev/null 2>&1; then
printf 'stable formula disappeared after installing beta\n' >&2
return 1
fi
stable_version_after="$(brew list --versions "$PACKAGE" | awk '{print $2}')"
stable_sha_after="$(shasum -a 256 "$stable_bin" | awk '{print $1}')"
stable_link_after="$(readlink "$linked_dws" 2>/dev/null || true)"
if [[ "$stable_version_after" != "$stable_version" ]]; then
printf 'stable version changed after beta install: %s -> %s\n' "$stable_version" "$stable_version_after" >&2
return 1
fi
if [[ "$stable_sha_after" != "$stable_sha_before" ]]; then
printf 'stable binary changed after beta install\n' >&2
return 1
fi
if [[ "$stable_link_after" != "$stable_link_before" ]]; then
printf 'stable link changed after beta install: %s -> %s\n' "$stable_link_before" "$stable_link_after" >&2
return 1
fi
# The linked dws on PATH must still be stable, not the keg-only beta.
smoke "$linked_dws" "$stable_version"
)
verify_upgrade() (
set -e
home="$ROOT/upgrade/home"
bin="$ROOT/upgrade/bin"
mkdir -p "$home" "$bin"
HOME="$home" DWS_INSTALL_DIR="$bin" DWS_NO_SKILLS=1 \
bash <(curl -fsSL "https://raw.githubusercontent.com/$REPO/main/scripts/install.sh")
printf 'before: %s\n' "$($bin/dws version --format json)"
HOME="$home" "$bin/dws" upgrade --force --skip-skills -y
printf 'after: %s\n' "$($bin/dws version --format json)"
smoke "$bin/dws" "$LATEST_VERSION"
rm -f "$bin/dws"
)
run_step curl verify_curl
if [[ "${OS:-}" == "Windows_NT" ]] && command -v pwsh >/dev/null 2>&1; then
run_step powershell verify_powershell
else
skip powershell "requires native Windows and pwsh"
fi
if command -v npm >/dev/null 2>&1; then
run_step npm-stable verify_npm latest
run_step npm-beta verify_npm beta
else
skip npm-stable "npm is not installed"
skip npm-beta "npm is not installed"
fi
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
run_step homebrew verify_homebrew
else
skip homebrew "requires macOS and Homebrew"
fi
run_step dws-upgrade verify_upgrade
printf '\n%-8s %-14s %s\n' STATUS CHANNEL DETAIL
printf '%s\n' '-------- -------------- ----------------------------------------------'
failed=0
for name in curl powershell npm-stable npm-beta homebrew dws-upgrade; do
IFS=$'\t' read -r status channel detail < "$RESULTS/$name"
printf '%-8s %-14s %s\n' "$status" "$channel" "$detail"
[[ "$status" == "FAIL" ]] && failed=1
done
exit "$failed"