Compare commits

...
Author SHA1 Message Date
修雨 52d2f03d1b merge main and preserve signed immutable releases 2026-07-13 17:26:20 +08:00
修雨 2fe57aee89 fix(ci): integrate code admission dependencies 2026-07-13 15:47:23 +08:00
修雨 e605e43a71 ci: add code admission gate (#53)
* ci: add code admission gate

* ci: fix fork release baseline

(cherry picked from commit 7ff2f3a5f0435209908822e141a6355fc6fa4aa6)
2026-07-13 15:45:52 +08:00
修雨 82bb8f3026 fix(release): preserve unreleased changelog entries (#55)
(cherry picked from commit e7989c1bb03ec461c638de89337d175f8ef115e4)
2026-07-13 15:34:28 +08:00
修雨 e3cedc8f67 feat(release): add guarded prerelease and stable pipeline (#54)
* feat(release): add guarded prerelease and stable pipeline

* feat(release): add guided dws-release entry

(cherry picked from commit f7fa7b78f325f3574f0487862fc3e65bba5cdc96)
2026-07-13 15:34:21 +08:00
42 changed files with 6402 additions and 633 deletions
+6
View File
@@ -0,0 +1,6 @@
paths:
.github/workflows/release.yml:
ignore:
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
# bundled workflow schema has not caught up with the platform syntax.
- 'unexpected key "queue" for "concurrency" section'
+160 -28
View File
@@ -7,8 +7,7 @@ on:
pull_request:
permissions:
contents: write
pull-requests: write
contents: read
jobs:
lint:
@@ -59,7 +58,17 @@ jobs:
run: make build
- name: Test with Race Detection
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
run: |
go test -v -race -count=1 -timeout=5m \
./cmd/... \
./internal/... \
./pkg/... \
./test/unit/...
go test -v -count=1 -timeout=5m \
./test/cli/...
- name: Test engineering scripts
run: go test -v -count=1 -timeout=5m ./test/scripts/...
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
@@ -136,30 +145,6 @@ jobs:
coverage.txt
coverage.html
- name: Update coverage badge
if: github.ref == 'refs/heads/main'
run: |
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
COLOR="brightgreen"
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
COLOR="yellow"
else
COLOR="red"
fi
mkdir -p .github/badges
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
- name: Commit badge
if: github.ref == 'refs/heads/main'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/badges/coverage.svg || true
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
git push || true
policy:
name: Policy Check
runs-on: ubuntu-latest
@@ -198,11 +183,158 @@ jobs:
- name: Run edition contract tests
run: go test -v -count=1 ./pkg/editiontest/...
code-check:
name: Code Check
needs: [lint, test, test-darwin, test-windows, coverage, policy, edition-tests]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify code checks
env:
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
POLICY_RESULT: ${{ needs.policy.result }}
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
run: |
set -eu
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
"Coverage:$COVERAGE_RESULT" \
"Policy Check:$POLICY_RESULT" \
"Edition Contract Tests:$EDITION_TESTS_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
command-compatibility:
name: Command Compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch official release tags
run: |
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check command compatibility
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$PR_BASE_SHA"
fi
stable_ref="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')"
test -n "$base_ref"
test -n "$stable_ref"
stable_commit="$(git rev-parse "$stable_ref^{commit}")"
./scripts/release/verify-delivered-stable.sh "$stable_ref" "$stable_commit"
./scripts/policy/check-command-compatibility.sh \
--base-ref "$base_ref" \
--stable-ref "$stable_ref"
cli-smoke:
name: CLI Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run CLI smoke tests
run: go test -count=1 -timeout=5m ./test/smoke/...
mock-mcp-smoke:
name: Mock MCP Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run Mock MCP smoke tests
run: go test -count=1 -timeout=5m ./test/mock_mcp/...
ci-gate:
name: CI Gate
needs: [code-check, command-compatibility, cli-smoke, mock-mcp-smoke]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required checks
env:
CODE_CHECK_RESULT: ${{ needs.code-check.result }}
COMMAND_COMPATIBILITY_RESULT: ${{ needs.command-compatibility.result }}
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
run: |
set -eu
failed=0
for check in \
"Code Check:$CODE_CHECK_RESULT" \
"Command Compatibility:$COMMAND_COMPATIBILITY_RESULT" \
"CLI Smoke:$CLI_SMOKE_RESULT" \
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
notify-downstream:
name: Notify Wukong Overlay
needs: [test, policy, edition-tests]
needs: [ci-gate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |
+7 -16
View File
@@ -1,4 +1,5 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
@@ -10,8 +11,6 @@ on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
@@ -23,13 +22,14 @@ concurrency:
jobs:
mirror:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
- name: Checkout main history
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
@@ -41,15 +41,7 @@ jobs:
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
@@ -81,7 +73,6 @@ jobs:
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
-71
View File
@@ -1,71 +0,0 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
File diff suppressed because it is too large Load Diff
@@ -1,50 +0,0 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+2 -7
View File
@@ -1,19 +1,14 @@
# GoReleaser configuration for dws
# Docs: https://goreleaser.com
#
# To release:
# git tag -a v0.1.0 -m "Release v0.1.0"
# git push origin v0.1.0
# To release, use scripts/release/release.sh. It seals main, validates the
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
#
# To test locally (no publish):
# goreleaser release --snapshot --clean
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: dws
+4
View File
@@ -14,6 +14,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
### Changed
- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, and optional Gitee delivery with fail-closed recovery checks.
## [1.0.51] - 2026-07-10
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
+1 -1
View File
@@ -48,7 +48,7 @@ git diff --check
1. Keep implementation and tests in sync.
2. Run `./scripts/dev/ci-local.sh`.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
+39 -7
View File
@@ -1,6 +1,9 @@
GO ?= go
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy edition-test package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -11,8 +14,11 @@ help:
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make policy - Run open-source asset and command-surface checks\n"
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
@printf " make release - Build and publish a release via goreleaser\n"
@printf " make package - Build all release artifacts locally\n"
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
@@ -38,8 +44,8 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
package:
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
publish-homebrew-formula:
@./scripts/release/publish-homebrew-formula.sh
@@ -47,6 +53,32 @@ publish-homebrew-formula:
setup-hooks:
@git config core.hooksPath scripts/hooks 2>/dev/null || true
changelog-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
changelog-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
release-pre:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
release-stable:
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
@args=""; \
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
release:
goreleaser release --clean
@./scripts/release/post-goreleaser.sh
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
@exit 2
+19 -16
View File
@@ -2,6 +2,7 @@ class __CLASS_NAME__ < Formula
desc "DingTalk Workspace CLI"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
url "__ARCHIVE_URL__"
version "__VERSION__"
sha256 "__ARCHIVE_SHA256__"
license "Apache-2.0"
__KEG_ONLY_LINE__
@@ -39,23 +40,25 @@ __KEG_ONLY_LINE__
entries = Dir["#{skill_root}/*"]
return if entries.empty?
skill_home_override = "__SKILL_HOME_OVERRIDE__"
skill_home = skill_home_override.empty? ? Dir.home : skill_home_override
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")),
Pathname.new(File.join(skill_home, ".agents/skills/dws")),
Pathname.new(File.join(skill_home, ".claude/skills/dws")),
Pathname.new(File.join(skill_home, ".cursor/skills/dws")),
Pathname.new(File.join(skill_home, ".qoder/skills/dws")),
Pathname.new(File.join(skill_home, ".qoderwork/skills/dws")),
Pathname.new(File.join(skill_home, ".gemini/skills/dws")),
Pathname.new(File.join(skill_home, ".codex/skills/dws")),
Pathname.new(File.join(skill_home, ".github/skills/dws")),
Pathname.new(File.join(skill_home, ".windsurf/skills/dws")),
Pathname.new(File.join(skill_home, ".augment/skills/dws")),
Pathname.new(File.join(skill_home, ".cline/skills/dws")),
Pathname.new(File.join(skill_home, ".amp/skills/dws")),
Pathname.new(File.join(skill_home, ".kiro/skills/dws")),
Pathname.new(File.join(skill_home, ".trae/skills/dws")),
Pathname.new(File.join(skill_home, ".openclaw/skills/dws")),
Pathname.new(File.join(skill_home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// interface-snapshot is an internal CI helper. It is intentionally a separate
// binary so it can be copied into a temporary worktree and compiled against an
// older revision's real Cobra root.
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
switch args[0] {
case "generate":
if err := runGenerate(args[1:], stdout, stderr); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
return 0
case "compare":
compatible, err := runCompare(args[1:], stdout, stderr)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if !compatible {
return 1
}
return 0
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
return 2
}
}
func runGenerate(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "-", "snapshot output path, or - for stdout")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("generate accepts no positional arguments")
}
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
if err != nil {
return fmt.Errorf("create isolated home: %w", err)
}
defer os.RemoveAll(home)
environment := map[string]string{
"DWS_CONFIG_DIR": home,
"DWS_LANG": "en",
"HOME": home,
"NO_COLOR": "1",
"USERPROFILE": home,
}
type previousEnv struct {
value string
set bool
}
previous := make(map[string]previousEnv, len(environment))
for key, value := range environment {
oldValue, wasSet := os.LookupEnv(key)
previous[key] = previousEnv{value: oldValue, set: wasSet}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
}
defer func() {
for key, old := range previous {
if old.set {
_ = os.Setenv(key, old.value)
} else {
_ = os.Unsetenv(key)
}
}
}()
previousLang := i18n.Lang()
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
file, err := os.Create(filepath.Clean(*output))
if err != nil {
return fmt.Errorf("create snapshot %q: %w", *output, err)
}
writeErr := interfacesnapshot.Write(file, snapshot)
closeErr := file.Close()
if writeErr != nil {
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
}
if closeErr != nil {
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
}
return nil
}
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
flags.SetOutput(stderr)
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
if err := flags.Parse(args); err != nil {
return false, err
}
if flags.NArg() != 0 {
return false, fmt.Errorf("compare accepts no positional arguments")
}
if *currentPath == "" {
return false, fmt.Errorf("compare requires --current")
}
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
current, err := readSnapshot(*currentPath)
if err != nil {
return false, fmt.Errorf("read current snapshot: %w", err)
}
references := make(map[string]interfacesnapshot.Snapshot, 2)
if *basePath != "" {
references["main"], err = readSnapshot(*basePath)
if err != nil {
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
}
}
if *stablePath != "" {
references["stable"], err = readSnapshot(*stablePath)
if err != nil {
return false, fmt.Errorf("read stable snapshot: %w", err)
}
}
report := interfacesnapshot.CompareAll(current, references)
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
return false, fmt.Errorf("write comparison report: %w", err)
}
return report.Compatible, nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
defer file.Close()
return interfacesnapshot.Read(file)
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
func TestRunGenerateCapturesActualRootOffline(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
if err != nil {
t.Fatalf("decode generated snapshot: %v", err)
}
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
for _, command := range snapshot.Commands {
commands[command.Path] = command
}
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
if _, ok := commands[path]; !ok {
t.Errorf("actual root snapshot is missing %q", path)
}
}
for _, path := range []string{"dws completion", "dws help"} {
if _, ok := commands[path]; ok {
t.Errorf("framework-noise path %q leaked into snapshot", path)
}
}
create := commands["dws dev app create"]
if !hasFlag(create.LocalFlags, "name", "string") {
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
}
if !hasFlag(create.InheritedFlags, "profile", "string") {
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
}
}
func TestRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
stable := commandSnapshot("dws", "dws legacy")
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", current)
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
stablePath := writeSnapshot(t, dir, "stable.json", stable)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", mergeBasePath,
"--stable", stablePath,
}, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
}
}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
commands = append(commands, interfacesnapshot.Command{
Path: path,
Aliases: []string{},
LocalFlags: []interfacesnapshot.Flag{},
InheritedFlags: []interfacesnapshot.Flag{},
})
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: commands,
}
}
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
t.Helper()
path := filepath.Join(dir, name)
file, err := os.Create(path)
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if err := interfacesnapshot.Write(file, snapshot); err != nil {
file.Close()
t.Fatalf("write %s: %v", path, err)
}
if err := file.Close(); err != nil {
t.Fatalf("close %s: %v", path, err)
}
return path
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
return true
}
}
return false
}
+113
View File
@@ -0,0 +1,113 @@
# 发布手册(预发 / 正式)
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
## 日常只用一个入口
安装发布 Skill 后直接运行:
```bash
dws-release
```
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
```bash
dws-release config --remote origin
```
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
## 发布模型
```text
main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z-beta.N(预发验证)
→ 只允许补正式 CHANGELOG,源码不得再变化
→ vX.Y.Z(正式发布)
```
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
## 预发发布
运行统一入口:
```bash
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
```bash
dws-release v1.2.3-beta.1 --publish
```
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
## 正式发布
beta 验证通过后,运行正式版入口:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
```bash
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
```
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
## CHANGELOG 契约
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
```markdown
## [1.2.3-beta.1] - 2026-07-11
### Changed
- 本次 beta 验证的用户可见变化。
```
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
## 平台治理前置
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interfacesnapshot
import (
"fmt"
"reflect"
"sort"
"strings"
)
// Report combines comparisons against independently supplied compatibility
// references, normally the PR merge-base and the latest stable GA release.
type Report struct {
Compatible bool `json:"compatible"`
Comparisons []Comparison `json:"comparisons"`
}
// Comparison is the result for one reference snapshot.
type Comparison struct {
Reference string `json:"reference"`
Compatible bool `json:"compatible"`
Blocking []Change `json:"blocking"`
Additions []Change `json:"additions"`
}
// Change describes one compatibility decision. Before and After are concise,
// human-readable values intended for CI annotations.
type Change struct {
Kind string `json:"kind"`
Path string `json:"path"`
Flag string `json:"flag,omitempty"`
Before string `json:"before,omitempty"`
After string `json:"after,omitempty"`
}
// CompareAll compares current against every named reference in one pass.
// Additions are reported but remain compatible.
func CompareAll(current Snapshot, references map[string]Snapshot) Report {
labels := make([]string, 0, len(references))
for label := range references {
labels = append(labels, label)
}
sort.Strings(labels)
report := Report{Compatible: true, Comparisons: []Comparison{}}
for _, label := range labels {
comparison := Compare(current, references[label], label)
report.Comparisons = append(report.Comparisons, comparison)
if !comparison.Compatible {
report.Compatible = false
}
}
return report
}
// Compare enforces the deliberately small admission policy:
// - every previously accepted command path must still resolve to a runnable,
// visible-compatible target (a rename may preserve the old path as an
// alias),
// - flags accepted at each command path may not disappear, change type, or
// become required; an existing path may not gain a new required flag,
// - new commands and flags are allowed.
//
// Comparing the effective local + inherited set is intentional. It catches a
// persistent flag whose scope is accidentally narrowed to its declaring
// command, while allowing a local flag to move to an ancestor without breaking
// the old invocation path.
func Compare(current, baseline Snapshot, reference string) Comparison {
result := Comparison{
Reference: reference,
Compatible: true,
Blocking: []Change{},
Additions: []Change{},
}
if !reflect.DeepEqual(current.Rules, baseline.Rules) {
result.Blocking = append(result.Blocking, Change{
Kind: "snapshot_rules_changed",
Path: "dws",
Before: fmt.Sprintf("%v", baseline.Rules),
After: fmt.Sprintf("%v", current.Rules),
})
}
result.Blocking = append(result.Blocking, aliasCollisionChanges(current)...)
currentCommands := commandIndex(current)
baselineCommands := commandIndex(baseline)
currentAccepted := acceptedPathIndex(current)
baselineAccepted := acceptedPathIndex(baseline)
removedPaths := make([]string, 0)
for path := range baselineAccepted {
if _, ok := currentAccepted[path]; !ok {
removedPaths = append(removedPaths, path)
}
}
for _, path := range minimalPaths(removedPaths) {
kind := "command_alias_removed"
if _, canonical := baselineCommands[path]; canonical {
kind = "command_removed"
}
result.Blocking = append(result.Blocking, Change{Kind: kind, Path: path})
}
mappedCurrent := make(map[string]bool)
for _, acceptedPath := range sortedAcceptedPaths(baselineAccepted) {
oldPath := baselineAccepted[acceptedPath]
oldCommand := baselineCommands[oldPath]
newPath, ok := currentAccepted[acceptedPath]
if !ok {
continue
}
newCommand, ok := currentCommands[newPath]
if !ok {
continue
}
mappedCurrent[newPath] = true
compareCommandContract(&result, acceptedPath, oldCommand, newCommand)
compareEffectiveFlags(&result, acceptedPath, oldCommand, newCommand)
}
for _, path := range sortedCommandPaths(current.Commands) {
if mappedCurrent[path] {
continue
}
if _, existed := baselineCommands[path]; !existed {
result.Additions = append(result.Additions, Change{Kind: "command_added", Path: path})
}
}
sortChanges(result.Blocking)
sortChanges(result.Additions)
result.Blocking = dedupeChanges(result.Blocking)
result.Additions = dedupeChanges(result.Additions)
result.Compatible = len(result.Blocking) == 0
return result
}
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
if oldCommand.Runnable && !newCommand.Runnable {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_non_runnable",
Path: acceptedPath,
Before: "runnable",
After: "non-runnable",
})
}
if !oldCommand.Hidden && newCommand.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_hidden",
Path: acceptedPath,
Before: "visible",
After: "hidden",
})
}
}
func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
oldFlags := effectiveFlagIndex(oldCommand)
newFlags := effectiveFlagIndex(newCommand)
for _, name := range sortedFlagNames(oldFlags) {
oldFlag := oldFlags[name]
newFlag, ok := newFlags[name]
if !ok {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_removed",
Path: acceptedPath,
Flag: name,
})
continue
}
if oldFlag.Type != newFlag.Type {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_type_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Type,
After: newFlag.Type,
})
}
if !oldFlag.Required && newFlag.Required {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_required",
Path: acceptedPath,
Flag: name,
Before: "optional",
After: "required",
})
}
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_shorthand_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.Shorthand,
After: newFlag.Shorthand,
})
}
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_no_opt_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.NoOpt,
After: newFlag.NoOpt,
})
}
if !oldFlag.Hidden && newFlag.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_hidden",
Path: acceptedPath,
Flag: name,
Before: "visible",
After: "hidden",
})
}
}
for _, name := range sortedFlagNames(newFlags) {
newFlag := newFlags[name]
if _, existed := oldFlags[name]; existed {
continue
}
if newFlag.Required {
result.Blocking = append(result.Blocking, Change{
Kind: "required_flag_added",
Path: acceptedPath,
Flag: name,
Before: "absent",
After: "required",
})
}
}
// Report optional additions once where they are declared. Required
// additions were handled against the effective set above because they can
// break every descendant invocation.
newLocalFlags := flagIndex(newCommand.LocalFlags)
for _, name := range sortedFlagNames(newLocalFlags) {
newFlag := newLocalFlags[name]
if _, existed := oldFlags[name]; existed || newFlag.Required {
continue
}
result.Additions = append(result.Additions, Change{Kind: "flag_added", Path: newCommand.Path, Flag: name})
}
}
func commandIndex(snapshot Snapshot) map[string]Command {
out := make(map[string]Command, len(snapshot.Commands))
for _, command := range snapshot.Commands {
out[command.Path] = command
}
return out
}
func aliasCollisionChanges(snapshot Snapshot) []Change {
acceptedSiblings := make(map[string]string)
changes := []Change{}
for _, command := range snapshot.Commands {
parent, name := splitParent(command.Path)
for _, acceptedName := range append([]string{name}, command.Aliases...) {
acceptedName = strings.TrimSpace(acceptedName)
if acceptedName == "" {
continue
}
key := parent + "\x00" + acceptedName
if previous, exists := acceptedSiblings[key]; exists && previous != command.Path {
changes = append(changes, Change{
Kind: "command_alias_collision",
Path: strings.TrimSpace(parent + " " + acceptedName),
Before: previous,
After: command.Path,
})
continue
}
acceptedSiblings[key] = command.Path
}
}
return changes
}
// acceptedPathIndex expands aliases at every path segment. For example, if
// "dws chat" has alias "im", then "dws im message send" remains accepted for
// every descendant even though descendants only store their canonical paths.
func acceptedPathIndex(snapshot Snapshot) map[string]string {
commands := append([]Command(nil), snapshot.Commands...)
sort.Slice(commands, func(i, j int) bool {
leftDepth, rightDepth := pathDepth(commands[i].Path), pathDepth(commands[j].Path)
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return commands[i].Path < commands[j].Path
})
variants := make(map[string][]string, len(commands))
accepted := make(map[string]string)
// Canonical paths always win over an alias collision.
for _, command := range commands {
accepted[command.Path] = command.Path
}
for _, command := range commands {
parent, name := splitParent(command.Path)
names := compactSorted(append([]string{name}, command.Aliases...))
parentVariants := variants[parent]
if parent == "" {
parentVariants = []string{""}
} else if len(parentVariants) == 0 {
parentVariants = []string{parent}
}
commandVariants := make([]string, 0, len(parentVariants)*len(names))
for _, parentVariant := range parentVariants {
for _, candidateName := range names {
path := strings.TrimSpace(parentVariant + " " + candidateName)
commandVariants = append(commandVariants, path)
if _, canonical := accepted[path]; !canonical {
accepted[path] = command.Path
}
}
}
variants[command.Path] = compactSorted(commandVariants)
}
return accepted
}
func flagIndex(flags []Flag) map[string]Flag {
out := make(map[string]Flag, len(flags))
for _, flag := range flags {
out[flag.Name] = flag
}
return out
}
func effectiveFlagIndex(command Command) map[string]Flag {
out := flagIndex(command.InheritedFlags)
for _, flag := range command.LocalFlags {
// A local flag is the callable definition when it shadows an inherited
// flag with the same name.
out[flag.Name] = flag
}
return out
}
func sortedCommandPaths(commands []Command) []string {
paths := make([]string, 0, len(commands))
for _, command := range commands {
paths = append(paths, command.Path)
}
sort.Strings(paths)
return paths
}
func sortedAcceptedPaths(accepted map[string]string) []string {
paths := make([]string, 0, len(accepted))
for path := range accepted {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func sortedFlagNames(flags map[string]Flag) []string {
names := make([]string, 0, len(flags))
for name := range flags {
names = append(names, name)
}
sort.Strings(names)
return names
}
func minimalPaths(paths []string) []string {
sort.Slice(paths, func(i, j int) bool {
leftDepth, rightDepth := pathDepth(paths[i]), pathDepth(paths[j])
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
return paths[i] < paths[j]
})
minimal := make([]string, 0, len(paths))
for _, path := range paths {
covered := false
for _, parent := range minimal {
if strings.HasPrefix(path, parent+" ") {
covered = true
break
}
}
if !covered {
minimal = append(minimal, path)
}
}
return minimal
}
func splitParent(path string) (string, string) {
index := strings.LastIndexByte(path, ' ')
if index < 0 {
return "", path
}
return path[:index], path[index+1:]
}
func pathDepth(path string) int {
return len(strings.Fields(path))
}
func sortChanges(changes []Change) {
sort.Slice(changes, func(i, j int) bool {
left := changes[i].Path + "\x00" + changes[i].Flag + "\x00" + changes[i].Kind
right := changes[j].Path + "\x00" + changes[j].Flag + "\x00" + changes[j].Kind
return left < right
})
}
func dedupeChanges(changes []Change) []Change {
if len(changes) < 2 {
return changes
}
out := changes[:1]
for _, change := range changes[1:] {
if change == out[len(out)-1] {
continue
}
out = append(out, change)
}
return out
}
+367
View File
@@ -0,0 +1,367 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interfacesnapshot
import "testing"
func TestCompareAdmissionPolicy(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
tests := []struct {
name string
current Snapshot
compatible bool
kind string
}{
{
name: "command addition allowed",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
testCommand("dws status"),
),
compatible: true,
},
{
name: "flag addition allowed",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search",
testFlag("limit", "int", false),
testFlag("query", "string", false),
),
),
compatible: true,
},
{
name: "required flag addition blocked on existing command",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search",
testFlag("query", "string", false),
testFlag("tenant", "string", true),
),
),
kind: "required_flag_added",
},
{
name: "command deletion blocked",
current: testSnapshot(
testCommand("dws"),
),
kind: "command_removed",
},
{
name: "rename without alias blocked",
current: testSnapshot(
testCommand("dws"),
testCommand("dws find", testFlag("query", "string", false)),
),
kind: "command_removed",
},
{
name: "flag deletion blocked",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search"),
),
kind: "flag_removed",
},
{
name: "optional becoming required blocked",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", true)),
),
kind: "flag_became_required",
},
{
name: "flag type change blocked",
current: testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "stringSlice", false)),
),
kind: "flag_type_changed",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
comparison := Compare(test.current, base, "base")
if comparison.Compatible != test.compatible {
t.Fatalf("Compatible = %v, want %v; blocking=%#v", comparison.Compatible, test.compatible, comparison.Blocking)
}
if test.kind == "" {
return
}
if !hasChangeKind(comparison.Blocking, test.kind) {
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
}
})
}
}
func TestCompareAllowsRenameWhenOldPathIsAlias(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws find", []string{"search"}, testFlag("query", "string", false)),
)
comparison := Compare(current, base, "base")
if !comparison.Compatible {
t.Fatalf("rename with compatibility alias was blocked: %#v", comparison.Blocking)
}
}
func TestCompareBlocksRemovedAlias(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search"),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_removed") {
t.Fatalf("removed alias was not blocked: %#v", comparison)
}
}
func TestCompareBlocksAliasRetargetedToIncompatibleCommand(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws search", testFlag("query", "string", false)),
testCommand("dws find"),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws find", "query") {
t.Fatalf("alias retarget was not checked against its old contract: %#v", comparison)
}
}
func TestCompareBlocksSnapshotRuleChanges(t *testing.T) {
base := testSnapshot(testCommand("dws"))
current := testSnapshot(testCommand("dws"))
current.Rules.ExcludedFlags = append(current.Rules.ExcludedFlags, "legacy")
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "snapshot_rules_changed") {
t.Fatalf("snapshot rule change was not blocked: %#v", comparison)
}
}
func TestCompareBlocksCallableMetadataRegressions(t *testing.T) {
baseFlag := testFlag("format", "string", false)
baseFlag.Shorthand = "f"
baseFlag.NoOpt = "json"
base := testSnapshot(
testCommand("dws"),
testCommand("dws export", baseFlag),
)
tests := []struct {
name string
command Command
kind string
}{
{
name: "command became non-runnable",
command: func() Command {
command := testCommand("dws export", baseFlag)
command.Runnable = false
return command
}(),
kind: "command_became_non_runnable",
},
{
name: "command became hidden",
command: func() Command {
command := testCommand("dws export", baseFlag)
command.Hidden = true
return command
}(),
kind: "command_became_hidden",
},
{
name: "flag shorthand removed",
command: func() Command {
flag := baseFlag
flag.Shorthand = ""
return testCommand("dws export", flag)
}(),
kind: "flag_shorthand_changed",
},
{
name: "flag no-opt behavior removed",
command: func() Command {
flag := baseFlag
flag.NoOpt = ""
return testCommand("dws export", flag)
}(),
kind: "flag_no_opt_changed",
},
{
name: "flag became hidden",
command: func() Command {
flag := baseFlag
flag.Hidden = true
return testCommand("dws export", flag)
}(),
kind: "flag_became_hidden",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := testSnapshot(testCommand("dws"), test.command)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasChangeKind(comparison.Blocking, test.kind) {
t.Fatalf("metadata regression %q was not blocked: %#v", test.kind, comparison)
}
})
}
}
func TestCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
base := testSnapshot(
testCommand("dws", testFlag("profile", "string", false)),
testCommandWithFlagScopes(
"dws search",
[]Flag{testFlag("query", "string", false)},
[]Flag{testFlag("profile", "string", false)},
),
)
t.Run("persistent flag scope narrowing is blocked", func(t *testing.T) {
current := testSnapshot(
testCommand("dws", testFlag("profile", "string", false)),
testCommand("dws search", testFlag("query", "string", false)),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws search", "profile") {
t.Fatalf("lost inherited flag was not blocked: %#v", comparison)
}
})
t.Run("local flag moved to inherited remains compatible", func(t *testing.T) {
current := testSnapshot(
testCommand("dws",
testFlag("profile", "string", false),
testFlag("query", "string", false),
),
testCommandWithFlagScopes(
"dws search",
nil,
[]Flag{
testFlag("profile", "string", false),
testFlag("query", "string", false),
},
),
)
comparison := Compare(current, base, "base")
if !comparison.Compatible {
t.Fatalf("flag moved to an inherited scope was blocked: %#v", comparison.Blocking)
}
})
}
func TestCompareAllRequiresBothReferencesToPass(t *testing.T) {
current := testSnapshot(testCommand("dws"), testCommand("dws status"))
mergeBase := testSnapshot(testCommand("dws"))
stable := testSnapshot(testCommand("dws"), testCommand("dws legacy"))
report := CompareAll(current, map[string]Snapshot{
"stable": stable,
"merge-base": mergeBase,
})
if report.Compatible {
t.Fatal("aggregate report passed even though stable comparison removed a command")
}
if len(report.Comparisons) != 2 || report.Comparisons[0].Reference != "merge-base" || report.Comparisons[1].Reference != "stable" {
t.Fatalf("comparisons are not deterministic: %#v", report.Comparisons)
}
if !report.Comparisons[0].Compatible || report.Comparisons[1].Compatible {
t.Fatalf("unexpected per-reference results: %#v", report.Comparisons)
}
}
func testSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
Rules: Rules{
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func testCommand(path string, flags ...Flag) Command {
return testCommandWithAliases(path, nil, flags...)
}
func testCommandWithAliases(path string, aliases []string, flags ...Flag) Command {
return Command{
Path: path,
Runnable: true,
Aliases: aliases,
LocalFlags: flags,
InheritedFlags: []Flag{},
}
}
func testCommandWithFlagScopes(path string, local, inherited []Flag) Command {
return Command{
Path: path,
Aliases: []string{},
LocalFlags: local,
InheritedFlags: inherited,
}
}
func testFlag(name, flagType string, required bool) Flag {
return Flag{Name: name, Type: flagType, Required: required}
}
func hasChangeKind(changes []Change, kind string) bool {
for _, change := range changes {
if change.Kind == kind {
return true
}
}
return false
}
func hasFlagChange(changes []Change, kind, path, flag string) bool {
for _, change := range changes {
if change.Kind == kind && change.Path == path && change.Flag == flag {
return true
}
}
return false
}
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package interfacesnapshot captures and compares the public Cobra command
// surface without executing commands or contacting runtime services.
package interfacesnapshot
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 1
var (
// Framework-owned command subtrees are deliberately excluded. They are
// generated by Cobra or shell-completion plumbing rather than DWS product
// code, so changes to them are not command-surface compatibility changes.
excludedCommandSubtrees = []string{
"dws __complete",
"dws __completeNoDesc",
"dws completion",
"dws help",
}
// Cobra installs --help lazily on every command. Keeping it would make the
// snapshot depend on whether help happened to be rendered before capture.
excludedFlags = map[string]bool{"help": true}
)
// Rules records the noise filtering contract in every snapshot. A future rule
// change is therefore visible instead of silently changing comparison scope.
type Rules struct {
ExcludedCommandSubtrees []string `json:"excluded_command_subtrees"`
ExcludedFlags []string `json:"excluded_flags"`
}
// Snapshot is a deterministic representation of a Cobra command tree.
type Snapshot struct {
SchemaVersion int `json:"schema_version"`
Rules Rules `json:"rules"`
Commands []Command `json:"commands"`
}
// Command contains compatibility-relevant command metadata. Path always
// includes the root command name (for example, "dws chat message send").
type Command struct {
Path string `json:"path"`
Runnable bool `json:"runnable"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
Aliases []string `json:"aliases"`
LocalFlags []Flag `json:"local_flags"`
InheritedFlags []Flag `json:"inherited_flags"`
}
// Flag contains the stable pflag contract visible at a command node.
type Flag struct {
Name string `json:"name"`
Shorthand string `json:"shorthand,omitempty"`
Type string `json:"type"`
Default string `json:"default"`
NoOpt string `json:"no_opt,omitempty"`
Required bool `json:"required"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
}
// Capture walks root without rendering help or executing any command. Both
// hidden compatibility commands and hidden flags are retained unless they are
// covered by the explicit framework-noise rules above.
func Capture(root *cobra.Command) Snapshot {
snapshot := Snapshot{
SchemaVersion: SchemaVersion,
Rules: Rules{
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
ExcludedFlags: []string{"help"},
},
Commands: []Command{},
}
if root == nil {
return snapshot
}
// --version is also installed lazily by Cobra, but unlike --help it is a
// real root CLI contract and should be captured.
root.InitDefaultVersionFlag()
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
path := normalizePath(cmd.CommandPath())
if commandExcluded(path) {
return
}
aliases := append([]string{}, cmd.Aliases...)
for i := range aliases {
aliases[i] = strings.TrimSpace(aliases[i])
}
aliases = compactSorted(aliases)
snapshot.Commands = append(snapshot.Commands, Command{
Path: path,
Runnable: cmd.Runnable(),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
Aliases: aliases,
LocalFlags: captureFlags(cmd.LocalFlags()),
InheritedFlags: captureFlags(cmd.InheritedFlags()),
})
children := append([]*cobra.Command(nil), cmd.Commands()...)
sort.Slice(children, func(i, j int) bool {
return children[i].Name() < children[j].Name()
})
for _, child := range children {
walk(child)
}
}
walk(root)
sort.Slice(snapshot.Commands, func(i, j int) bool {
return snapshot.Commands[i].Path < snapshot.Commands[j].Path
})
return snapshot
}
// Write emits canonical, indented JSON with a trailing newline.
func Write(w io.Writer, snapshot Snapshot) error {
if err := snapshot.Validate(); err != nil {
return err
}
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
return encoder.Encode(snapshot)
}
// Read decodes and validates a snapshot. Unknown fields are rejected so a
// comparison never silently ignores a newer contract it does not understand.
func Read(r io.Reader) (Snapshot, error) {
var snapshot Snapshot
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return Snapshot{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return Snapshot{}, fmt.Errorf("interface snapshot contains multiple JSON values")
}
return Snapshot{}, fmt.Errorf("read trailing interface snapshot data: %w", err)
}
if err := snapshot.Validate(); err != nil {
return Snapshot{}, err
}
return snapshot, nil
}
// Validate checks the invariants needed by the comparison algorithm.
func (s Snapshot) Validate() error {
if s.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported interface snapshot schema_version %d (want %d)", s.SchemaVersion, SchemaVersion)
}
seenCommands := make(map[string]bool, len(s.Commands))
for _, command := range s.Commands {
if command.Path == "" {
return fmt.Errorf("interface snapshot contains an empty command path")
}
if seenCommands[command.Path] {
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
}
seenCommands[command.Path] = true
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
return err
}
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
return err
}
}
return nil
}
func captureFlags(set *pflag.FlagSet) []Flag {
flags := []Flag{}
if set == nil {
return flags
}
set.VisitAll(func(flag *pflag.Flag) {
if flag == nil || excludedFlags[flag.Name] {
return
}
flags = append(flags, Flag{
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
})
})
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
return flags
}
func isRequired(flag *pflag.Flag) bool {
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
if value == "true" {
return true
}
}
return false
}
func commandExcluded(path string) bool {
for _, prefix := range excludedCommandSubtrees {
if path == prefix || strings.HasPrefix(path, prefix+" ") {
return true
}
}
return false
}
func normalizePath(path string) string {
return strings.Join(strings.Fields(path), " ")
}
func compactSorted(values []string) []string {
sort.Strings(values)
out := values[:0]
for _, value := range values {
if value == "" || (len(out) > 0 && out[len(out)-1] == value) {
continue
}
out = append(out, value)
}
return out
}
func validateFlags(path, scope string, flags []Flag) error {
seen := make(map[string]bool, len(flags))
for _, flag := range flags {
if flag.Name == "" {
return fmt.Errorf("command %q contains an empty %s flag name", path, scope)
}
if seen[flag.Name] {
return fmt.Errorf("command %q contains duplicate %s flag %q", path, scope, flag.Name)
}
seen[flag.Name] = true
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package interfacesnapshot
import (
"bytes"
"reflect"
"testing"
"github.com/spf13/cobra"
)
func TestCaptureUsesStableNoiseRulesAndFlagScopes(t *testing.T) {
root := &cobra.Command{Use: "dws", Version: "test"}
root.PersistentFlags().String("profile", "", "profile")
service := &cobra.Command{
Use: "service",
Aliases: []string{"svc", "api", "svc"},
Hidden: true,
Deprecated: "use replacement",
}
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("name", "", "name")
leaf.Flags().Bool("force", false, "force")
if err := leaf.MarkFlagRequired("name"); err != nil {
t.Fatalf("MarkFlagRequired: %v", err)
}
leaf.InitDefaultHelpFlag()
service.AddCommand(leaf)
help := &cobra.Command{Use: "help"}
completion := &cobra.Command{Use: "completion"}
completion.AddCommand(&cobra.Command{Use: "zsh"})
root.AddCommand(service, help, completion)
snapshot := Capture(root)
wantPaths := []string{"dws", "dws service", "dws service run"}
if got := sortedCommandPaths(snapshot.Commands); !reflect.DeepEqual(got, wantPaths) {
t.Fatalf("paths = %v, want %v", got, wantPaths)
}
serviceSnapshot := commandIndex(snapshot)["dws service"]
if !serviceSnapshot.Hidden || serviceSnapshot.Deprecated != "use replacement" {
t.Fatalf("service metadata = %#v", serviceSnapshot)
}
if want := []string{"api", "svc"}; !reflect.DeepEqual(serviceSnapshot.Aliases, want) {
t.Fatalf("aliases = %v, want %v", serviceSnapshot.Aliases, want)
}
leafSnapshot := commandIndex(snapshot)["dws service run"]
if !leafSnapshot.Runnable {
t.Fatal("runnable leaf was not recorded as runnable")
}
local := flagIndex(leafSnapshot.LocalFlags)
if len(local) != 2 {
t.Fatalf("local flags = %#v, want two business flags and no auto help flag", leafSnapshot.LocalFlags)
}
if local["name"].Type != "string" || !local["name"].Required {
t.Fatalf("name flag = %#v, want required string", local["name"])
}
if local["force"].Type != "bool" || local["force"].Required {
t.Fatalf("force flag = %#v, want optional bool", local["force"])
}
inherited := flagIndex(leafSnapshot.InheritedFlags)
if inherited["profile"].Type != "string" {
t.Fatalf("inherited flags = %#v, want root --profile", leafSnapshot.InheritedFlags)
}
if _, exists := inherited["help"]; exists {
t.Fatal("auto --help leaked into inherited flags")
}
var first, second bytes.Buffer
if err := Write(&first, snapshot); err != nil {
t.Fatalf("first Write: %v", err)
}
if err := Write(&second, Capture(root)); err != nil {
t.Fatalf("second Write: %v", err)
}
if !bytes.Equal(first.Bytes(), second.Bytes()) {
t.Fatal("capturing the same Cobra root twice produced different JSON")
}
}
func TestReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 1,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
}`)
if _, err := Read(input); err == nil {
t.Fatal("Read accepted an unknown field")
}
}
func TestCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
base := testSnapshot(testCommand("dws"))
current := testSnapshot(
testCommand("dws"),
testCommandWithAliases("dws search", []string{"find"}),
testCommand("dws find"),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_collision") {
t.Fatalf("candidate sibling alias collision was not blocked: %#v", comparison)
}
}
+6 -4
View File
@@ -6,13 +6,15 @@ These repo-local entrypoints are the supported shell entrypoints for building, t
- `make test`: run `go test ./...`
- `make lint`: run formatting checks and required `golangci-lint`
- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/`
- `make package`: build all release artifacts locally via `goreleaser --snapshot`
- `make release`: build and publish a release via `goreleaser`
- `make package`: build all release artifacts locally
- `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag)
- `make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N`: validate a stable promotion (`PUBLISH=1` pushes its tag)
Script groups:
- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-open-source-assets.sh`
- Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-open-source-assets.sh`
- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md)
- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
set -eu
# Compare the current Cobra command surface with two local Git revisions:
# the PR merge-base/main reference and the latest stable GA tag. The caller must
# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag
# governance treats that tag as proof of a successfully published GA release;
# release automation must not leave a GA tag behind when publication fails.
#
# The script never fetches refs or snapshots from the network. With checkout
# fetch-depth=0, both references are materialized in temporary worktrees. A
# reference uses its own snapshot helper when available; older revisions are
# bootstrapped with the candidate helper.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASE_REF=""
STABLE_REF=""
usage() {
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref>" >&2
}
while [ "$#" -gt 0 ]; do
case "$1" in
--base-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage
exit 2
;;
esac
done
[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; }
cd "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || {
printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2
exit 2
}
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
ensure_snapshot_helper() {
worktree="$1"
# Once a baseline contains the helper, use that revision's own capture
# rules. This makes a rule/filter change in the candidate visible as a
# snapshot_rules_changed failure instead of applying the new exclusion to
# both sides and accidentally hiding a removed command. Older revisions
# created before this helper existed are bootstrapped with the candidate
# implementation.
if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then
return
fi
if [ -e "$worktree/cmd/interface-snapshot" ] || \
[ -e "$worktree/internal/interfacesnapshot" ]; then
printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2
exit 2
fi
mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot"
cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/"
cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/"
}
generate_ref_snapshot() {
ref="$1"
worktree="$2"
output="$3"
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
ensure_snapshot_helper "$worktree"
(
cd "$worktree"
go run ./cmd/interface-snapshot generate --output "$output"
)
}
CANDIDATE="$TMP_ROOT/candidate.json"
BASELINE="$TMP_ROOT/merge-base.json"
STABLE="$TMP_ROOT/stable.json"
go run ./cmd/interface-snapshot generate --output "$CANDIDATE"
generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE"
generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE"
+5 -48
View File
@@ -1,50 +1,7 @@
#!/usr/bin/env bash
# Build release artifacts locally in the current runner and publish them to Gitee.
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
SCRIPT_ROOT="$ROOT"
cd "$SCRIPT_ROOT"
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
[ -n "$VERSION" ] || {
echo "error: VERSION is required or HEAD must be an exact release tag" >&2
exit 1
}
case "$VERSION" in
v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;;
*) TAG="v$VERSION"; SEMVER="$VERSION" ;;
esac
git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true
target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)"
[ -n "$target_commit" ] || {
echo "error: could not resolve tag ${TAG}" >&2
exit 1
}
current_commit="$(git rev-parse HEAD)"
WORKDIR="$SCRIPT_ROOT"
cleanup_worktree() {
if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then
git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR"
fi
}
trap cleanup_worktree EXIT
if [ "$current_commit" != "$target_commit" ]; then
WORKDIR="$(mktemp -d)"
rm -rf "$WORKDIR"
git worktree add --detach "$WORKDIR" "$TAG"
mkdir -p "$WORKDIR/scripts/release"
cp "$SCRIPT_ROOT/scripts/release/publish-gitee-local.sh" "$WORKDIR/scripts/release/publish-gitee-local.sh"
chmod +x "$WORKDIR/scripts/release/publish-gitee-local.sh"
fi
cd "$WORKDIR"
echo "==> Building ${TAG} locally for Gitee"
VERSION="$SEMVER" ./scripts/dev/build-all.sh
DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh
VERSION="$TAG" ./scripts/release/publish-gitee-local.sh
printf '%s\n' \
'Direct Gitee release builds are disabled.' \
'Use make release-pre/release-stable; release.yml mirrors the exact immutable GitHub assets.' >&2
exit 2
+322
View File
@@ -0,0 +1,322 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION=""
FROM_BETA=""
REMOTE=""
MODE="check"
MODE_SET=0
WIZARD=0
usage() {
cat >&2 <<'EOF'
usage: dws-release [version] [options]
dws-release config [--remote <name>]
With no arguments, starts an interactive release guide. For a version that has
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
full guarded preflight; add --publish to publish after the preflight succeeds.
Options:
--from-beta <tag> Required stable promotion baseline
--remote <name> Override the configured release remote
--check Validate only (default)
--publish Run the preflight, then create and push the tag
-h, --help Show this help
Examples:
dws-release config --remote origin
dws-release v1.2.3-beta.1
dws-release v1.2.3-beta.1 --publish
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
EOF
}
die_usage() {
printf '%s\n' "$1" >&2
usage
exit 2
}
is_interactive() {
[ -t 0 ] && [ -t 1 ]
}
repository_identity() {
identity_url="$1"
case "$identity_url" in
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
*) printf '%s\n' "$identity_url"; return 0 ;;
esac
identity_path="${identity_path%/}"
identity_path="${identity_path%.git}"
printf 'github.com/%s\n' "$identity_path"
}
release_remote_identity() {
identity_remote="$1"
identity_fetch_url="$(git remote get-url "$identity_remote" 2>/dev/null)" || {
printf 'configured release remote does not exist: %s\n' "$identity_remote" >&2
return 1
}
identity_push_urls="$(git remote get-url --push --all "$identity_remote" 2>/dev/null)" || {
printf 'could not resolve push URL for release remote: %s\n' "$identity_remote" >&2
return 1
}
identity_push_count="$(printf '%s\n' "$identity_push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
[ "$identity_push_count" -eq 1 ] || {
printf 'release remote must have exactly one push URL: %s\n' "$identity_remote" >&2
return 1
}
identity_push_url="$(printf '%s\n' "$identity_push_urls" | sed -n '1p')"
identity_fetch="$(repository_identity "$identity_fetch_url")"
identity_push="$(repository_identity "$identity_push_url")"
[ "$identity_fetch" = "$identity_push" ] || {
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' \
"$identity_fetch_url" "$identity_push_url" >&2
return 1
}
printf '%s\n' "$identity_fetch"
}
set_mode() {
requested_mode="$1"
if [ "$MODE_SET" -eq 1 ] && [ "$MODE" != "$requested_mode" ]; then
die_usage '--check and --publish cannot be used together'
fi
MODE="$requested_mode"
MODE_SET=1
}
require_remote() {
if [ -z "$REMOTE" ]; then
REMOTE="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
fi
if [ -z "$REMOTE" ] && is_interactive; then
printf 'Configured remotes:\n' >&2
git remote -v >&2
printf 'Release remote: ' >&2
IFS= read -r REMOTE
fi
[ -n "$REMOTE" ] || {
printf '%s\n' 'release remote is not configured; run: dws-release config --remote <name>' >&2
exit 1
}
expected_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
[ -n "$expected_repository" ] || {
printf '%s\n' 'release repository identity is not configured; rerun: dws-release config --remote <name>' >&2
exit 1
}
actual_repository="$(release_remote_identity "$REMOTE")" || exit 1
[ "$actual_repository" = "$expected_repository" ] || {
printf 'release remote %s changed repository identity:\n configured: %s\n current: %s\n' \
"$REMOTE" "$expected_repository" "$actual_repository" >&2
printf '%s\n' 'Review the remote and run dws-release config again only if this change is intentional.' >&2
exit 1
}
}
sync_main_if_safe() {
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "main" ] || {
printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2
exit 1
}
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release main worktree must be clean before synchronization' >&2
exit 1
}
printf '==> Synchronizing %s/main\n' "$REMOTE"
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
remote_main="refs/remotes/$REMOTE/main"
head_commit="$(git rev-parse HEAD)"
remote_commit="$(git rev-parse "$remote_main^{commit}")"
if [ "$head_commit" = "$remote_commit" ]; then
return 0
fi
if git merge-base --is-ancestor HEAD "$remote_main"; then
git merge --ff-only "$remote_main"
return 0
fi
if git merge-base --is-ancestor "$remote_main" HEAD; then
printf 'local main is ahead of %s/main; publish only after the commits are reviewed and pushed\n' "$REMOTE" >&2
else
printf 'local main has diverged from %s/main; resolve it explicitly before release\n' "$REMOTE" >&2
fi
exit 1
}
configure_remote() {
shift
config_remote=""
while [ "$#" -gt 0 ]; do
case "$1" in
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
config_remote="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*) die_usage "unknown config argument: $1" ;;
esac
done
cd "$ROOT"
if [ -z "$config_remote" ]; then
configured="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
configured_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
printf 'Configured release remote: %s\n' "${configured:-none}"
printf 'Configured repository: %s\n' "${configured_repository:-none}"
git remote -v
exit 0
fi
configured_repository="$(release_remote_identity "$config_remote")" || exit 1
git config --local dws.releaseRemote "$config_remote"
git config --local dws.releaseRepository "$configured_repository"
printf 'Configured release remote: %s\n' "$config_remote"
printf 'Configured repository: %s\n' "$configured_repository"
exit 0
}
if [ "${1:-}" = "config" ]; then
configure_remote "$@"
fi
[ "$#" -gt 0 ] || WIZARD=1
while [ "$#" -gt 0 ]; do
case "$1" in
--from-beta)
[ "$#" -ge 2 ] || die_usage '--from-beta requires a value'
FROM_BETA="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
shift 2
;;
--check) set_mode check; shift ;;
--publish) set_mode publish; shift ;;
-h|--help) usage; exit 0 ;;
-*) die_usage "unknown argument: $1" ;;
*)
[ -z "$VERSION" ] || die_usage "unexpected argument: $1"
VERSION="$1"
shift
;;
esac
done
if [ -z "$VERSION" ]; then
is_interactive || { usage; exit 2; }
printf 'Release version (vX.Y.Z-beta.N or vX.Y.Z): ' >&2
IFS= read -r VERSION
fi
CHANNEL="$(release_channel_for_version "$VERSION")" || exit 2
if [ "$CHANNEL" = "stable" ]; then
if [ -z "$FROM_BETA" ] && is_interactive; then
printf 'Validated beta tag for %s: ' "$VERSION" >&2
IFS= read -r FROM_BETA
fi
[ -n "$FROM_BETA" ] || die_usage 'stable release requires --from-beta <tag>'
release_validate_version_channel prerelease "$FROM_BETA" || exit 2
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
exit 2
}
else
[ -z "$FROM_BETA" ] || die_usage '--from-beta is only valid for stable releases'
fi
if [ "$WIZARD" -eq 1 ]; then
printf 'Mode [check/publish] (default: check): ' >&2
IFS= read -r selected_mode
case "$selected_mode" in
''|check) MODE=check ;;
publish) MODE=publish ;;
*) die_usage "invalid mode: $selected_mode" ;;
esac
fi
[ -z "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-}" ] || {
printf '%s\n' 'DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE is test-only and cannot be set through dws-release' >&2
exit 1
}
[ -z "${DWS_RELEASE_OFFICIAL_TAGS_URL:-}" ] || {
printf '%s\n' 'DWS_RELEASE_OFFICIAL_TAGS_URL cannot override release authority through dws-release' >&2
exit 1
}
[ -z "${DWS_RELEASE_OFFICIAL_REPOSITORY:-}" ] || {
printf '%s\n' 'DWS_RELEASE_OFFICIAL_REPOSITORY cannot override release authority through dws-release' >&2
exit 1
}
cd "$ROOT"
CHANGELOG="$ROOT/CHANGELOG.md"
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
MAIN_SYNCED=0
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" = "main" ]; then
require_remote
sync_main_if_safe
MAIN_SYNCED=1
fi
semver="$(release_semver "$VERSION")"
section_count="$(awk -v wanted="$semver" '
BEGIN { prefix = "## [" wanted "] - "; count = 0 }
index($0, prefix) == 1 { count++ }
END { print count }
' "$CHANGELOG")"
if [ "$section_count" -eq 0 ]; then
printf '==> Preparing the missing CHANGELOG section for %s\n' "$VERSION"
if [ "$CHANNEL" = "stable" ]; then
"$SCRIPT_DIR/prepare-changelog.sh" stable "$VERSION" --from-beta "$FROM_BETA"
else
"$SCRIPT_DIR/prepare-changelog.sh" prerelease "$VERSION"
fi
printf '\nCHANGELOG preparation is complete; publishing intentionally stopped.\n'
printf 'Replace TODO, review the diff, commit it, and merge it to main. Then run dws-release for %s again.\n' "$VERSION"
exit 0
fi
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-entry.XXXXXX")"
cleanup() { rm -f "$notes_tmp"; }
trap cleanup EXIT HUP INT TERM
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
if [ "$MAIN_SYNCED" -ne 1 ]; then
require_remote
sync_main_if_safe
fi
printf '==> DWS release entry\n'
printf ' mode: %s\n' "$MODE"
printf ' channel: %s\n' "$CHANNEL"
printf ' version: %s\n' "$VERSION"
printf ' remote: %s\n' "$REMOTE"
[ -z "$FROM_BETA" ] || printf ' beta: %s\n' "$FROM_BETA"
set -- "$CHANNEL" "$VERSION" --remote "$REMOTE"
if [ -n "$FROM_BETA" ]; then
set -- "$@" --from-beta "$FROM_BETA"
fi
if [ "$MODE" = "publish" ]; then
set -- "$@" --publish
fi
cleanup
trap - EXIT HUP INT TERM
exec "$SCRIPT_DIR/release.sh" "$@"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
PACKAGE_DIR="${1:-}"
OUTPUT="${2:-}"
[ -n "$PACKAGE_DIR" ] && [ -n "$OUTPUT" ] || {
printf 'usage: pack-npm-package.sh <package-dir> <output.tgz>\n' >&2
exit 2
}
[ -f "$PACKAGE_DIR/package.json" ] || {
printf 'npm package manifest not found: %s/package.json\n' "$PACKAGE_DIR" >&2
exit 1
}
command -v npx >/dev/null 2>&1 || { printf 'npx is required\n' >&2; exit 1; }
command -v node >/dev/null 2>&1 || { printf 'node is required\n' >&2; exit 1; }
NPM_PACK_VERSION="10.9.2"
output_dir="$(CDPATH= cd -- "$(dirname -- "$OUTPUT")" && pwd)"
output_name="$(basename -- "$OUTPUT")"
rm -f "$output_dir/$output_name"
pack_json="$(
npx --yes --package "npm@$NPM_PACK_VERSION" -- \
npm pack "$PACKAGE_DIR" --pack-destination "$output_dir" --json --ignore-scripts
)"
pack_metadata="$(printf '%s' "$pack_json" | node -e '
let input = "";
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const entries = JSON.parse(input);
if (!Array.isArray(entries) || entries.length !== 1) {
throw new Error("npm pack did not return exactly one package");
}
const entry = entries[0];
if (!entry.filename || !entry.integrity) {
throw new Error("npm pack output is missing filename or integrity");
}
process.stdout.write(`${entry.filename}\n${entry.integrity}\n`);
});
')"
packed_name="$(printf '%s\n' "$pack_metadata" | sed -n '1p')"
integrity="$(printf '%s\n' "$pack_metadata" | sed -n '2p')"
[ -f "$output_dir/$packed_name" ] || {
printf 'npm pack did not create expected tarball: %s\n' "$packed_name" >&2
exit 1
}
if [ "$packed_name" != "$output_name" ]; then
mv "$output_dir/$packed_name" "$output_dir/$output_name"
fi
printf '%s\n' "$integrity"
+14 -21
View File
@@ -90,36 +90,27 @@ resolve_release_base_url() {
stage_npm_package() {
version="$1"
pkg_root="$DIST_DIR/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp "$ROOT/build/npm/install.js" "$pkg_root/install.js"
cp "$ROOT/build/npm/bin/dws.js" "$pkg_root/bin/dws.js"
cp "$ROOT/build/npm/README.md" "$pkg_root/README.md"
sed "s|__VERSION__|$version|g" "$ROOT/build/npm/package.json.tmpl" > "$pkg_root/package.json"
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/dws-skills.zip; do
if [ -f "$artifact" ]; then
cp "$artifact" "$pkg_root/assets/"
fi
done
DWS_PACKAGE_SOURCE_ROOT="$ROOT" \
DWS_PACKAGE_DIST_DIR="$DIST_DIR" \
"$ROOT/scripts/release/stage-npm-package.sh" "$version"
}
# ---------- Homebrew formula staging ----------
render_homebrew_formula() {
class_name="$1"
archive_url="$2"
skills_url="$3"
archive_sha="$4"
skills_sha="$5"
keg_only_line="$6"
output_path="$7"
formula_version="$2"
archive_url="$3"
skills_url="$4"
archive_sha="$5"
skills_sha="$6"
keg_only_line="$7"
output_path="$8"
sed \
-e "s|__CLASS_NAME__|$class_name|g" \
-e "s|__VERSION__|$formula_version|g" \
-e "s|__SKILL_HOME_OVERRIDE__||g" \
-e "s|__ARCHIVE_URL__|$archive_url|g" \
-e "s|__ARCHIVE_SHA256__|$archive_sha|g" \
-e "s|__SKILLS_URL__|$skills_url|g" \
@@ -149,6 +140,7 @@ stage_homebrew_formula() {
render_homebrew_formula \
"DingtalkWorkspaceCliLocal" \
"$version" \
"file://$archive_path" \
"file://$DIST_DIR/dws-skills.zip" \
"$archive_sha" \
@@ -158,6 +150,7 @@ stage_homebrew_formula() {
render_homebrew_formula \
"DingtalkWorkspaceCli" \
"$version" \
"$release_url_base/$archive_name" \
"$release_url_base/$skills_name" \
"$archive_sha" \
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CHANNEL="${1:-}"
VERSION="${2:-}"
FROM_BETA=""
FROM_REF=""
CHANGELOG="$ROOT/CHANGELOG.md"
usage() {
cat >&2 <<'EOF'
usage: prepare-changelog.sh <prerelease|stable> <version> [options]
Options:
--from-beta <tag> Required for stable release notes
--from-ref <ref> Commit-list baseline for prerelease notes
--changelog <path> Override CHANGELOG.md path
EOF
}
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
shift 2
while [ "$#" -gt 0 ]; do
case "$1" in
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
--from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;;
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
release_validate_version_channel "$CHANNEL" "$VERSION"
cd "$ROOT"
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf 'prepare changelog requires a clean worktree\n' >&2
exit 1
}
semver="$(release_semver "$VERSION")"
if grep -Fq "## [$semver] - " "$CHANGELOG"; then
printf 'CHANGELOG section already exists for %s\n' "$semver" >&2
exit 1
fi
if [ "$CHANNEL" = "stable" ]; then
[ -n "$FROM_BETA" ] || { printf 'stable changelog requires --from-beta\n' >&2; exit 1; }
release_is_prerelease_version "$FROM_BETA" || { printf 'invalid beta baseline: %s\n' "$FROM_BETA" >&2; exit 1; }
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
exit 1
}
FROM_REF="$FROM_BETA"
fi
release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
cleanup() { rm -f "$section" "$output"; }
trap cleanup EXIT HUP INT TERM
{
printf '## [%s] - %s\n\n' "$semver" "$release_date"
if [ "$CHANNEL" = "stable" ]; then
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
else
printf '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\n\n'
fi
printf '### Changed\n\n'
if [ "$CHANNEL" = "stable" ]; then
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
else
printf -- '- TODO: summarize this beta candidate and its validation scope.\n'
fi
} > "$section"
inserted=0
in_unreleased=0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
'## [Unreleased]')
in_unreleased=1
;;
'## '*)
if [ "$in_unreleased" -eq 1 ]; then
cat "$section" >> "$output"
printf '\n' >> "$output"
inserted=1
in_unreleased=0
fi
;;
esac
printf '%s\n' "$line" >> "$output"
done < "$CHANGELOG"
if [ "$in_unreleased" -eq 1 ]; then
printf '\n' >> "$output"
cat "$section" >> "$output"
inserted=1
fi
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
cp "$output" "$CHANGELOG"
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then
printf '\nCommits since %s:\n' "$FROM_REF"
git log --oneline "$FROM_REF..HEAD"
fi
+5 -142
View File
@@ -1,144 +1,7 @@
#!/usr/bin/env bash
# Publish locally-built release artifacts to the matching Gitee release.
#
# Intended to run inside Gitee Go after building artifacts in China. This avoids
# the unreliable GitHub Actions -> Gitee cross-border upload path.
#!/bin/sh
set -eu
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required"
[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR"
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag"
case "$VERSION" in
v*) ;;
*) VERSION="v$VERSION" ;;
esac
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}'
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'
fi
}
api_get() {
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@"
}
echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}"
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)"
rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
if [ -z "$release_id" ]; then
echo " No Gitee release for ${VERSION} yet — creating it."
rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X POST "${base}/releases" \
-F "access_token=${GITEE_TOKEN}" \
-F "tag_name=${VERSION}" \
-F "name=${VERSION}" \
-F "body=Gitee-local build of ${VERSION} for China users." \
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
fi
[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}"
echo " Gitee release id = ${release_id}"
assets_map="$(api_get "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
| python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
for a in rows:
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
if n and i!="":
print("%s\t%s\t%s" % (n, i, u))
except Exception:
pass' 2>/dev/null || true)"
gitee_attach() {
file="$1"
fn="$(basename "$file")"
attempt=1
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
--retry 2 --retry-delay 5 --retry-all-errors \
-X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
return 0
fi
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
attempt=$((attempt + 1))
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
done
return 1
}
gitee_delete() {
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
}
uploaded=0
replaced=0
skipped=0
failed=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt "$DIST_DIR"/dws-skills.zip; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
local_sha="$(sha256_of "$f")"
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
count="$(printf '%s' "$ids" | grep -c . || true)"
if [ "$count" -eq 1 ]; then
gitee_sha="$(api_get "$aurl" 2>/dev/null | sha256_of || true)"
if [ "$gitee_sha" = "$local_sha" ]; then
echo " ✓ ${fn} already correct on Gitee — skip"
skipped=$((skipped + 1))
continue
fi
echo " ↻ ${fn} differs on Gitee — replacing"
elif [ "$count" -gt 1 ]; then
echo " ↻ ${fn} has ${count} copies on Gitee — replacing"
else
echo " ⬆ ${fn} (new)"
fi
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
done
if gitee_attach "$f"; then
if [ "$count" -eq 0 ]; then uploaded=$((uploaded + 1)); else replaced=$((replaced + 1)); fi
else
echo " ❌ upload failed for ${fn}" >&2
failed=$((failed + 1))
fi
done
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
printf '%s\n' \
'Direct Gitee artifact publication is disabled.' \
'Use the guarded Release workflow so Gitee receives the exact immutable GitHub assets.' >&2
exit 2
+221
View File
@@ -0,0 +1,221 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CHANNEL=""
VERSION=""
CONTEXT="local"
REMOTE="origin"
BRANCH="main"
FROM_BETA=""
FROM_BETA_COMMIT=""
CHANGELOG=""
NOTES_OUTPUT=""
METADATA_OUTPUT=""
usage() {
cat >&2 <<'EOF'
usage: release-contract.sh --channel <prerelease|stable> --version <tag> [options]
Options:
--context <local|ci> Local pre-push checks or tag-workflow checks
--remote <name> Release remote (default: origin)
--branch <name> Sealed release branch (default: main)
--from-beta <tag> Required stable promotion baseline
--repo-root <path> Override repository root (primarily for tests)
--changelog <path> Override CHANGELOG.md path
--notes-output <path> Write the exact CHANGELOG section body
--metadata-output <path> Append channel/version/baseline key-value output
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--channel) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANNEL="$2"; shift 2 ;;
--version) [ "$#" -ge 2 ] || { usage; exit 2; }; VERSION="$2"; shift 2 ;;
--context) [ "$#" -ge 2 ] || { usage; exit 2; }; CONTEXT="$2"; shift 2 ;;
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
--branch) [ "$#" -ge 2 ] || { usage; exit 2; }; BRANCH="$2"; shift 2 ;;
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
--repo-root) [ "$#" -ge 2 ] || { usage; exit 2; }; ROOT="$2"; shift 2 ;;
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
--notes-output) [ "$#" -ge 2 ] || { usage; exit 2; }; NOTES_OUTPUT="$2"; shift 2 ;;
--metadata-output) [ "$#" -ge 2 ] || { usage; exit 2; }; METADATA_OUTPUT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
case "$CONTEXT" in local|ci) ;; *) printf 'invalid context: %s\n' "$CONTEXT" >&2; exit 2 ;; esac
release_validate_version_channel "$CHANNEL" "$VERSION"
cd "$ROOT"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
printf 'not a Git worktree: %s\n' "$ROOT" >&2
exit 1
}
[ -n "$CHANGELOG" ] || CHANGELOG="$ROOT/CHANGELOG.md"
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
head_commit="$(git rev-parse HEAD)"
remote_main="refs/remotes/$REMOTE/$BRANCH"
git remote get-url "$REMOTE" >/dev/null 2>&1 || {
printf 'release remote does not exist: %s\n' "$REMOTE" >&2
exit 1
}
git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || {
printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
remote_main_commit="$(git rev-parse "$remote_main^{commit}")"
if [ "$CONTEXT" = "local" ]; then
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2
exit 1
}
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$BRANCH" ] || {
printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2
exit 1
}
[ "$head_commit" = "$remote_main_commit" ] || {
printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
printf 'release tag already exists locally: %s\n' "$VERSION" >&2
exit 1
fi
remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || {
printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2
exit 1
}
[ -z "$remote_tag" ] || {
printf 'release tag already exists on %s: %s\n' "$REMOTE" "$VERSION" >&2
exit 1
}
else
git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || {
printf 'CI release tag is not available: %s\n' "$VERSION" >&2
exit 1
}
[ "$(git rev-parse "refs/tags/$VERSION^{commit}")" = "$head_commit" ] || {
printf 'CI checkout HEAD does not match tag %s\n' "$VERSION" >&2
exit 1
}
[ "$(git cat-file -t "refs/tags/$VERSION")" = "tag" ] || {
printf 'release tag must be annotated: %s\n' "$VERSION" >&2
exit 1
}
git merge-base --is-ancestor HEAD "$remote_main" || {
printf 'release tag commit must be contained in %s/%s\n' "$REMOTE" "$BRANCH" >&2
exit 1
}
fi
previous_stable=""
previous_stable_commit=""
for tag in $(git tag --list 'v*' --sort=-version:refname); do
[ "$tag" = "$VERSION" ] && continue
if release_is_stable_version "$tag"; then
previous_stable="$tag"
previous_stable_commit="$(git rev-parse "$tag^{commit}")"
break
fi
done
if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then
printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2
exit 1
fi
core_tag="$(release_core_tag "$VERSION")"
if [ "$CHANNEL" = "prerelease" ]; then
[ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; }
if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then
printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2
exit 1
fi
previous_beta=""
for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do
[ "$tag" = "$VERSION" ] && continue
if release_is_prerelease_version "$tag"; then
previous_beta="$tag"
break
fi
done
beta_number="$(release_beta_number "$VERSION")"
if [ -z "$previous_beta" ]; then
[ "$beta_number" -eq 1 ] || {
printf 'the first prerelease for %s must be beta.1\n' "$core_tag" >&2
exit 1
}
else
previous_beta_number="$(release_beta_number "$previous_beta")"
expected_beta_number=$((previous_beta_number + 1))
[ "$beta_number" -eq "$expected_beta_number" ] || {
printf 'prerelease after %s must be %s-beta.%s\n' "$previous_beta" "$core_tag" "$expected_beta_number" >&2
exit 1
}
fi
else
if [ -z "$FROM_BETA" ] && [ "$CONTEXT" = "ci" ]; then
FROM_BETA="$(git for-each-ref "refs/tags/$VERSION" --format='%(contents)' | awk '/^From-Beta:[[:space:]]*/ { sub(/^From-Beta:[[:space:]]*/, ""); print }')"
fi
[ -n "$FROM_BETA" ] || {
printf 'stable release requires an explicit --from-beta tag\n' >&2
exit 1
}
release_is_prerelease_version "$FROM_BETA" || {
printf 'invalid stable beta baseline: %s\n' "$FROM_BETA" >&2
exit 1
}
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
exit 1
}
git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || {
printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2
exit 1
}
FROM_BETA_COMMIT="$(git rev-parse "refs/tags/$FROM_BETA^{commit}")"
git merge-base --is-ancestor "$FROM_BETA^{commit}" HEAD || {
printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2
exit 1
}
if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then
printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2
git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2
exit 1
fi
fi
semver="$(release_semver "$VERSION")"
if [ -n "$NOTES_OUTPUT" ]; then
release_extract_changelog "$CHANGELOG" "$semver" "$NOTES_OUTPUT"
else
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-contract.XXXXXX")"
trap 'rm -f "$notes_tmp"' EXIT HUP INT TERM
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
fi
if [ -n "$METADATA_OUTPUT" ]; then
mkdir -p "$(dirname "$METADATA_OUTPUT")"
{
printf 'channel=%s\n' "$CHANNEL"
printf 'version=%s\n' "$VERSION"
printf 'semver=%s\n' "$semver"
printf 'previous_stable=%s\n' "$previous_stable"
printf 'previous_stable_commit=%s\n' "$previous_stable_commit"
printf 'from_beta=%s\n' "$FROM_BETA"
printf 'from_beta_commit=%s\n' "$FROM_BETA_COMMIT"
} >> "$METADATA_OUTPUT"
fi
printf 'Release contract passed: channel=%s version=%s commit=%s' "$CHANNEL" "$VERSION" "$head_commit"
[ -z "$FROM_BETA" ] || printf ' from-beta=%s' "$FROM_BETA"
printf '\n'
+167
View File
@@ -0,0 +1,167 @@
#!/bin/sh
# Shared release version and CHANGELOG helpers. This file is sourced by the
# local release command, the CI contract, and mirror publishing so every stage
# agrees on what "prerelease" and "stable" mean.
release_stable_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
release_prerelease_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.[1-9][0-9]*$'
release_is_stable_version() {
printf '%s\n' "$1" | grep -Eq "$release_stable_pattern"
}
release_is_prerelease_version() {
printf '%s\n' "$1" | grep -Eq "$release_prerelease_pattern"
}
release_channel_for_version() {
version="$1"
if release_is_stable_version "$version"; then
printf '%s\n' stable
return 0
fi
if release_is_prerelease_version "$version"; then
printf '%s\n' prerelease
return 0
fi
printf 'invalid release version: %s (expected vX.Y.Z or vX.Y.Z-beta.N)\n' "$version" >&2
return 1
}
release_validate_version_channel() {
expected="$1"
version="$2"
case "$expected" in
stable|prerelease) ;;
*)
printf 'invalid release channel: %s (expected prerelease or stable)\n' "$expected" >&2
return 1
;;
esac
actual="$(release_channel_for_version "$version")" || return 1
if [ "$actual" != "$expected" ]; then
printf 'release channel/version mismatch: channel=%s version=%s\n' "$expected" "$version" >&2
return 1
fi
}
release_semver() {
printf '%s\n' "${1#v}"
}
release_core_tag() {
version="$1"
printf '%s\n' "${version%%-beta.*}"
}
release_beta_number() {
printf '%s\n' "${1##*.}"
}
release_core_is_greater() {
candidate="$(release_core_tag "$1")"
baseline="$(release_core_tag "$2")"
candidate="${candidate#v}"
baseline="${baseline#v}"
awk -v candidate="$candidate" -v baseline="$baseline" 'BEGIN {
split(candidate, c, ".")
split(baseline, b, ".")
for (i = 1; i <= 3; i++) {
if ((c[i] + 0) > (b[i] + 0)) exit 0
if ((c[i] + 0) < (b[i] + 0)) exit 1
}
exit 1
}'
}
release_version_is_greater() {
_rvig_candidate="$1"
_rvig_baseline="$2"
_rvig_candidate_channel="$(release_channel_for_version "$_rvig_candidate")" || return 1
_rvig_baseline_channel="$(release_channel_for_version "$_rvig_baseline")" || return 1
if release_core_is_greater "$_rvig_candidate" "$_rvig_baseline"; then
return 0
fi
if release_core_is_greater "$_rvig_baseline" "$_rvig_candidate"; then
return 1
fi
if [ "$_rvig_candidate_channel" = "stable" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
return 0
fi
if [ "$_rvig_candidate_channel" = "prerelease" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
[ "$(release_beta_number "$_rvig_candidate")" -gt "$(release_beta_number "$_rvig_baseline")" ]
return
fi
return 1
}
# Extract one exact CHANGELOG section (without its H2 heading) and validate that
# it is dated, unique, non-placeholder content with at least one bullet.
release_extract_changelog() {
changelog="$1"
semver="$2"
output="$3"
tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-notes.XXXXXX")"
set +e
awk -v wanted="$semver" '
BEGIN {
prefix = "## [" wanted "] - "
found = 0
active = 0
invalid_date = 0
meaningful = 0
bullet = 0
placeholder = 0
}
/^## / {
active = 0
if (index($0, prefix) == 1) {
found++
active = 1
date = substr($0, length(prefix) + 1)
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) invalid_date = 1
}
next
}
active {
print
if ($0 ~ /[^[:space:]]/) meaningful = 1
if ($0 ~ /^- /) bullet = 1
lowered = tolower($0)
if (lowered ~ /todo|tbd/) placeholder = 1
}
END {
if (found != 1) exit 41
if (invalid_date) exit 42
if (!meaningful || !bullet) exit 43
if (placeholder) exit 44
}
' "$changelog" > "$tmp"
status=$?
set -e
case "$status" in
0) ;;
41) printf 'CHANGELOG must contain exactly one section: ## [%s] - YYYY-MM-DD\n' "$semver" >&2 ;;
42) printf 'CHANGELOG section for %s has an invalid date\n' "$semver" >&2 ;;
43) printf 'CHANGELOG section for %s must contain release notes and at least one bullet\n' "$semver" >&2 ;;
44) printf 'CHANGELOG section for %s still contains TODO/TBD placeholders\n' "$semver" >&2 ;;
*) printf 'failed to parse CHANGELOG section for %s\n' "$semver" >&2 ;;
esac
if [ "$status" -ne 0 ]; then
rm -f "$tmp"
return "$status"
fi
if [ "$output" = "-" ]; then
cat "$tmp"
else
mkdir -p "$(dirname "$output")"
mv "$tmp" "$output"
tmp=""
fi
[ -z "$tmp" ] || rm -f "$tmp"
}
+353
View File
@@ -0,0 +1,353 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CHANNEL="${1:-}"
VERSION="${2:-}"
REMOTE=""
BRANCH="main"
FROM_BETA=""
PUBLISH=0
YES=0
OFFICIAL_TAGS_URL="${DWS_RELEASE_OFFICIAL_TAGS_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}"
usage() {
cat >&2 <<'EOF'
usage: release.sh <prerelease|stable> <version> [options]
Runs the full test, command-compatibility, package, and install preflight.
The default is validation only; add --publish to create and push the tag.
Options:
--remote <name> Required tag destination
--from-beta <tag> Required for stable releases
--publish Create and push the annotated release tag
--yes Skip the interactive version confirmation
EOF
}
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
shift 2
while [ "$#" -gt 0 ]; do
case "$1" in
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
--publish) PUBLISH=1; shift ;;
--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
release_validate_version_channel "$CHANNEL" "$VERSION"
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
cd "$ROOT"
fetch_url="$(git remote get-url "$REMOTE" 2>/dev/null)" || { printf 'unknown release remote: %s\n' "$REMOTE" >&2; exit 1; }
push_urls="$(git remote get-url --push --all "$REMOTE" 2>/dev/null)" || {
printf 'could not resolve push URL for release remote: %s\n' "$REMOTE" >&2
exit 1
}
[ "$(printf '%s\n' "$push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf 'release remote must have exactly one push URL: %s\n' "$REMOTE" >&2
exit 1
}
push_url="$(printf '%s\n' "$push_urls" | sed -n '1p')"
repository_identity() {
identity_url="$1"
case "$identity_url" in
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
*) printf '%s\n' "$identity_url"; return 0 ;;
esac
identity_path="${identity_path%/}"
identity_path="${identity_path%.git}"
printf 'github.com/%s\n' "$identity_path"
}
fetch_identity="$(repository_identity "$fetch_url")"
push_identity="$(repository_identity "$push_url")"
[ "$fetch_identity" = "$push_identity" ] || {
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' "$fetch_url" "$push_url" >&2
exit 1
}
printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url"
github_repository_from_url() {
github_identity="$(repository_identity "$1")"
case "$github_identity" in
github.com/*) printf '%s\n' "${github_identity#github.com/}" ;;
*) return 1 ;;
esac
}
require_delivered_previous_stable() {
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
if [ -z "$github_repository" ]; then
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
printf 'release validation requires a github.com remote to prove the stable baseline was delivered\n' >&2
return 1
}
return 0
fi
[ -n "$previous_stable" ] || {
printf 'a delivered previous stable baseline is required\n' >&2
return 1
}
stable_commit="$(git rev-parse "$previous_stable^{commit}")"
DWS_RELEASE_OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" \
"$SCRIPT_DIR/verify-delivered-stable.sh" "$previous_stable" "$stable_commit"
}
require_github_publication_authority() {
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
if [ -z "$github_repository" ]; then
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
printf 'publishing requires a github.com remote so CI and delivery authority can be verified\n' >&2
return 1
}
printf 'warning: GitHub publication checks explicitly disabled for non-GitHub test remote\n' >&2
return 0
fi
command -v gh >/dev/null 2>&1 || {
printf 'gh is required to verify CI and release authority before publishing\n' >&2
return 1
}
immutable_enabled="$(
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$github_repository/immutable-releases" \
--jq '.enabled'
)" || {
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
return 1
}
[ "$immutable_enabled" = "true" ] || {
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
return 1
}
active_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
--jq '.workflow_runs[] | select(.status != "completed") | [.id, .event, .status, .head_branch] | @tsv'
)" || {
printf 'could not query active Release workflow runs for %s\n' "$github_repository" >&2
return 1
}
[ -z "$active_runs" ] || {
printf 'another Release workflow is still active; wait for it before allocating a new tag:\n%s\n' "$active_runs" >&2
return 1
}
sealed_commit="$(git rev-parse HEAD)"
passed_gate="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \
--jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length'
)" || {
printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2
return 1
}
[ "$passed_gate" -gt 0 ] || {
printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2
return 1
}
if [ "$CHANNEL" = "stable" ]; then
beta_state="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/releases/tags/$FROM_BETA" \
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
)" || {
printf 'could not query beta release %s in %s\n' "$FROM_BETA" "$github_repository" >&2
return 1
}
[ "$beta_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" ] || {
printf '%s must be a public immutable prerelease before stable tag allocation (got: %s)\n' "$FROM_BETA" "$beta_state" >&2
return 1
}
beta_assets="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/releases/tags/$FROM_BETA" \
--jq '.assets[].name'
)" || return 1
[ "$(printf '%s\n' "$beta_assets" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 8 ] || {
printf 'beta release %s must contain exactly eight supported assets\n' "$FROM_BETA" >&2
return 1
}
for beta_asset in \
dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \
dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \
dws-windows-amd64.zip dws-windows-arm64.zip \
dws-skills.zip checksums.txt; do
[ "$(printf '%s\n' "$beta_assets" | grep -Fxc "$beta_asset")" -eq 1 ] || {
printf 'beta release %s must contain %s exactly once\n' "$FROM_BETA" "$beta_asset" >&2
return 1
}
done
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
beta_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
)" || return 1
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
END { exit(found ? 0 : 1) }
' || {
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
return 1
}
fi
}
fetch_release_tags() {
git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*'
git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*'
}
printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-metadata.XXXXXX")"
final_metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-final-metadata.XXXXXX")"
cleanup() { rm -f "$metadata" "$final_metadata"; }
trap cleanup EXIT HUP INT TERM
run_contract() {
if [ -n "$FROM_BETA" ]; then
"$SCRIPT_DIR/release-contract.sh" \
--channel "$CHANNEL" \
--version "$VERSION" \
--context local \
--remote "$REMOTE" \
--branch "$BRANCH" \
--from-beta "$FROM_BETA" \
"$@"
else
"$SCRIPT_DIR/release-contract.sh" \
--channel "$CHANNEL" \
--version "$VERSION" \
--context local \
--remote "$REMOTE" \
--branch "$BRANCH" \
"$@"
fi
}
run_contract --metadata-output "$metadata"
previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)"
printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}"
require_delivered_previous_stable
if [ "$PUBLISH" -eq 1 ]; then
printf '==> Verifying GitHub publication authority before local gates\n'
require_github_publication_authority
fi
printf '==> Running repository test and policy gates\n'
make test
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$previous_stable"
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
make package VERSION="$VERSION"
printf '==> Verifying release artifact set and checksums\n'
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
printf '==> Verifying npm package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
if command -v brew >/dev/null 2>&1; then
printf '==> Verifying Homebrew package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
fi
# Collect human confirmation before the final authority refresh. Nothing may
# block between that refresh and tag creation.
if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
if [ ! -t 0 ]; then
printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2
exit 1
fi
printf 'Type %s to create and push the release tag: ' "$VERSION"
IFS= read -r confirmation
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
fi
# Re-check after every local gate so tag creation cannot race with a modified
# source tree. dist/ is ignored and therefore does not make the tree dirty.
printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
run_contract --metadata-output "$final_metadata"
final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
if [ "$final_previous_stable" != "$previous_stable" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
"${previous_stable:-none}" "${final_previous_stable:-none}"
[ -n "$final_previous_stable" ] || {
printf 'latest stable authority unexpectedly became empty\n' >&2
exit 1
}
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$final_previous_stable"
fi
if [ "$PUBLISH" -ne 1 ]; then
printf '\nPreflight passed. No tag was created.\n'
printf 'Publish with the same command plus --publish.\n'
exit 0
fi
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
require_github_publication_authority
printf '==> Creating annotated tag %s\n' "$VERSION"
if [ "$CHANNEL" = "stable" ]; then
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA"
else
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
fi
if ! git push "$push_url" "refs/tags/$VERSION"; then
set +e
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
query_status=$?
set -e
if [ "$query_status" -ne 0 ]; then
printf 'tag push reported failure and remote state could not be verified; keeping local tag %s for investigation\n' "$VERSION" >&2
exit 1
fi
remote_object="$(printf '%s\n' "$remote_refs" | awk '$2 !~ /\^\{\}$/ { print $1; exit }')"
local_object="$(git rev-parse "refs/tags/$VERSION")"
if [ -z "$remote_object" ]; then
git tag -d "$VERSION" >/dev/null 2>&1 || true
printf 'tag push failed; remote has no tag and the new local tag was removed: %s\n' "$VERSION" >&2
exit 1
fi
if [ "$remote_object" != "$local_object" ]; then
printf 'tag push failed and remote %s points elsewhere; keeping local tag for investigation\n' "$VERSION" >&2
exit 1
fi
printf 'warning: push reported failure, but push target %s has the exact sealed tag; treating it as published\n' "$push_url" >&2
fi
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
DEFAULT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
SOURCE_ROOT="${DWS_PACKAGE_SOURCE_ROOT:-$DEFAULT_ROOT}"
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$DEFAULT_ROOT/dist}"
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
[ -n "$VERSION" ] || { printf 'package version is required\n' >&2; exit 2; }
SEMVER="${VERSION#v}"
printf '%s\n' "$SEMVER" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' || {
printf 'invalid npm package version: %s\n' "$VERSION" >&2
exit 2
}
PKG_ROOT="$DIST_DIR/npm/dingtalk-workspace-cli"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/assets" "$PKG_ROOT/bin"
cp "$SOURCE_ROOT/build/npm/install.js" "$PKG_ROOT/install.js"
cp "$SOURCE_ROOT/build/npm/bin/dws.js" "$PKG_ROOT/bin/dws.js"
cp "$SOURCE_ROOT/build/npm/README.md" "$PKG_ROOT/README.md"
sed "s|__VERSION__|$SEMVER|g" "$SOURCE_ROOT/build/npm/package.json.tmpl" > "$PKG_ROOT/package.json"
command -v node >/dev/null 2>&1 || { printf 'node is required to validate the npm manifest\n' >&2; exit 1; }
node - "$PKG_ROOT/package.json" "$SEMVER" <<'NODE'
const fs = require("fs");
const [manifestPath, version] = process.argv.slice(2);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const expectedScripts = { postinstall: "node install.js" };
if (manifest.name !== "dingtalk-workspace-cli") {
throw new Error(`unexpected npm package name: ${manifest.name}`);
}
if (manifest.version !== version) {
throw new Error(`unexpected npm package version: ${manifest.version}`);
}
if (JSON.stringify(manifest.scripts) !== JSON.stringify(expectedScripts)) {
throw new Error(`unexpected npm lifecycle scripts: ${JSON.stringify(manifest.scripts)}`);
}
if (!manifest.bin || manifest.bin.dws !== "./bin/dws.js") {
throw new Error("unexpected npm binary entrypoint");
}
NODE
copied=0
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$artifact" ] || continue
cp "$artifact" "$PKG_ROOT/assets/"
copied=$((copied + 1))
done
[ "$copied" -gt 0 ] || { printf 'no release assets found in %s\n' "$DIST_DIR" >&2; exit 1; }
[ -f "$PKG_ROOT/assets/dws-skills.zip" ] || { printf 'dws-skills.zip is missing\n' >&2; exit 1; }
[ -f "$PKG_ROOT/assets/checksums.txt" ] || { printf 'checksums.txt is missing\n' >&2; exit 1; }
printf 'Staged npm package %s at %s\n' "$SEMVER" "$PKG_ROOT"
+82 -12
View File
@@ -24,8 +24,9 @@
# Gating: if GITEE_TOKEN / GITEE_USER / GITEE_REPO are unset, exit 0 with a
# notice so the step can live in release.yml without breaking forks.
set -eu
set -euo pipefail
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
@@ -39,12 +40,17 @@ missing=""
[ -z "${GITEE_USER:-}" ] && missing="$missing GITEE_USER"
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
if [ -n "$missing" ]; then
if [ "${DWS_REQUIRE_GITEE:-0}" = "1" ]; then
echo "❌ Gitee mirror sync is enabled but credentials are missing:${missing}" >&2
exit 1
fi
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
exit 0
fi
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
@@ -72,10 +78,18 @@ gitee_tag_commit() {
}'
}
echo " Syncing Gitee tag ${VERSION} -> ${target_commit}"
git push --force "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
gitee_commit="$(gitee_tag_commit || true)"
if [ -n "$gitee_commit" ] && [ "$gitee_commit" != "$target_commit" ]; then
echo "❌ Existing Gitee tag ${VERSION} points to ${gitee_commit}, expected ${target_commit}; refusing to move it." >&2
exit 1
fi
if [ -z "$gitee_commit" ]; then
echo " Creating Gitee tag ${VERSION} -> ${target_commit}"
git push "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
else
echo " Gitee tag ${VERSION} already points to ${target_commit}."
fi
gitee_commit=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
gitee_commit="$(gitee_tag_commit || true)"
[ "$gitee_commit" = "$target_commit" ] && break
@@ -124,8 +138,10 @@ echo " Gitee release id = ${release_id}"
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
# id, so DELETE /attach_files/{id} was previously called with an empty id and
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
assets_map="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
| python3 -c 'import json,sys
load_assets_map() {
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
"${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" \
| python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
@@ -134,7 +150,13 @@ try:
if n and i!="":
print("%s\t%s\t%s" % (n, i, u))
except Exception:
pass' 2>/dev/null || true)"
sys.exit(1)'
}
assets_map="$(load_assets_map)" || {
echo "❌ Could not list Gitee release attachments; refusing a blind upload." >&2
exit 1
}
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
@@ -163,12 +185,13 @@ gitee_attach() { # upload file $1; success when the response carries a download
gitee_delete() { # delete attachment by id $1
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
>/dev/null
}
uploaded=0
replaced=0
skipped=0
failed=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
@@ -180,7 +203,12 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
if [ "$count" -eq 0 ]; then
echo " ⬆ ${fn} (new)"
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
if gitee_attach "$f"; then
uploaded=$((uploaded + 1))
else
echo " ❌ upload failed for ${fn}" >&2
failed=$((failed + 1))
fi
continue
fi
@@ -197,16 +225,58 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
fi
# Delete every copy, then upload exactly one fresh, correct file.
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
delete_failed=0
for aid in $ids; do
if ! gitee_delete "$aid"; then
echo " ❌ failed to delete stale Gitee attachment ${aid} for ${fn}" >&2
delete_failed=1
fi
done
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
if [ "$delete_failed" -ne 0 ]; then
failed=$((failed + 1))
continue
fi
if gitee_attach "$f"; then
replaced=$((replaced + 1))
else
echo " ❌ re-upload failed for ${fn}" >&2
failed=$((failed + 1))
fi
done
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
exit 1
fi
# Gitee may accept an upload before the attachment list is consistent. Re-read
# the release and require every supported asset to be unique and byte-identical.
verified=0
for verify_attempt in 1 2 3 4 5; do
final_map="$(load_assets_map || true)"
verify_failed=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
fn="$(basename "$f")"
rows="$(printf '%s\n' "$final_map" | awk -F'\t' -v n="$fn" '$1==n')"
count="$(printf '%s\n' "$rows" | grep -c . || true)"
if [ "$count" -ne 1 ]; then
verify_failed=1
continue
fi
aurl="$(printf '%s\n' "$rows" | awk -F'\t' 'NR==1 {print $3}')"
remote_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)"
[ "$remote_sha" = "$(sha256_of "$f")" ] || verify_failed=1
done
if [ "$verify_failed" -eq 0 ] && [ "$failed" -eq 0 ]; then
verified=1
break
fi
[ "$verify_attempt" -lt 5 ] && sleep 5
done
[ "$verified" -eq 1 ] || {
echo "❌ Gitee release ${VERSION} does not contain one byte-identical copy of every release asset." >&2
exit 1
}
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+133 -27
View File
@@ -2,19 +2,16 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Sync release artifacts to a China-accessible OSS mirror so that the install
# scripts' DWS_RELEASE_BASE switch can serve domestic users.
# Sync release artifacts to a China-accessible OSS mirror. Channel pointers are
# mirror metadata; repository installers currently resolve GitHub/Gitee and do
# not consume these OSS pointers directly.
#
# Mirror layout produced (matches install.sh RELEASE_BASE="<base>/<version>/<file>"):
# Mirror layout produced:
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-<os>-<arch>.tar.gz|.zip
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/checksums.txt
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-skills.zip
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (plain version string)
#
# So with the CDN/custom domain CNAME'd to the bucket, China users run:
# DWS_RELEASE_BASE=https://<domain>/$OSS_PREFIX/download \
# DWS_VERSION=<version> \
# curl -fsSL https://<domain>/$OSS_PREFIX/install.sh | sh
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (stable version only)
# oss://$OSS_BUCKET/$OSS_PREFIX/beta.txt (prerelease version only)
#
# Required environment (CI secrets):
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_ENDPOINT, OSS_BUCKET
@@ -28,6 +25,9 @@
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
DIST_DIR="${DIST_DIR:-dist}"
OSS_PREFIX="${OSS_PREFIX:-dws}"
@@ -38,6 +38,10 @@ for v in OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET OSS_ENDPOINT OSS_BUCKET; do
[ -z "$val" ] && missing="$missing $v"
done
if [ -n "$missing" ]; then
if [ "${DWS_REQUIRE_OSS:-0}" = "1" ]; then
echo "❌ OSS mirror sync is required but credentials are missing:${missing}" >&2
exit 1
fi
echo "ℹ️ OSS mirror sync skipped — missing:${missing}"
echo " Set these as repo secrets to auto-publish releases to the China mirror."
exit 0
@@ -45,20 +49,60 @@ fi
# ── Resolve version ──────────────────────────────────────────────────────────
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
release_validate_version_channel "$CHANNEL" "$VERSION"
echo "📦 Syncing release ${VERSION} → oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${VERSION}/"
# Never move a channel pointer to an incomplete or mixed-version directory,
# even when this helper is invoked outside the normal workflow.
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
# ── Ensure ossutil is available ───────────────────────────────────────────────
OSSUTIL="${OSSUTIL:-ossutil}"
OSSUTIL_INSTALL_DIR=""
if ! command -v "$OSSUTIL" >/dev/null 2>&1; then
echo "⬇ ossutil not found; installing to ./.ossutil ..."
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
echo "⬇ ossutil not found; installing a verified temporary copy ..."
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=mac ;;
*) printf 'unsupported ossutil operating system: %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
arch="$(uname -m)"
case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
curl -fsSL "https://gosspublic.alicdn.com/ossutil/v2/2.0.0/ossutil-2.0.0-${os}-${arch}.zip" -o /tmp/ossutil.zip
unzip -qo /tmp/ossutil.zip -d /tmp/ossutil-extract
found="$(find /tmp/ossutil-extract -name ossutil -type f | head -1)"
mkdir -p ./.ossutil && cp "$found" ./.ossutil/ossutil && chmod +x ./.ossutil/ossutil
OSSUTIL="./.ossutil/ossutil"
case "${os}-${arch}" in
linux-amd64) expected_ossutil_sha256=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a ;;
linux-arm64) expected_ossutil_sha256=f6c95ba0c2d2ef30290af686ce4d706c701f4734ce8090bee4288a77e3f1d764 ;;
mac-amd64) expected_ossutil_sha256=8437fdd3ef1a3eb12310f61fcf1c00a5bff5cdab47b4fea815527472e7cf896c ;;
mac-arm64) expected_ossutil_sha256=058fd048f321f8c80def8b748030531646eefe3a82837bf16b581ba7d9c84ac7 ;;
*) printf 'unsupported ossutil architecture: %s-%s\n' "$os" "$arch" >&2; exit 1 ;;
esac
ossutil_archive="$(mktemp "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX.zip")"
ossutil_extract="$(mktemp -d "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX")"
env -u OSS_ACCESS_KEY_ID -u OSS_ACCESS_KEY_SECRET \
curl -fsSL \
"https://gosspublic.alicdn.com/ossutil/v2/2.3.0/ossutil-2.3.0-${os}-${arch}.zip" \
-o "$ossutil_archive"
if command -v sha256sum >/dev/null 2>&1; then
actual_ossutil_sha256="$(sha256sum "$ossutil_archive" | awk '{print $1}')"
else
actual_ossutil_sha256="$(shasum -a 256 "$ossutil_archive" | awk '{print $1}')"
fi
[ "$actual_ossutil_sha256" = "$expected_ossutil_sha256" ] || {
rm -rf "$ossutil_archive" "$ossutil_extract"
printf 'ossutil archive checksum mismatch for %s-%s\n' "$os" "$arch" >&2
exit 1
}
unzip -qo "$ossutil_archive" -d "$ossutil_extract"
found="$(find "$ossutil_extract" -name ossutil -type f | head -1)"
[ -n "$found" ] || {
rm -rf "$ossutil_archive" "$ossutil_extract"
printf 'verified ossutil archive does not contain the executable\n' >&2
exit 1
}
OSSUTIL_INSTALL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-ossutil.XXXXXX")"
cp "$found" "$OSSUTIL_INSTALL_DIR/ossutil" && chmod +x "$OSSUTIL_INSTALL_DIR/ossutil"
rm -rf "$ossutil_archive" "$ossutil_extract"
OSSUTIL="$OSSUTIL_INSTALL_DIR/ossutil"
fi
oss_cp() {
@@ -70,8 +114,59 @@ oss_cp() {
"$1" "oss://${OSS_BUCKET}/$2"
}
oss_get() {
# oss_get <oss-key> <local-file>
"$OSSUTIL" cp -f \
--access-key-id "$OSS_ACCESS_KEY_ID" \
--access-key-secret "$OSS_ACCESS_KEY_SECRET" \
--endpoint "$OSS_ENDPOINT" \
"oss://${OSS_BUCKET}/$1" "$2"
}
base="${OSS_PREFIX}/download/${VERSION}"
if [ "$CHANNEL" = "stable" ]; then
pointer_name="latest.txt"
else
pointer_name="beta.txt"
fi
current_pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-current-pointer.XXXXXX")"
pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-release-pointer.XXXXXX")"
pointer_status_file="$(mktemp "${TMPDIR:-/tmp}/dws-pointer-status.XXXXXX")"
trap 'rm -f "$current_pointer_file" "$pointer_file" "$pointer_status_file"; [ -z "$OSSUTIL_INSTALL_DIR" ] || rm -rf "$OSSUTIL_INSTALL_DIR"' EXIT HUP INT TERM
rm -f "$current_pointer_file"
current_version=""
update_pointer=1
if oss_get "${OSS_PREFIX}/${pointer_name}" "$current_pointer_file" >"$pointer_status_file" 2>&1; then
[ -s "$current_pointer_file" ] || {
cat "$pointer_status_file" >&2
echo "❌ OSS ${pointer_name} was read successfully but is empty; refusing to publish." >&2
exit 1
}
current_version="$(tr -d '[:space:]' < "$current_pointer_file")"
release_validate_version_channel "$CHANNEL" "$current_version" || {
printf '❌ OSS %s contains an invalid %s channel version: %s\n' "$pointer_name" "$CHANNEL" "$current_version" >&2
exit 1
}
if [ "$current_version" != "$VERSION" ]; then
if release_version_is_greater "$VERSION" "$current_version"; then
:
elif release_version_is_greater "$current_version" "$VERSION"; then
update_pointer=0
echo "ℹ️ OSS ${pointer_name} already points to newer ${current_version}; assets will be repaired without moving it."
else
echo "❌ Cannot order OSS ${pointer_name}=${current_version} against ${VERSION}." >&2
exit 1
fi
fi
elif grep -Eq '(^|[^[:alnum:]])NoSuchKey([^[:alnum:]]|$)' "$pointer_status_file"; then
printf 'ℹ️ OSS %s does not exist yet; creating it.\n' "$pointer_name"
else
cat "$pointer_status_file" >&2
printf '❌ Could not read OSS %s; refusing to move the channel pointer.\n' "$pointer_name" >&2
exit 1
fi
# ── Upload binaries + checksums + skills ──────────────────────────────────────
uploaded=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
@@ -85,16 +180,27 @@ if [ "$uploaded" -eq 0 ]; then
exit 1
fi
# ── Publish the latest pointer (for DWS_VERSION=latest resolution on mirror) ──
echo "$VERSION" > /tmp/dws-latest.txt
oss_cp /tmp/dws-latest.txt "${OSS_PREFIX}/latest.txt"
# ── Publish a channel-specific pointer ───────────────────────────────────────
# A beta must never move latest.txt: that pointer is consumed by ordinary
# installs and is the stable channel contract.
pointer_summary="${pointer_name}"
if [ "$update_pointer" -eq 1 ]; then
echo "$VERSION" > "$pointer_file"
oss_cp "$pointer_file" "${OSS_PREFIX}/${pointer_name}"
else
pointer_summary="${pointer_name} unchanged at ${current_version}"
fi
# ── Publish the install scripts themselves (entry layer) ──────────────────────
for s in install.sh install.ps1 install-skills.sh; do
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
done
# Shared installer entrypoints are stable-only. A prerelease must not replace
# the stable entry layer, even though those scripts currently fetch from
# GitHub/Gitee rather than this asset mirror.
installer_summary=""
if [ "$CHANNEL" = "stable" ] && [ "$update_pointer" -eq 1 ]; then
for s in install.sh install.ps1 install-skills.sh; do
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
done
installer_summary=" + stable install scripts"
fi
echo "✅ Synced ${uploaded} artifact(s) + install scripts + latest.txt to the OSS mirror."
echo " China install:"
echo " DWS_RELEASE_BASE=https://<your-domain>/${OSS_PREFIX}/download DWS_VERSION=${VERSION} \\"
echo " curl -fsSL https://<your-domain>/${OSS_PREFIX}/install.sh | sh"
echo "✅ Synced ${uploaded} artifact(s) + ${pointer_summary}${installer_summary} to the OSS mirror."
echo " Mirror path: ${OSS_PREFIX}/download/${VERSION}/"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
printf 'usage: verify-delivered-stable.sh <stable-tag> <commit>\n' >&2
exit 2
}
release_is_stable_version "$TAG" || {
printf 'invalid stable delivery baseline: %s\n' "$TAG" >&2
exit 2
}
command -v curl >/dev/null 2>&1 || { printf 'curl is required to verify stable delivery\n' >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { printf 'python3 is required to verify stable delivery\n' >&2; exit 1; }
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
API_TOKEN="$(gh auth token 2>/dev/null || true)"
fi
if [ -z "$API_TOKEN" ]; then
API_TOKEN="${GITHUB_TOKEN:-}"
fi
github_get() {
if [ -n "$API_TOKEN" ]; then
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
-H "Authorization: Bearer $API_TOKEN" \
"https://api.github.com/$1" 2>/dev/null && return 0
fi
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"https://api.github.com/$1"
}
if git rev-parse --verify --quiet "refs/tags/$TAG^{commit}" >/dev/null; then
local_commit="$(git rev-parse "refs/tags/$TAG^{commit}")"
[ "$local_commit" = "$EXPECTED_COMMIT" ] || {
printf 'stable tag %s resolves to %s, expected %s\n' "$TAG" "$local_commit" "$EXPECTED_COMMIT" >&2
exit 1
}
fi
stable_state="$(
github_get "repos/$REPOSITORY/releases/tags/$TAG" \
| python3 -c 'import json,sys
r=json.load(sys.stdin)
print("%s\t%s\t%s" % (r.get("tag_name", ""), str(r.get("draft", "")).lower(), str(r.get("prerelease", "")).lower()))'
)" || {
printf 'could not query previous stable release %s in %s\n' "$TAG" "$REPOSITORY" >&2
exit 1
}
[ "$stable_state" = "$(printf '%s\tfalse\tfalse' "$TAG")" ] || {
printf '%s is not a public stable GitHub Release in %s (got: %s)\n' \
"$TAG" "$REPOSITORY" "$stable_state" >&2
exit 1
}
stable_runs="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100" \
| python3 -c 'import json,sys
for run in json.load(sys.stdin).get("workflow_runs", []):
print("%s\t%s\t%s" % (run.get("head_sha", ""), run.get("head_branch", ""), run.get("conclusion", "")))'
)" || {
printf 'could not query Release workflow delivery for %s in %s\n' "$TAG" "$REPOSITORY" >&2
exit 1
}
printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TAG" '
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
END { exit(found ? 0 : 1) }
' || {
printf 'Release workflow did not complete successfully for stable baseline %s at %s\n' \
"$TAG" "$EXPECTED_COMMIT" >&2
exit 1
}
printf 'Delivered stable baseline verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
TAG="${1:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
cat > "$tmp/expected" <<'EOF'
checksums.txt
dws-darwin-amd64.tar.gz
dws-darwin-arm64.tar.gz
dws-linux-amd64.tar.gz
dws-linux-arm64.tar.gz
dws-skills.zip
dws-windows-amd64.zip
dws-windows-arm64.zip
EOF
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/releases/tags/$TAG" \
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
if ! diff -u "$tmp/expected" "$tmp/actual"; then
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
exit 1
fi
printf 'GitHub Release asset set verified: %s\n' "$TAG"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
local_object="$(git rev-parse "refs/tags/$TAG")"
remote_ref="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/git/ref/tags/$TAG" \
--jq '[.object.type, .object.sha] | @tsv'
)"
[ "$remote_ref" = "$(printf 'tag\t%s' "$local_object")" ] || {
printf 'remote tag object for %s differs from the sealed annotated tag (local=%s remote=%s)\n' \
"$TAG" "$local_object" "$remote_ref" >&2
exit 1
}
remote_tag="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/git/tags/$local_object" \
--jq '[.tag, .object.type, .object.sha] | @tsv'
)"
[ "$remote_tag" = "$(printf '%s\tcommit\t%s' "$TAG" "$EXPECTED_COMMIT")" ] || {
printf 'remote annotated tag %s does not peel to expected commit %s (got: %s)\n' \
"$TAG" "$EXPECTED_COMMIT" "$remote_tag" >&2
exit 1
}
printf 'GitHub tag authority verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
+71 -24
View File
@@ -5,6 +5,10 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
FORMULA_PATH="$DIST_DIR/homebrew/dingtalk-workspace-cli-local.rb"
NPM_STAGE_DIR="$DIST_DIR/npm/dingtalk-workspace-cli"
RUN_NPM=1
RUN_BREW=1
EXPECTED_VERSION=""
VERIFY_SKILL_TARGETS=1
say() {
printf '%s\n' "$*"
@@ -23,6 +27,34 @@ need_file() {
[ -f "$1" ] || err "required file not found: $1"
}
usage() {
printf '%s\n' "usage: $0 [--npm-only|--brew-only] [--expected-version <vX.Y.Z>] [--skip-skill-targets]" >&2
}
mode_seen=0
while [ "$#" -gt 0 ]; do
case "$1" in
--npm-only)
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
RUN_NPM=1; RUN_BREW=0; mode_seen=1; shift
;;
--brew-only)
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
RUN_NPM=0; RUN_BREW=1; mode_seen=1; shift
;;
--expected-version)
[ "$#" -ge 2 ] || { usage; exit 2; }
EXPECTED_VERSION="${2#v}"
shift 2
;;
--skip-skill-targets) VERIFY_SKILL_TARGETS=0; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
[ -z "$EXPECTED_VERSION" ] || need_cmd strings
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_AGENT_PARENTS="
.claude
@@ -60,13 +92,11 @@ HOME_SKILL_TARGETS="
.hermes/skills/dws
"
cleanup() {
if command -v brew >/dev/null 2>&1; then
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
if [ -n "${BREW_TAP_NAME:-}" ]; then
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
fi
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew uninstall --force "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null 2>&1 || true
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
fi
rm -rf "$TMP_ROOT"
}
@@ -111,8 +141,18 @@ verify_npm() {
npm install -g "$tarball_path" >/dev/null
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
"$npm_prefix/bin/dws" --help >/dev/null
verify_skill_targets "$npm_home"
HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
if [ -n "$EXPECTED_VERSION" ]; then
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
need_file "$vendor_bin"
strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \
err "npm-installed binary does not embed expected version v$EXPECTED_VERSION"
EXPECTED_VERSION="$EXPECTED_VERSION" node -e '
const pkg = require(process.argv[1]);
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
fi
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm uninstall -g dingtalk-workspace-cli >/dev/null
@@ -123,6 +163,10 @@ verify_npm() {
verify_brew() {
need_cmd brew
need_file "$FORMULA_PATH"
if [ -n "$EXPECTED_VERSION" ]; then
grep -Fq " version \"$EXPECTED_VERSION\"" "$FORMULA_PATH" || \
err "Homebrew formula does not declare version $EXPECTED_VERSION"
fi
brew_home="$TMP_ROOT/brew-home"
mkdir -p "$brew_home"
@@ -130,36 +174,39 @@ verify_brew() {
BREW_TAP_NAME="local/dws-package-verify-$$"
say "==> verifying Homebrew formula install"
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null
tap_repo="$(
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --repository "$BREW_TAP_NAME"
)"
mkdir -p "$tap_repo/Formula"
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
sed "s|skill_home_override = \"\"|skill_home_override = \"$brew_home\"|" \
"$FORMULA_PATH" > "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
prefix="$(
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --prefix dingtalk-workspace-cli-local
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --prefix "$BREW_TAP_NAME/dingtalk-workspace-cli-local"
)"
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
"$prefix/bin/dws" --help >/dev/null
verify_skill_targets "$brew_home"
HOME="$brew_home" "$prefix/bin/dws" --help >/dev/null
if [ -n "$EXPECTED_VERSION" ]; then
strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \
err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION"
installed_version="$(HOMEBREW_NO_AUTO_UPDATE=1 brew list --versions "$BREW_TAP_NAME/dingtalk-workspace-cli-local" | awk '{ print $2 }')"
[ "$installed_version" = "$EXPECTED_VERSION" ] || \
err "Homebrew installed version $installed_version, expected $EXPECTED_VERSION"
fi
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$brew_home"
}
need_file "$DIST_DIR/dws-skills.zip"
verify_npm
verify_brew
[ "$RUN_NPM" -eq 0 ] || verify_npm
[ "$RUN_BREW" -eq 0 ] || verify_brew
say "Package-manager verification complete."
+107
View File
@@ -0,0 +1,107 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
[ -n "$VERSION" ] || { printf 'expected release version is required\n' >&2; exit 2; }
SEMVER="${VERSION#v}"
CHECKSUMS="$DIST_DIR/checksums.txt"
EXPECTED_PLATFORM_ASSETS="
dws-darwin-amd64.tar.gz
dws-darwin-arm64.tar.gz
dws-linux-amd64.tar.gz
dws-linux-arm64.tar.gz
dws-windows-amd64.zip
dws-windows-arm64.zip
"
EXPECTED_ASSETS="$EXPECTED_PLATFORM_ASSETS
dws-skills.zip
"
[ -f "$CHECKSUMS" ] || { printf 'missing checksums.txt in %s\n' "$DIST_DIR" >&2; exit 1; }
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-binary.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
# The public asset namespace is an exact set. GoReleaser may also leave local
# metadata files (artifacts.json/config.yaml/metadata.json), but the upload and
# npm staging globs never publish them.
printf '%s\nchecksums.txt\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-root"
for path in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$CHECKSUMS"; do
[ -f "$path" ] || continue
basename "$path"
done | LC_ALL=C sort > "$tmp/actual-root"
if ! diff -u "$tmp/expected-root" "$tmp/actual-root"; then
printf 'public release assets must contain exactly the supported files\n' >&2
exit 1
fi
checksum_format_ok=1
awk '
NF != 2 { bad = 1; next }
$1 !~ /^[0-9a-fA-F]{64}$/ { bad = 1; next }
{ print $2 }
END { if (bad) exit 1 }
' "$CHECKSUMS" > "$tmp/checksum-assets" || checksum_format_ok=0
[ "$checksum_format_ok" -eq 1 ] || {
printf 'checksums.txt must contain only SHA-256 and filename pairs\n' >&2
exit 1
}
skills_checksum_count="$(awk '$2 == "dws-skills.zip" { count++ } END { print count + 0 }' "$CHECKSUMS")"
[ "$skills_checksum_count" -eq 1 ] || {
printf 'checksums.txt must contain dws-skills.zip exactly once (found %s)\n' "$skills_checksum_count" >&2
exit 1
}
printf '%s\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-checksums"
LC_ALL=C sort "$tmp/checksum-assets" > "$tmp/actual-checksums"
if ! diff -u "$tmp/expected-checksums" "$tmp/actual-checksums"; then
printf 'checksums.txt must describe exactly the supported release assets\n' >&2
exit 1
fi
if command -v sha256sum >/dev/null 2>&1; then
(cd "$DIST_DIR" && sha256sum --check checksums.txt)
elif command -v shasum >/dev/null 2>&1; then
(cd "$DIST_DIR" && shasum -a 256 --check checksums.txt)
else
printf 'sha256sum or shasum is required\n' >&2
exit 1
fi
verify_binary_version() {
asset="$1"
extract_dir="$tmp/extract-${asset}"
mkdir -p "$extract_dir"
case "$asset" in
*.tar.gz)
tar -xzf "$DIST_DIR/$asset" -C "$extract_dir"
binary="$extract_dir/dws"
;;
*.zip)
unzip -q "$DIST_DIR/$asset" -d "$extract_dir"
binary="$extract_dir/dws.exe"
;;
*)
printf 'unsupported release archive: %s\n' "$asset" >&2
return 1
;;
esac
[ -f "$binary" ] || {
printf '%s does not contain the expected dws binary\n' "$asset" >&2
return 1
}
strings "$binary" | grep -Fqx "v$SEMVER" || {
printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2
return 1
}
}
for asset in $EXPECTED_PLATFORM_ASSETS; do
verify_binary_version "$asset"
done
printf 'Release artifacts verified for v%s.\n' "$SEMVER"
+219
View File
@@ -0,0 +1,219 @@
package mock_mcp_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
)
const mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
type recordedToolCall struct {
path string
method string
authorization string
jsonrpc string
tool string
arguments map[string]any
err error
}
// TestCLIHelperProcess runs the production CLI entrypoint with real os.Args.
// The parent test supplies only isolated temp directories, a loopback endpoint,
// and a synthetic token accepted by the local fake server.
func TestCLIHelperProcess(t *testing.T) {
if os.Getenv(mockMCPSmokeHelperEnv) != "1" {
return
}
marker := -1
for i, arg := range os.Args {
if arg == "--" {
marker = i
break
}
}
if marker < 0 {
fmt.Fprintln(os.Stderr, "Mock MCP smoke helper: missing -- argument marker")
os.Exit(2)
}
os.Args = append([]string{"dws"}, os.Args[marker+1:]...)
os.Exit(app.Execute())
}
func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) {
var requestsMu sync.Mutex
var requests []recordedToolCall
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := recordedToolCall{
path: r.URL.Path,
method: r.Method,
authorization: r.Header.Get("Authorization"),
}
var envelope struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
call.err = err
} else {
call.jsonrpc = envelope.JSONRPC
call.tool = envelope.Params.Name
call.arguments = envelope.Params.Arguments
if envelope.Method != "tools/call" {
call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method)
}
}
requestsMu.Lock()
requests = append(requests, call)
requestsMu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": envelope.ID,
"result": map[string]any{
"content": []map[string]any{{
"type": "text",
"text": `{"success":true,"result":[{"userId":"mock-user-1","name":"Local Mock"}]}`,
}},
},
})
}))
defer server.Close()
env := isolatedCLIEnv(t, map[string]string{
"DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact",
})
args := []string{
"--token", "ci-smoke-token",
"--format", "json",
"contact", "user", "get",
"--ids", "user-001,user-002",
}
stdout, stderr, err := runCLI(t, env, args...)
if err != nil {
t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout, stderr)
}
requestsMu.Lock()
recorded := append([]recordedToolCall(nil), requests...)
requestsMu.Unlock()
if len(recorded) != 1 {
t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded)
}
call := recorded[0]
if call.err != nil {
t.Fatal(call.err)
}
if call.path != "/mcp/contact" {
t.Fatalf("request path = %q, want /mcp/contact", call.path)
}
if call.method != http.MethodPost {
t.Fatalf("HTTP method = %q, want POST", call.method)
}
if call.authorization != "Bearer ci-smoke-token" {
t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization)
}
if call.jsonrpc != "2.0" {
t.Fatalf("jsonrpc = %q, want 2.0", call.jsonrpc)
}
if call.tool != "get_user_info_by_user_ids" {
t.Fatalf("tool = %q, want get_user_info_by_user_ids", call.tool)
}
wantArgs := map[string]any{"user_id_list": []any{"user-001", "user-002"}}
if !reflect.DeepEqual(call.arguments, wantArgs) {
t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs)
}
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
if payload["success"] != true {
t.Fatalf("CLI success = %#v, want true; payload=%#v", payload["success"], payload)
}
result, ok := payload["result"].([]any)
if !ok || len(result) != 1 {
t.Fatalf("CLI result = %#v, want one mock user", payload["result"])
}
user, _ := result[0].(map[string]any)
if user["userId"] != "mock-user-1" {
t.Fatalf("CLI userId = %#v, want mock-user-1; payload=%#v", user["userId"], payload)
}
}
func isolatedCLIEnv(t *testing.T, extra map[string]string) []string {
t.Helper()
root := t.TempDir()
controlled := map[string]string{
"HOME": root,
"USERPROFILE": root,
"DWS_CONFIG_DIR": filepath.Join(root, "config"),
"DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"),
"DWS_DISABLE_KEYCHAIN": "1",
"HTTP_PROXY": "http://127.0.0.1:1",
"HTTPS_PROXY": "http://127.0.0.1:1",
"http_proxy": "http://127.0.0.1:1",
"https_proxy": "http://127.0.0.1:1",
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
mockMCPSmokeHelperEnv: "1",
"DWS_ALLOW_HTTP_ENDPOINTS": "1",
"DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1",
}
for key, value := range extra {
controlled[key] = value
}
env := make([]string, 0, len(controlled)+8)
for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} {
if value := os.Getenv(key); value != "" {
env = append(env, key+"="+value)
}
}
for key, value := range controlled {
env = append(env, key+"="+value)
}
sort.Strings(env)
return env
}
func runCLI(t *testing.T, env []string, args ...string) (string, string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...)
cmd := exec.CommandContext(ctx, os.Args[0], processArgs...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if ctx.Err() != nil {
t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String())
}
return stdout.String(), stderr.String(), err
}
+57 -46
View File
@@ -95,7 +95,7 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
}
}
func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string {
func postGoreleaserEnv(t *testing.T, distDir, version, releaseBaseURL string) []string {
t.Helper()
binDir := t.TempDir()
@@ -106,7 +106,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="+version,
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
)
@@ -134,7 +134,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3")
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -161,6 +161,8 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
formulaText := string(formulaData)
for _, want := range []string{
"class DingtalkWorkspaceCliLocal < Formula",
"version \"1.2.3\"",
"skill_home_override = \"\"",
"resource \"skills\" do",
"DingTalk Workspace CLI",
} {
@@ -177,6 +179,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
releaseFormulaText := string(releaseFormulaData)
for _, want := range []string{
"class DingtalkWorkspaceCli < Formula",
"version \"1.2.3\"",
"https://downloads.example.com/dws/releases/v1.2.3/" + archiveName,
"https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip",
} {
@@ -219,13 +222,20 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
}
}
// Verify checksums.txt was updated to include skills zip
// Re-running post packaging must replace, not duplicate, the skills checksum.
cmd = exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("second post-goreleaser.sh error = %v\noutput:\n%s", err, output)
}
// Verify checksums.txt includes exactly one skills zip entry.
checksumsData, err := os.ReadFile(filepath.Join(distDir, "checksums.txt"))
if err != nil {
t.Fatalf("ReadFile(checksums.txt) error = %v", err)
}
if !strings.Contains(string(checksumsData), "dws-skills.zip") {
t.Fatalf("checksums.txt missing dws-skills.zip entry:\n%s", string(checksumsData))
if count := strings.Count(string(checksumsData), "dws-skills.zip"); count != 1 {
t.Fatalf("checksums.txt dws-skills.zip count = %d, want 1:\n%s", count, checksumsData)
}
}
@@ -253,7 +263,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
seedDistArtifacts(t, distDir, allArchives)
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v9.9.9")
cmd.Env = postGoreleaserEnv(t, distDir, "v9.9.9", "https://downloads.example.com/dws/releases/v9.9.9")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -267,7 +277,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
}
packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets")
for _, rel := range append(allArchives, "dws-skills.zip") {
for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") {
if _, err := os.Stat(filepath.Join(packageAssetsDir, rel)); err != nil {
t.Fatalf("npm asset missing %q: %v", rel, err)
}
@@ -330,7 +340,7 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v0.0.0")
cmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -392,38 +402,40 @@ func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
}
workflow := string(data)
build := strings.Index(workflow, "Build release artifacts without publishing")
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
upload := strings.Index(workflow, "Upload finalized signed assets to release")
if postProcess == -1 || upload == -1 || upload < postProcess {
t.Fatalf("finalized asset upload must run after post-goreleaser.sh")
preserve := strings.Index(workflow, "Preserve finalized distribution files")
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
publishJob := strings.Index(workflow, "publish-release:")
if build == -1 || postProcess == -1 || preserve == -1 || verifyJob == -1 || publishJob == -1 ||
!(build < postProcess && postProcess < preserve && preserve < verifyJob && verifyJob < publishJob) {
t.Fatalf("post-processed assets must be preserved, Apple-verified, and only then published")
}
if !strings.Contains(workflow[upload:], "./scripts/release/finalize-github-release.sh") {
t.Fatal("release workflow must delegate atomic finalization to finalize-github-release.sh")
}
finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
finalizeData, err := os.ReadFile(finalizePath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", finalizePath, err)
}
finalize := string(finalizeData)
buildSection := workflow[build:verifyJob]
for _, required := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"--skip=publish",
"actions/upload-artifact@v4",
"finalized-release-dist",
} {
if !strings.Contains(buildSection, required) {
t.Errorf("signed build stage is missing %q", required)
}
}
publishSection := workflow[publishJob:]
for _, required := range []string{
"actions/download-artifact@v4",
"dist/dws-*.tar.gz",
"dist/dws-windows-*.zip",
"checksums.txt",
"dws-skills.zip",
"gh release upload",
"gh release view",
"--clobber",
"release asset digest mismatch",
"verify-release-artifacts.sh",
} {
if !strings.Contains(finalize, required) {
t.Errorf("finalized asset upload is missing %q", required)
if !strings.Contains(publishSection, required) {
t.Errorf("immutable publication stage is missing %q", required)
}
}
}
@@ -442,7 +454,7 @@ func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) {
workflow := string(data)
prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate")
goReleaser := strings.Index(workflow, "Run GoReleaser")
goReleaser := strings.Index(workflow, "Build release artifacts without publishing")
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
cleanup := strings.Index(workflow, "Remove Apple Developer ID certificate")
if prepare == -1 || goReleaser == -1 || postProcess == -1 || cleanup == -1 ||
@@ -533,36 +545,35 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
}
workflow := string(data)
upload := strings.Index(workflow, "Upload finalized signed assets to release")
preserve := strings.Index(workflow, "Preserve finalized distribution files")
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
publishJob := strings.Index(workflow, "publish-release:")
if upload == -1 || verifyJob == -1 || publishJob == -1 || !(upload < verifyJob && verifyJob < publishJob) {
t.Fatal("finalized Draft assets must be uploaded, Apple-verified, and only then published")
if preserve == -1 || verifyJob == -1 || publishJob == -1 || !(preserve < verifyJob && verifyJob < publishJob) {
t.Fatal("finalized artifacts must be preserved, Apple-verified, and only then published")
}
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false`)
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`)
if codesign == -1 || publish == -1 {
t.Fatal("macOS codesign verification and explicit Draft publication are required")
}
buildSection := workflow[upload:verifyJob]
buildSection := workflow[preserve:verifyJob]
for _, required := range []string{
`DWS_PUBLISH_RELEASE: "false"`,
"actions/upload-artifact@v4",
"finalized-release-dist",
} {
if !strings.Contains(buildSection, required) {
t.Errorf("Draft build stage is missing %q", required)
t.Errorf("signed build stage is missing %q", required)
}
}
verifySection := workflow[verifyJob:publishJob]
for _, required := range []string{
"runs-on: macos-latest",
"gh release download",
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"actions/download-artifact@v4",
"finalized-release-dist",
`dws-darwin-${arch}.tar.gz`,
"codesign --verify --strict --verbose=4",
} {
if !strings.Contains(verifySection, required) {
@@ -574,9 +585,9 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
for _, required := range []string{
"verify-darwin-signatures",
"actions/download-artifact@v4",
"Publish verified Draft release",
"Publish stable to npm",
"Publish prerelease to npm beta",
"Publish or reuse immutable GitHub Release",
"gh release upload",
"Publish missing version to npm channel",
} {
if !strings.Contains(publishSection, required) {
t.Errorf("post-verification publication stage is missing %q", required)
+301
View File
@@ -0,0 +1,301 @@
package scripts_test
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
type releaseEntryFixture struct {
repo *releaseTestRepo
entry string
log string
}
func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
t.Helper()
r := newReleaseTestRepo(t)
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
entry := filepath.Join(r.root, "scripts", "release", "dws-release.sh")
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, filepath.Join(sourceRoot, "scripts", "release", "dws-release.sh"), entry, 0o755)
fakeDelegate := func(name string) []byte {
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + "' >> \"$DWS_RELEASE_CALL_LOG\"\nfor arg in \"$@\"; do printf '\\t%s' \"$arg\" >> \"$DWS_RELEASE_CALL_LOG\"; done\nprintf '\\n' >> \"$DWS_RELEASE_CALL_LOG\"\nexit \"${DWS_RELEASE_FAKE_EXIT:-0}\"\n")
}
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
r.commitAndPush(t, "install unified release entry fixture")
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
}
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
cmd.Dir = f.repo.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_CALL_LOG="+f.log)
cmd.Env = append(cmd.Env, extraEnv...)
output, err := cmd.CombinedOutput()
return string(output), err
}
func (f *releaseEntryFixture) configureRemote(t *testing.T, remote string) {
t.Helper()
output, err := f.run(t, nil, "config", "--remote", remote)
if err != nil {
t.Fatalf("config remote error = %v\noutput:\n%s", err, output)
}
}
func (f *releaseEntryFixture) callLog(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(f.log)
if errors.Is(err, os.ErrNotExist) {
return ""
}
if err != nil {
t.Fatalf("ReadFile(call log) error = %v", err)
}
return string(data)
}
func TestDWSReleaseEntryPreparesMissingChangelogAndStops(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "prerelease",
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
want: "prepare\tprerelease\tv1.0.1-beta.1\n",
},
{
name: "stable",
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
want: "prepare\tstable\tv1.0.1\t--from-beta\tv1.0.1-beta.1\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
output, err := f.run(t, nil, test.args...)
if err != nil {
t.Fatalf("dws-release prepare error = %v\noutput:\n%s", err, output)
}
if got := f.callLog(t); got != test.want {
t.Fatalf("delegate calls = %q, want %q", got, test.want)
}
if !strings.Contains(output, "publishing intentionally stopped") {
t.Fatalf("prepare output did not make the stop boundary clear:\n%s", output)
}
})
}
}
func TestDWSReleaseEntryRoutesOneGuardedCommand(t *testing.T) {
tests := []struct {
name string
section string
args []string
wantCall string
}{
{
name: "check prerelease",
section: betaSection(),
args: []string{"v1.0.1-beta.1", "--remote", "origin"},
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n",
},
{
name: "publish prerelease keeps confirmation",
section: betaSection(),
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\t--publish\n",
},
{
name: "check stable",
section: stableSection(),
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\n",
},
{
name: "publish stable keeps confirmation",
section: stableSection(),
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin", "--publish"},
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\t--publish\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(test.section)), 0o644)
f.repo.commitAndPush(t, "add candidate changelog")
output, err := f.run(t, nil, test.args...)
if err != nil {
t.Fatalf("dws-release route error = %v\noutput:\n%s", err, output)
}
if got := f.callLog(t); got != test.wantCall {
t.Fatalf("delegate calls = %q, want %q\noutput:\n%s", got, test.wantCall, output)
}
})
}
}
func TestDWSReleaseEntryRejectsInvalidInvocationBeforeDelegation(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "invalid version", args: []string{"1.0.1"}, want: "invalid release version"},
{name: "stable missing beta", args: []string{"v1.0.1"}, want: "requires --from-beta"},
{name: "prerelease with beta", args: []string{"v1.0.1-beta.1", "--from-beta", "v1.0.1-beta.1"}, want: "only valid for stable"},
{name: "conflicting mode", args: []string{"v1.0.1-beta.1", "--check", "--publish"}, want: "cannot be used together"},
{name: "yes bypass", args: []string{"v1.0.1-beta.1", "--publish", "--yes"}, want: "unknown argument"},
{name: "unknown flag", args: []string{"v1.0.1-beta.1", "--force"}, want: "unknown argument"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f := newReleaseEntryFixture(t)
output, err := f.run(t, nil, test.args...)
if err == nil || !strings.Contains(output, test.want) {
t.Fatalf("invalid invocation: err=%v, want=%q\noutput:\n%s", err, test.want, output)
}
if got := f.callLog(t); got != "" {
t.Fatalf("invalid invocation delegated work: %q", got)
}
})
}
}
func TestDWSReleaseEntryUsesConfiguredRemote(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
f.repo.commitAndPush(t, "add beta changelog")
output, err := f.run(t, nil, "v1.0.1-beta.1")
if err != nil {
t.Fatalf("configured remote route error = %v\noutput:\n%s", err, output)
}
want := "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n"
if got := f.callLog(t); got != want {
t.Fatalf("delegate calls = %q, want %q", got, want)
}
}
func TestDWSReleaseEntryFailsClosedWithoutRemote(t *testing.T) {
f := newReleaseEntryFixture(t)
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
f.repo.commitAndPush(t, "add beta changelog")
output, err := f.run(t, nil, "v1.0.1-beta.1")
if err == nil || !strings.Contains(output, "release remote is not configured") {
t.Fatalf("missing remote did not fail closed: err=%v\noutput:\n%s", err, output)
}
if got := f.callLog(t); got != "" {
t.Fatalf("missing remote delegated work: %q", got)
}
}
func TestDWSReleaseEntryPropagatesDelegateFailure(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
f.repo.commitAndPush(t, "add beta changelog")
output, err := f.run(t, []string{"DWS_RELEASE_FAKE_EXIT=23"}, "v1.0.1-beta.1", "--remote", "origin")
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
t.Fatalf("delegate error was not propagated: err=%v\noutput:\n%s", err, output)
}
}
func TestDWSReleaseEntryFastForwardsCleanMain(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
other := filepath.Join(t.TempDir(), "other")
mustRun(t, filepath.Dir(other), "git", "clone", "-b", "main", f.repo.remote, other)
mustRun(t, other, "git", "config", "user.name", "Release Test")
mustRun(t, other, "git", "config", "user.email", "release-test@example.com")
mustWriteFile(t, filepath.Join(other, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
mustRun(t, other, "git", "add", "CHANGELOG.md")
mustRun(t, other, "git", "commit", "-m", "add remote beta changelog")
mustRun(t, other, "git", "push", "origin", "main")
output, err := f.run(t, nil, "v1.0.1-beta.1", "--remote", "origin")
if err != nil {
t.Fatalf("fast-forward route error = %v\noutput:\n%s", err, output)
}
localHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "HEAD"))
remoteHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "refs/remotes/origin/main"))
if localHead != remoteHead {
t.Fatalf("local main was not fast-forwarded: local=%s remote=%s", localHead, remoteHead)
}
if got := f.callLog(t); !strings.HasPrefix(got, "release\tprerelease\tv1.0.1-beta.1") {
t.Fatalf("fast-forward did not reach guarded delegate: %q", got)
}
}
func TestDWSReleaseEntryRejectsRetargetedRemote(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
f.repo.commitAndPush(t, "add beta changelog")
otherRemote := filepath.Join(t.TempDir(), "other.git")
mustRun(t, f.repo.root, "git", "init", "--bare", otherRemote)
mustRun(t, f.repo.root, "git", "remote", "set-url", "origin", otherRemote)
output, err := f.run(t, nil, "v1.0.1-beta.1")
if err == nil || !strings.Contains(output, "changed repository identity") {
t.Fatalf("retargeted remote was not rejected: err=%v\noutput:\n%s", err, output)
}
if got := f.callLog(t); got != "" {
t.Fatalf("retargeted remote delegated work: %q", got)
}
}
func TestDWSReleaseEntryRejectsAuthorityOverrides(t *testing.T) {
tests := []struct {
name string
env string
want string
}{
{name: "non github remote", env: "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1", want: "test-only"},
{name: "official tags URL", env: "DWS_RELEASE_OFFICIAL_TAGS_URL=https://example.com/tags.git", want: "cannot override"},
{name: "official repository", env: "DWS_RELEASE_OFFICIAL_REPOSITORY=other/repo", want: "cannot override"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f := newReleaseEntryFixture(t)
output, err := f.run(t, []string{test.env}, "v1.0.1-beta.1")
if err == nil || !strings.Contains(output, test.want) {
t.Fatalf("authority override was not rejected: err=%v, want=%q\noutput:\n%s", err, test.want, output)
}
if got := f.callLog(t); got != "" {
t.Fatalf("authority override delegated work: %q", got)
}
})
}
}
func TestReleaseCommandRejectsNonInteractivePublishWithoutYes(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "install release automation")
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish",
)
if err == nil || !strings.Contains(output, "interactive confirmation is unavailable") {
t.Fatalf("non-interactive publish did not require confirmation: err=%v\noutput:\n%s", err, output)
}
if got := mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1"); got != "" {
t.Fatalf("rejected non-interactive publish created a tag: %s", got)
}
}
+960
View File
@@ -0,0 +1,960 @@
package scripts_test
import (
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
var releasePlatformAssets = []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
}
func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) {
t.Helper()
stage := t.TempDir()
binary := "dws"
if strings.HasSuffix(asset, ".zip") {
binary = "dws.exe"
}
mustWriteFile(t, filepath.Join(stage, binary), []byte("fake release binary\n"+version+"\n"), 0o755)
if strings.HasSuffix(asset, ".zip") {
mustRun(t, stage, "zip", "-q", filepath.Join(dist, asset), binary)
return
}
mustRun(t, stage, "tar", "-czf", filepath.Join(dist, asset), binary)
}
func writeReleaseChecksums(t *testing.T, dist string, includeSkills bool) {
t.Helper()
assets := append([]string{}, releasePlatformAssets...)
if includeSkills {
assets = append(assets, "dws-skills.zip")
}
sort.Strings(assets)
var lines []string
for _, asset := range assets {
data, err := os.ReadFile(filepath.Join(dist, asset))
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", asset, err)
}
lines = append(lines, fmt.Sprintf("%x %s", sha256.Sum256(data), asset))
}
mustWriteFile(t, filepath.Join(dist, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
}
func seedVersionedReleaseArtifacts(t *testing.T, dist, version string) {
t.Helper()
if err := os.MkdirAll(dist, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", dist, err)
}
for _, asset := range releasePlatformAssets {
writeVersionedReleaseArchive(t, dist, asset, version)
}
mustWriteFile(t, filepath.Join(dist, "dws-skills.zip"), []byte("fake skills\n"), 0o644)
writeReleaseChecksums(t, dist, true)
}
type releaseTestRepo struct {
root string
remote string
contract string
prepare string
releaseCmd string
lib string
verify string
}
func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
t.Helper()
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
base := t.TempDir()
root := filepath.Join(base, "work")
remote := filepath.Join(base, "remote.git")
mustRun(t, base, "git", "init", "--bare", remote)
mustRun(t, base, "git", "init", "-b", "main", root)
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustWriteFile(t, filepath.Join(root, "CHANGELOG.md"), []byte(releaseChangelog()), 0o644)
mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("initial\n"), 0o644)
mustRun(t, root, "git", "add", ".")
mustRun(t, root, "git", "commit", "-m", "initial stable")
mustRun(t, root, "git", "tag", "-a", "v1.0.0", "-m", "Release v1.0.0")
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "main")
mustRun(t, root, "git", "push", "origin", "v1.0.0")
return &releaseTestRepo{
root: root,
remote: remote,
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
}
}
func TestReleaseVersionOrdering(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
lib := filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh")
tests := []struct {
candidate string
baseline string
greater bool
}{
{candidate: "v1.0.2", baseline: "v1.0.1", greater: true},
{candidate: "v1.1.0-beta.1", baseline: "v1.0.9-beta.9", greater: true},
{candidate: "v1.0.1-beta.2", baseline: "v1.0.1-beta.1", greater: true},
{candidate: "v1.0.1", baseline: "v1.0.1-beta.9", greater: true},
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1-beta.2", greater: false},
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1", greater: false},
{candidate: "v1.0.1", baseline: "v1.0.1", greater: false},
}
for _, test := range tests {
cmd := exec.Command("sh", "-c", `. "$1"; release_version_is_greater "$2" "$3"`, "sh", lib, test.candidate, test.baseline)
err := cmd.Run()
if (err == nil) != test.greater {
t.Fatalf("release_version_is_greater(%s, %s) error = %v, want greater=%v", test.candidate, test.baseline, err, test.greater)
}
}
}
func TestReleaseNpmPackingIgnoresLifecycleScripts(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
packageDir := filepath.Join(t.TempDir(), "package")
marker := filepath.Join(t.TempDir(), "prepack-ran")
manifest := fmt.Sprintf(`{
"name": "dingtalk-workspace-cli",
"version": "1.2.3",
"scripts": {"prepack": "touch %s"},
"files": ["README.md"]
}
`, marker)
mustWriteFile(t, filepath.Join(packageDir, "package.json"), []byte(manifest), 0o644)
mustWriteFile(t, filepath.Join(packageDir, "README.md"), []byte("test package\n"), 0o644)
outputTarball := filepath.Join(t.TempDir(), "package.tgz")
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "pack-npm-package.sh"), packageDir, outputTarball)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("pack-npm-package error = %v\noutput:\n%s", err, output)
}
if !strings.HasPrefix(strings.TrimSpace(string(output)), "sha512-") {
t.Fatalf("pack output is not an integrity value: %s", output)
}
if _, err := os.Stat(outputTarball); err != nil {
t.Fatalf("packed tarball missing: %v", err)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("npm prepack lifecycle unexpectedly ran: %v", err)
}
}
func TestReleaseNpmStagingRejectsUnexpectedLifecycleScripts(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
root := t.TempDir()
source := filepath.Join(root, "source")
dist := filepath.Join(root, "dist")
mustWriteFile(t, filepath.Join(source, "build", "npm", "install.js"), []byte("\n"), 0o644)
mustWriteFile(t, filepath.Join(source, "build", "npm", "bin", "dws.js"), []byte("\n"), 0o755)
mustWriteFile(t, filepath.Join(source, "build", "npm", "README.md"), []byte("test\n"), 0o644)
mustWriteFile(t, filepath.Join(source, "build", "npm", "package.json.tmpl"), []byte(`{
"name": "dingtalk-workspace-cli",
"version": "__VERSION__",
"bin": {"dws": "./bin/dws.js"},
"scripts": {"postinstall": "node install.js", "prepublishOnly": "node steal.js"}
}
`), 0o644)
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "stage-npm-package.sh"), "v1.2.3")
cmd.Env = append(os.Environ(), "DWS_PACKAGE_SOURCE_ROOT="+source, "DWS_PACKAGE_DIST_DIR="+dist)
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), "unexpected npm lifecycle scripts") {
t.Fatalf("unexpected lifecycle script was not rejected: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
exact := strings.Join([]string{
"dws-windows-arm64.zip",
"dws-linux-amd64.tar.gz",
"checksums.txt",
"dws-skills.zip",
"dws-darwin-amd64.tar.gz",
"dws-windows-amd64.zip",
"dws-linux-arm64.tar.gz",
"dws-darwin-arm64.tar.gz",
}, "\n")
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
run := func(assets string) (string, error) {
cmd := exec.Command("sh", script, "v1.2.3")
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GITHUB_REPOSITORY=owner/repo",
"GH_ASSETS=" + assets,
}
output, err := cmd.CombinedOutput()
return string(output), err
}
if output, err := run(exact); err != nil {
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
}
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
}
}
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
r := newReleaseTestRepo(t)
commit := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
binDir := t.TempDir()
fakeCurl := filepath.Join(binDir, "curl")
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
set -eu
for arg in "$@"; do last="$arg"; done
case "$last" in
*/releases/tags/*)
printf '{"tag_name":"v1.0.0","draft":false,"prerelease":false}\n'
;;
*/actions/workflows/release.yml/runs*)
printf '{"workflow_runs":[{"head_sha":"%s","head_branch":"v1.0.0","conclusion":"%s"}]}\n' "$EXPECTED_COMMIT" "$RUN_CONCLUSION"
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-delivered-stable.sh")
run := func(conclusion string) (string, error) {
cmd := exec.Command("sh", script, "v1.0.0", commit)
cmd.Dir = r.root
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
"EXPECTED_COMMIT=" + commit,
"RUN_CONCLUSION=" + conclusion,
}
output, err := cmd.CombinedOutput()
return string(output), err
}
if output, err := run("success"); err != nil {
t.Fatalf("delivered stable was rejected: %v\n%s", err, output)
}
if output, err := run("failure"); err == nil || !strings.Contains(output, "did not complete successfully") {
t.Fatalf("orphan stable tag was not rejected: err=%v\n%s", err, output)
}
}
func releaseChangelog(sections ...string) string {
text := "# Changelog\n\n## [Unreleased]\n\n"
for _, section := range sections {
text += section
if !strings.HasSuffix(text, "\n\n") {
text += "\n"
}
}
text += "## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"
return text
}
func betaSection() string {
return "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate the sealed beta candidate.\n\n"
}
func stableSection() string {
return "## [1.0.1] - 2026-07-11\n\nThis release promotes the sealed `v1.0.1-beta.1` contents to stable.\n\n### Changed\n\n- Publish the validated candidate to the stable channel.\n\n"
}
func (r *releaseTestRepo) commitAndPush(t *testing.T, message string) {
t.Helper()
mustRun(t, r.root, "git", "add", ".")
mustRun(t, r.root, "git", "commit", "-m", message)
mustRun(t, r.root, "git", "push", "origin", "main")
}
func (r *releaseTestRepo) seedBeta(t *testing.T) {
t.Helper()
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
mustWriteFile(t, filepath.Join(r.root, "feature.txt"), []byte("sealed candidate\n"), 0o644)
r.commitAndPush(t, "prepare beta")
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1", "-m", "Channel: prerelease")
mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1")
}
func runReleaseScript(t *testing.T, workdir, script string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command("sh", append([]string{script}, args...)...)
cmd.Dir = workdir
cmd.Env = append(os.Environ(), "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1")
if remote, err := exec.Command("git", "-C", workdir, "remote", "get-url", "origin").Output(); err == nil {
cmd.Env = append(cmd.Env, "DWS_RELEASE_OFFICIAL_TAGS_URL="+strings.TrimSpace(string(remote)))
}
output, err := cmd.CombinedOutput()
return string(output), err
}
func TestReleaseContractAcceptsPrereleaseAndWritesNotes(t *testing.T) {
r := newReleaseTestRepo(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "prepare beta changelog")
notes := filepath.Join(t.TempDir(), "notes.md")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--context", "local",
"--remote", "origin",
"--notes-output", notes,
)
if err != nil {
t.Fatalf("release contract error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "Release contract passed") {
t.Fatalf("contract output missing success:\n%s", output)
}
notesData, err := os.ReadFile(notes)
if err != nil {
t.Fatalf("ReadFile(notes) error = %v", err)
}
if !strings.Contains(string(notesData), "Validate the sealed beta candidate") {
t.Fatalf("notes did not come from exact changelog section:\n%s", notesData)
}
}
func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) {
r := newReleaseTestRepo(t)
tests := []struct {
channel string
version string
}{
{channel: "prerelease", version: "v1.0.1"},
{channel: "stable", version: "v1.0.1-beta.1"},
{channel: "prerelease", version: "v1.0.1-rc.1"},
{channel: "prerelease", version: "v1.0.1-preview"},
{channel: "stable", version: "1.0.1"},
{channel: "stable", version: "v01.0.1"},
{channel: "prerelease", version: "v1.0.1-beta.0"},
{channel: "prerelease", version: "v1.0.1-beta.2"},
}
for _, test := range tests {
t.Run(test.channel+"_"+strings.ReplaceAll(test.version, ".", "_"), func(t *testing.T) {
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", test.channel,
"--version", test.version,
)
if err == nil {
t.Fatalf("invalid pair unexpectedly passed:\n%s", output)
}
})
}
}
func TestReleaseContractRejectsBadChangelogSections(t *testing.T) {
tests := []struct {
name string
sections string
want string
}{
{name: "missing", sections: "", want: "exactly one section"},
{name: "empty", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n", want: "must contain release notes"},
{name: "placeholder", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- TODO: write this.\n\n", want: "TODO/TBD"},
{name: "duplicate", sections: betaSection() + betaSection(), want: "exactly one section"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := newReleaseTestRepo(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(test.sections)), 0o644)
mustWriteFile(t, filepath.Join(r.root, "candidate.txt"), []byte(test.name+"\n"), 0o644)
r.commitAndPush(t, "write candidate changelog")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil {
t.Fatalf("bad changelog unexpectedly passed:\n%s", output)
}
if !strings.Contains(output, test.want) {
t.Fatalf("output missing %q:\n%s", test.want, output)
}
})
}
}
func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) {
r := newReleaseTestRepo(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "prepare beta")
mustWriteFile(t, filepath.Join(r.root, "dirty.txt"), []byte("dirty\n"), 0o644)
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "worktree must be clean") {
t.Fatalf("dirty worktree was not blocked: err=%v\noutput:\n%s", err, output)
}
mustRun(t, r.root, "git", "add", "dirty.txt")
mustRun(t, r.root, "git", "commit", "-m", "local commit not pushed")
output, err = runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "must exactly match origin/main") {
t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
t.Run("sealed", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err != nil {
t.Fatalf("sealed stable promotion error = %v\noutput:\n%s", err, output)
}
})
t.Run("source drift", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
mustWriteFile(t, filepath.Join(r.root, "drift.txt"), []byte("untested change\n"), 0o644)
r.commitAndPush(t, "drift after beta")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil {
t.Fatalf("drifted stable promotion unexpectedly passed:\n%s", output)
}
if !strings.Contains(output, "only CHANGELOG.md may differ") || !strings.Contains(output, "drift.txt") {
t.Fatalf("drift output is not actionable:\n%s", output)
}
})
}
func TestReleaseContractCIReadsStableBaselineFromAnnotatedTag(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare stable changelog")
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1", "-m", "Channel: stable", "-m", "From-Beta: v1.0.1-beta.1")
mustRun(t, r.root, "git", "push", "origin", "v1.0.1")
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1")
metadata := filepath.Join(t.TempDir(), "metadata")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--context", "ci",
"--remote", "origin",
"--metadata-output", metadata,
)
if err != nil {
t.Fatalf("CI stable contract error = %v\noutput:\n%s", err, output)
}
metadataData, err := os.ReadFile(metadata)
if err != nil {
t.Fatalf("ReadFile(metadata) error = %v", err)
}
if !strings.Contains(string(metadataData), "from_beta=v1.0.1-beta.1") {
t.Fatalf("metadata missing annotated tag baseline:\n%s", metadataData)
}
}
func TestReleaseContractCIRejectsLightweightTag(t *testing.T) {
r := newReleaseTestRepo(t)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "prepare beta")
mustRun(t, r.root, "git", "tag", "v1.0.1-beta.1")
mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--context", "ci",
"--remote", "origin",
)
if err == nil {
t.Fatalf("lightweight release tag unexpectedly passed:\n%s", output)
}
if !strings.Contains(output, "must be annotated") {
t.Fatalf("output missing annotated-tag guidance:\n%s", output)
}
}
func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustWriteFile(t, filepath.Join(r.root, "after-seal.txt"), []byte("main advanced\n"), 0o644)
r.commitAndPush(t, "advance main after beta seal")
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1-beta.1")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--context", "ci",
"--remote", "origin",
)
if err != nil {
t.Fatalf("sealed tag should remain valid after main advances: %v\noutput:\n%s", err, output)
}
}
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
r.commitAndPush(t, "install changelog preparation")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
}
changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
}
r.commitAndPush(t, "commit unfinished release notes")
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
}
}
func TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644)
r.commitAndPush(t, "add unreleased changelog note")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
}
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
content := string(data)
positions := []int{
strings.Index(content, "## [Unreleased]"),
strings.Index(content, "- Keep this unreleased note."),
strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"),
strings.Index(content, "## [1.0.0] - 2026-07-01"),
}
for index, position := range positions {
if position < 0 {
t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content)
}
}
for index := 1; index < len(positions); index++ {
if positions[index-1] >= positions[index] {
t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content)
}
}
}
func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
for _, test := range []struct {
name string
version string
channel string
want string
doNotWant string
installer bool
}{
{name: "prerelease", version: "v1.2.3-beta.1", channel: "prerelease", want: "/beta.txt", doNotWant: "/latest.txt", installer: false},
{name: "stable", version: "v1.2.3", channel: "stable", want: "/latest.txt", doNotWant: "/beta.txt", installer: true},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
dist := filepath.Join(root, "dist")
seedVersionedReleaseArtifacts(t, dist, test.version)
logPath := filepath.Join(root, "ossutil.log")
fakeOSSUtil := filepath.Join(root, "ossutil")
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) echo 'ErrorCode=NoSuchKey' >&2; exit 1 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
cmd := exec.Command("bash", script)
cmd.Dir = sourceRoot
cmd.Env = append(os.Environ(),
"DIST_DIR="+dist,
"VERSION="+test.version,
"DWS_RELEASE_CHANNEL="+test.channel,
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,
"OSSUTIL_LOG="+logPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("sync-to-oss error = %v\noutput:\n%s", err, output)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(ossutil log) error = %v", err)
}
if !strings.Contains(string(logData), test.want) {
t.Fatalf("mirror log missing %q:\n%s", test.want, logData)
}
if strings.Contains(string(logData), test.doNotWant) {
t.Fatalf("mirror log unexpectedly contains %q:\n%s", test.doNotWant, logData)
}
hasInstaller := strings.Contains(string(logData), "/dws/install.sh")
if hasInstaller != test.installer {
t.Fatalf("installer upload = %v, want %v:\n%s", hasInstaller, test.installer, logData)
}
})
}
}
func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
for _, test := range []struct {
name string
readAction string
want string
}{
{name: "transport error", readAction: "echo 'connection timed out' >&2; exit 7", want: "Could not read OSS beta.txt"},
{name: "empty pointer", readAction: ": > \"$last\"; exit 0", want: "read successfully but is empty"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
dist := filepath.Join(root, "dist")
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
logPath := filepath.Join(root, "ossutil.log")
fakeOSSUtil := filepath.Join(root, "ossutil")
scriptText := "#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) " + test.readAction + " ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"
mustWriteFile(t, fakeOSSUtil, []byte(scriptText), 0o755)
cmd := exec.Command("bash", script)
cmd.Dir = sourceRoot
cmd.Env = append(os.Environ(),
"DIST_DIR="+dist,
"VERSION=v1.2.3-beta.1",
"DWS_RELEASE_CHANNEL=prerelease",
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,
"OSSUTIL_LOG="+logPath,
)
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), test.want) {
t.Fatalf("pointer failure was not closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
}
if logData, readErr := os.ReadFile(logPath); readErr == nil && strings.Contains(string(logData), "/beta.txt") {
t.Fatalf("failed pointer read still published beta pointer:\n%s", logData)
}
})
}
}
func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
root := t.TempDir()
dist := filepath.Join(root, "dist")
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
logPath := filepath.Join(root, "ossutil.log")
fakeOSSUtil := filepath.Join(root, "ossutil")
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) printf 'v1.2.4-beta.1\\n' > \"$last\"; exit 0 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh"))
cmd.Dir = sourceRoot
cmd.Env = append(os.Environ(),
"DIST_DIR="+dist,
"VERSION=v1.2.3-beta.1",
"DWS_RELEASE_CHANNEL=prerelease",
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,
"OSSUTIL_LOG="+logPath,
)
output, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(output), "assets will be repaired without moving it") {
t.Fatalf("historical OSS repair failed: err=%v\noutput:\n%s", err, output)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(ossutil log) error = %v", err)
}
if strings.Contains(string(logData), "/beta.txt") {
t.Fatalf("historical repair moved beta pointer:\n%s", logData)
}
if !strings.Contains(string(logData), "/download/v1.2.3-beta.1/") {
t.Fatalf("historical repair did not upload target assets:\n%s", logData)
}
}
func TestReleaseRequiredMirrorsFailWithoutCredentials(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
for _, test := range []struct {
name string
script string
require string
want string
}{
{name: "oss", script: "sync-to-oss.sh", require: "DWS_REQUIRE_OSS=1", want: "OSS mirror sync is required"},
{name: "gitee", script: "sync-to-gitee.sh", require: "DWS_REQUIRE_GITEE=1", want: "Gitee mirror sync is enabled"},
} {
t.Run(test.name, func(t *testing.T) {
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", test.script))
cmd.Dir = sourceRoot
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), test.require}
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), test.want) {
t.Fatalf("required mirror did not fail closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
}
})
}
}
func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) {
r := newReleaseTestRepo(t)
dist := t.TempDir()
seedVersionedReleaseArtifacts(t, dist, "v1.2.3")
cmd := exec.Command("sh", r.verify, "v1.2.3")
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("artifact verification error = %v\noutput:\n%s", err, output)
}
writeReleaseChecksums(t, dist, false)
cmd = exec.Command("sh", r.verify, "v1.2.3")
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), "dws-skills.zip exactly once") {
t.Fatalf("missing checksum was not blocked: err=%v\noutput:\n%s", err, output)
}
writeReleaseChecksums(t, dist, true)
mustWriteFile(t, filepath.Join(dist, "dws-linux-riscv64.tar.gz"), []byte("unexpected\n"), 0o644)
cmd = exec.Command("sh", r.verify, "v1.2.3")
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "public release assets") {
t.Fatalf("extra public archive was not rejected: err=%v\noutput:\n%s", err, output)
}
if err := os.Remove(filepath.Join(dist, "dws-linux-riscv64.tar.gz")); err != nil {
t.Fatalf("Remove(extra archive) error = %v", err)
}
writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.2")
writeReleaseChecksums(t, dist, true)
cmd = exec.Command("sh", r.verify, "v1.2.3")
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary") {
t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "install release automation")
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
)
if err != nil {
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "No tag was created") {
t.Fatalf("validation output missing dry-run result:\n%s", output)
}
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
t.Fatal("validation-only release created a tag")
}
output, err = runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
)
if err != nil {
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
}
if got := strings.TrimSpace(mustOutput(t, r.root, "git", "cat-file", "-t", "v1.0.1-beta.1")); got != "tag" {
t.Fatalf("release tag type = %q, want annotated tag", got)
}
if got := mustOutput(t, r.root, "git", "ls-remote", "--tags", "origin", "refs/tags/v1.0.1-beta.1"); !strings.Contains(got, "refs/tags/v1.0.1-beta.1") {
t.Fatalf("remote release tag missing:\n%s", got)
}
}
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "install release automation")
hook := filepath.Join(r.remote, "hooks", "pre-receive")
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
)
if err == nil {
t.Fatalf("rejected tag push unexpectedly passed:\n%s", output)
}
if !strings.Contains(output, "new local tag was removed") {
t.Fatalf("push failure output missing cleanup result:\n%s", output)
}
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
t.Fatal("failed push left the local release tag behind")
}
}
func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
r.commitAndPush(t, "install release automation")
otherRemote := filepath.Join(t.TempDir(), "other.git")
mustRun(t, r.root, "git", "init", "--bare", otherRemote)
mustRun(t, r.root, "git", "remote", "set-url", "--push", "origin", otherRemote)
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
)
if err == nil || !strings.Contains(output, "fetch and push URLs target different repositories") {
t.Fatalf("split release authority was not rejected: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
r := newReleaseTestRepo(t)
installReleaseCommandFixture(t, r)
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
r.commitAndPush(t, "install advancing release fixture")
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
"prerelease", "v1.0.2-beta.1", "--remote", "origin",
)
if err != nil {
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
!strings.Contains(output, "--stable-ref v1.0.1") {
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
}
}
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
t.Helper()
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
}
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
}
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
t.Helper()
data, err := os.ReadFile(source)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", source, err)
}
mustWriteFile(t, target, data, mode)
}
+212
View File
@@ -0,0 +1,212 @@
package smoke_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/spf13/cobra"
)
const cliSmokeHelperEnv = "DWS_CLI_SMOKE_HELPER"
// TestCLIHelperProcess executes the real app entrypoint in a subprocess. Product
// routing inspects os.Args, so using Cobra.SetArgs would not exercise the same
// path as the dws binary.
func TestCLIHelperProcess(t *testing.T) {
if os.Getenv(cliSmokeHelperEnv) != "1" {
return
}
marker := -1
for i, arg := range os.Args {
if arg == "--" {
marker = i
break
}
}
if marker < 0 {
fmt.Fprintln(os.Stderr, "CLI smoke helper: missing -- argument marker")
os.Exit(2)
}
os.Args = append([]string{"dws"}, os.Args[marker+1:]...)
os.Exit(app.Execute())
}
func TestCLISmoke_AllPublicCommandsSupportHelp(t *testing.T) {
_ = isolatedCLIEnv(t)
root := app.NewRootCommand()
root.InitDefaultHelpCmd()
paths := publicCommandPaths(root)
if len(paths) == 0 {
t.Fatal("public command traversal returned no commands")
}
for _, path := range paths {
path := path
name := "root"
if len(path) > 0 {
name = strings.Join(path, "/")
}
t.Run(name, func(t *testing.T) {
args := append(append([]string(nil), path...), "--help")
// Help never executes a product handler, so SetArgs is sufficient here.
// Real product dispatch is covered below through app.Execute in a
// subprocess because that path intentionally inspects os.Args.
cmd := app.NewRootCommand()
var output bytes.Buffer
cmd.SetOut(&output)
cmd.SetErr(&output)
cmd.SetArgs(args)
err := cmd.Execute()
if err != nil {
t.Fatalf("dws %s failed: %v\noutput:\n%s", strings.Join(args, " "), err, output.String())
}
if strings.TrimSpace(output.String()) == "" {
t.Fatalf("dws %s returned empty help", strings.Join(args, " "))
}
})
}
t.Logf("validated --help for %d public Cobra command paths", len(paths))
}
func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) {
env := isolatedCLIEnv(t)
tests := []struct {
name string
args []string
wantTool string
}{
{
name: "contact search",
args: []string{"--mock", "--format", "json", "contact", "user", "search", "--query", "Ada"},
wantTool: "search_contact_by_key_word",
},
{
name: "calendar list",
args: []string{
"--mock", "--format", "json", "calendar", "event", "list",
"--start", "2026-07-10T09:00:00+08:00",
"--end", "2026-07-10T10:00:00+08:00",
},
wantTool: "list_calendar_events",
},
{
name: "ding list",
args: []string{"--mock", "--format", "json", "ding", "message", "list", "--type", "UNREAD"},
wantTool: "list_ding_messages",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
stdout, stderr, err := runCLI(t, env, tc.args...)
if err != nil {
t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr)
}
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("dws %s returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr)
}
if payload["_mock"] != true {
t.Fatalf("dws %s _mock = %#v, want true; payload=%#v", strings.Join(tc.args, " "), payload["_mock"], payload)
}
if payload["_tool"] != tc.wantTool {
t.Fatalf("dws %s _tool = %#v, want %q; payload=%#v", strings.Join(tc.args, " "), payload["_tool"], tc.wantTool, payload)
}
})
}
}
// publicCommandPaths follows only canonical, user-visible Cobra commands.
// Hidden commands and commands marked Deprecated by Cobra are intentionally
// excluded; aliases are not separate command nodes. Parents and leaves are both
// checked, while positional placeholders in Use are never synthesized.
func publicCommandPaths(root *cobra.Command) [][]string {
var paths [][]string
var walk func(*cobra.Command, []string)
walk = func(cmd *cobra.Command, path []string) {
if cmd != root && (cmd.Hidden || cmd.Deprecated != "") {
return
}
paths = append(paths, append([]string(nil), path...))
for _, child := range cmd.Commands() {
walk(child, append(path, child.Name()))
}
}
walk(root, nil)
sort.Slice(paths, func(i, j int) bool {
return strings.Join(paths[i], " ") < strings.Join(paths[j], " ")
})
return paths
}
func isolatedCLIEnv(t *testing.T) []string {
t.Helper()
root := t.TempDir()
controlled := map[string]string{
"HOME": root,
"USERPROFILE": root,
"DWS_CONFIG_DIR": filepath.Join(root, "config"),
"DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"),
"DWS_DISABLE_KEYCHAIN": "1",
"HTTP_PROXY": "http://127.0.0.1:1",
"HTTPS_PROXY": "http://127.0.0.1:1",
"http_proxy": "http://127.0.0.1:1",
"https_proxy": "http://127.0.0.1:1",
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
cliSmokeHelperEnv: "1",
"DWS_ALLOW_HTTP_ENDPOINTS": "1",
"DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1",
}
for key, value := range controlled {
t.Setenv(key, value)
}
env := make([]string, 0, len(controlled)+8)
for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} {
if value := os.Getenv(key); value != "" {
env = append(env, key+"="+value)
}
}
for key, value := range controlled {
env = append(env, key+"="+value)
}
sort.Strings(env)
return env
}
func runCLI(t *testing.T, env []string, args ...string) (string, string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...)
cmd := exec.CommandContext(ctx, os.Args[0], processArgs...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if ctx.Err() != nil {
t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String())
}
return stdout.String(), stderr.String(), err
}