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:
@@ -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 时优雅跳过,不影响海外发布。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 覆盖。
|
||||
Executable
+234
@@ -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"
|
||||
Reference in New Issue
Block a user