Compare commits

..
Author SHA1 Message Date
修雨 8eda235f62 fix: tolerate incomplete Gitee releases 2026-07-13 09:37:55 +08:00
73 changed files with 1077 additions and 8542 deletions
-6
View File
@@ -1,6 +0,0 @@
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'
+28 -200
View File
@@ -7,7 +7,8 @@ on:
pull_request:
permissions:
contents: read
contents: write
pull-requests: write
jobs:
lint:
@@ -58,57 +59,7 @@ jobs:
run: make build
- name: Test with Race Detection
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
test-darwin:
name: Test (macOS auth/keychain)
runs-on: macos-latest
timeout-minutes: 15
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: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
test-windows:
name: Test (Windows)
runs-on: windows-latest
timeout-minutes: 15
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: Build Windows CLI
run: go build -o dws.exe ./cmd
- name: Test Windows auth and DPAPI paths
run: |
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
coverage:
name: Coverage
@@ -145,6 +96,30 @@ 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
@@ -183,158 +158,11 @@ 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: [ci-gate]
needs: [test, policy, edition-tests]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |
+16 -7
View File
@@ -1,5 +1,4 @@
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
@@ -11,6 +10,8 @@ on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
@@ -22,14 +23,13 @@ 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 main history
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
@@ -41,7 +41,15 @@ jobs:
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
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'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
@@ -73,6 +81,7 @@ 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
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+71
View File
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,50 @@
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 }}
@@ -0,0 +1,54 @@
name: Verify Gitee release
on:
schedule:
- cron: "30 2 * * *"
workflow_dispatch:
inputs:
version:
description: "Release tag to verify; defaults to the latest stable GitHub release"
required: false
type: string
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Resolve release version
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -eu
version="$INPUT_VERSION"
if [ -z "$version" ]; then
version="$(gh release view --repo "${{ github.repository }}" --json tagName --jq .tagName)"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Download GitHub release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ steps.release.outputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
- name: Verify matching Gitee release
run: ./scripts/release/verify-gitee-release.sh
env:
VERSION: ${{ steps.release.outputs.version }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
+8 -5
View File
@@ -1,14 +1,19 @@
# GoReleaser configuration for dws
# Docs: https://goreleaser.com
#
# 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 release:
# git tag -a v0.1.0 -m "Release v0.1.0"
# git push origin v0.1.0
#
# To test locally (no publish):
# goreleaser release --snapshot --clean
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: dws
@@ -62,9 +67,7 @@ release:
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
name: dingtalk-workspace-cli
# Keep the release private until post-processing has replaced the Darwin
# archives and verified every finalized asset digest.
draft: true
draft: false
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
-12
View File
@@ -6,18 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Safe macOS Keychain → file-DEK migration** — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
### Fixed
- **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. 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.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
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.
+7 -39
View File
@@ -1,9 +1,6 @@
GO ?= go
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
.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
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -14,11 +11,8 @@ 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\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 package - Build all release artifacts locally (goreleaser snapshot)\n"
@printf " make release - Build and publish a release via goreleaser\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
@@ -44,8 +38,8 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
package:
@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
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
publish-homebrew-formula:
@./scripts/release/publish-homebrew-formula.sh
@@ -53,32 +47,6 @@ 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:
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
@exit 2
goreleaser release --clean
@./scripts/release/post-goreleaser.sh
-10
View File
@@ -258,16 +258,6 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
</details>
<details>
-10
View File
@@ -255,16 +255,6 @@ dws --profile <名称|corpId> contact user search --query "..." # 单次对指
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
</details>
<details>
+16 -19
View File
@@ -2,7 +2,6 @@ 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__
@@ -40,25 +39,23 @@ __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(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")),
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
-195
View File
@@ -1,195 +0,0 @@
// 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
@@ -1,130 +0,0 @@
// 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
@@ -1,113 +0,0 @@
# 发布手册(预发 / 正式)
发布只走一条链路:本地脚本负责封板、验证并推送 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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+2 -70
View File
@@ -82,7 +82,6 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
newAuthMigrateKeychainCommand(),
newAuthExportCommand(),
newAuthImportCommand(),
newAuthExchangeCommand(),
@@ -284,7 +283,6 @@ var (
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
loginRecommendProductSelector = selectLoginRecommendProducts
authLoginInteractiveTerminal = isInteractiveTerminal
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
)
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
@@ -523,65 +521,6 @@ func newAuthStatusCommand() *cobra.Command {
return cmd
}
func newAuthMigrateKeychainCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "migrate-keychain",
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
先用 --dry-run 预检,确认后加 --yes 执行。`,
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := cmd.Flags().GetString("to")
if err != nil {
return apperrors.NewInternal("failed to read --to")
}
if strings.TrimSpace(target) != "file-dek" {
return apperrors.NewValidation("--to 当前仅支持 file-dek")
}
if os.Getenv(keychain.DisableKeychainEnv) != "" {
return apperrors.NewValidation(fmt.Sprintf(
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
keychain.DisableKeychainEnv,
))
}
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
if !dryRun && !yes {
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
}
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
}
result := struct {
Success bool `json:"success"`
DryRun bool `json:"dry_run"`
Target string `json:"target"`
Entries int `json:"entries"`
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
}
if dryRun {
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
}
return nil
},
}
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
return cmd
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
return apperrors.NewValidation(err.Error())
@@ -657,7 +596,7 @@ func newAuthExportCommand() *cobra.Command {
}
if !authpkg.PortableExportSupported() {
return apperrors.NewValidation(fmt.Sprintf(
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
keychain.DisableKeychainEnv,
))
}
@@ -1290,18 +1229,11 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
if err == nil {
return nil
}
if keychain.IsCiphertextKeyMismatch(err) {
return &authStatusDiagnostic{
Reason: "ciphertext_key_mismatch",
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
}
}
if keychain.IsDEKMissing(err) {
return &authStatusDiagnostic{
Reason: "dek_missing",
Message: "本地登录密钥缺失,无法解密已保存的登录态",
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
}
}
if !keychain.IsUnavailable(err) {
-102
View File
@@ -19,7 +19,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -233,22 +232,6 @@ func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
if !strings.Contains(resp.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
}
if !strings.Contains(resp.Hint, "dws auth reset") {
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
}
}
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
if diagnostic == nil {
t.Fatal("authStatusDiagnosticFromError() = nil")
}
if diagnostic.Reason != "ciphertext_key_mismatch" {
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
}
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
}
}
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
@@ -352,91 +335,6 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
}
}
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "")
oldMigrate := migrateKeychainToFileDEK
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
calls := 0
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
calls++
if calls == 1 && !dryRun {
t.Fatal("first migration call should be dry-run")
}
if calls == 2 && dryRun {
t.Fatal("second migration call should execute")
}
return 4, nil
}
newRoot := func() (*cobra.Command, *bytes.Buffer) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(newAuthMigrateKeychainCommand())
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
return root, &out
}
root, out := newRoot()
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
}
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
t.Fatalf("dry-run output = %q", out.String())
}
root, out = newRoot()
root.SetArgs([]string{"migrate-keychain", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
}
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
t.Fatalf("migration output = %q", out.String())
}
if calls != 2 {
t.Fatalf("migration calls = %d, want 2", calls)
}
}
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
oldMigrate := migrateKeychainToFileDEK
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
t.Fatal("migration backend should not be called")
return 0, nil
}
newRoot := func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(newAuthMigrateKeychainCommand())
root.SetOut(io.Discard)
root.SetErr(io.Discard)
return root
}
t.Setenv(keychain.DisableKeychainEnv, "")
root := newRoot()
root.SetArgs([]string{"migrate-keychain"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
}
t.Setenv(keychain.DisableKeychainEnv, "1")
root = newRoot()
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
}
}
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
-4
View File
@@ -163,10 +163,6 @@ func (p *DeviceFlowProvider) resetCredentialState() {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
p.clientID = runtimeClientID
clientMu.Lock()
-28
View File
@@ -1,28 +0,0 @@
// 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 auth
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
// refresh and login cannot rewrite an entry while its DEK backend is changing.
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
var migrated int
err := withProfilesLock(configDir, func() error {
var err error
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
return err
})
return migrated, err
}
+1 -72
View File
@@ -15,14 +15,12 @@ package auth
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
var (
@@ -30,9 +28,6 @@ var (
migrationDone bool
)
// ErrTokenDataNotFound means the requested keychain slot does not exist.
var ErrTokenDataNotFound = errors.New("token data not found")
// SaveTokenDataKeychain saves TokenData to the platform keychain.
// This is the new secure storage method using random master key.
func SaveTokenDataKeychain(data *TokenData) error {
@@ -91,7 +86,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
return nil, fmt.Errorf("no token data in keychain account %q", account)
}
var data TokenData
@@ -101,72 +96,6 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
return &data, nil
}
// preflightTokenPersistence verifies that every registered token slot can be
// read before an OAuth login or exchange can target any profile.
// A missing slot is safe (first login or a legacy fallback); any other error
// stops the remote operation when existing ciphertext is already known to be
// unreadable and therefore unsafe to update.
func preflightTokenPersistence(configDir string) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
}
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return fmt.Errorf("load token profiles: %w", err)
}
seen := make(map[string]struct{}, len(cfg.Profiles))
for _, profile := range cfg.Profiles {
corpID := strings.TrimSpace(profile.CorpID)
if corpID == "" {
continue
}
if _, ok := seen[corpID]; ok {
continue
}
seen[corpID] = struct{}{}
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf(
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
TokenAccountForCorpID(corpID), corpID, err,
)
}
}
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
return fmt.Errorf(
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
err,
)
}
return nil
}
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
// An unrelated broken profile must not prevent the current profile from using
// its still-valid credentials.
func preflightTokenRefreshPersistence(data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return nil
}
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
}
if data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
}
return nil
}
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
-5
View File
@@ -27,15 +27,10 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.exchangeCodeViaMCP(ctx, code)
-6
View File
@@ -110,9 +110,6 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
}
}
if err := preflightTokenPersistence(p.configDir); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
// Fall through: full browser OAuth flow.
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
@@ -626,9 +623,6 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if !data.IsRefreshTokenValid() {
return nil, fmt.Errorf("refresh_token 已过期")
}
if err := preflightTokenRefreshPersistence(data); err != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
}
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
+2 -9
View File
@@ -67,11 +67,7 @@ func PortableAuthTargetPopulated(configDir string) bool {
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
func PortableAuthSourceReady() bool {
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
return false
}
_, err := LoadTokenDataKeychain()
return err == nil
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
}
func portableAuthSourcePopulated(keychainDir string) bool {
@@ -97,7 +93,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
return fmt.Errorf("missing output writer")
}
if !PortableExportSupported() {
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
}
keychainDir := keychain.StorageDir(keychain.Service)
if _, err := os.Stat(keychainDir); err != nil {
@@ -106,9 +102,6 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
if !portableAuthSourcePopulated(keychainDir) {
return fmt.Errorf("auth token is not available for export; run dws auth login first")
}
if _, err := LoadTokenDataKeychain(); err != nil {
return fmt.Errorf("auth token cannot be decrypted with the portable file DEK: %w", err)
}
gz := gzip.NewWriter(w)
defer gz.Close()
+1 -4
View File
@@ -17,7 +17,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -188,7 +187,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if err == nil {
return data, nil
}
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
if strings.TrimSpace(profile) != "" {
return nil, err
}
// No explicit --profile: `selected` is the resolved current/primary
@@ -198,8 +197,6 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
return legacy, nil
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
return nil, lerr
}
return nil, err
}
-376
View File
@@ -1,376 +0,0 @@
// 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.
//go:build darwin
package auth
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
t.Helper()
t.Setenv(keychain.DisableKeychainEnv, "1")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
}
func setPreflightTestCredentials(t *testing.T) {
t.Helper()
SetClientID("preflight-client-id")
SetClientSecret("preflight-client-secret")
resetClientIDFromMCP()
t.Cleanup(func() {
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
})
}
func profileCiphertextPathForTest(corpID string) string {
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
}
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
}
if err := preflightTokenPersistence(configDir); err != nil {
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.AccessToken != data.AccessToken {
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
}
}
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err == nil {
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
}
if loaded != nil {
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
}
}
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
if err := preflightTokenPersistence(configDir); err != nil {
t.Fatalf("preflightTokenPersistence() error = %v", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
}
}
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
}
err := preflightTokenPersistence(configDir)
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
}
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
}
}
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
// Simulate interruption after the profile ciphertext rename but before
// profiles.json is updated by saveTokenDataLocked.
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
}
}
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
data := testToken("at_portable", "", "")
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if !PortableAuthSourceReady() {
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
}
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
}
if PortableAuthSourceReady() {
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
}
if bundle.Len() != 0 {
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
}
}
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
configDir := t.TempDir()
dataA := testToken("at_a", "corp_a", "A Org")
dataB := testToken("at_b", "corp_b", "B Org")
if err := SaveTokenData(configDir, dataA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, dataB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
}
if err := preflightTokenRefreshPersistence(dataB); err != nil {
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
}
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
if err != nil {
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
}
if loaded.AccessToken != dataB.AccessToken {
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
}
}
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
setPreflightTestCredentials(t)
for _, force := range []bool{false, true} {
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
provider := NewOAuthProvider(configDir, nil)
provider.NoBrowser = true
_, err := provider.Login(ctx, force)
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
}
})
}
}
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected HTTP request")
})}
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
}
if !keychain.IsCiphertextKeyMismatch(err) {
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls = %d, want 0", got)
}
}
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
cleanupKeychain(t)
setPreflightTestCredentials(t)
configDir := t.TempDir()
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
}))
defer server.Close()
provider := NewDeviceFlowProvider(configDir, nil)
provider.Output = io.Discard
provider.SetBaseURL(server.URL)
_, err := provider.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("device code requests = %d, want 0", got)
}
}
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
data.ExpiresAt = time.Now().Add(-time.Hour)
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
}
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return nil, errors.New("unexpected refresh request")
})}
_, err := provider.lockedRefresh(context.Background())
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("refresh HTTP calls = %d, want 0", got)
}
}
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
cleanupKeychain(t)
t.Setenv(keychain.DisableKeychainEnv, "1")
setPreflightTestCredentials(t)
configDir := t.TempDir()
var calls atomic.Int32
provider := NewOAuthProvider(configDir, nil)
provider.Output = io.Discard
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
)),
}, nil
})}
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
if err != nil {
t.Fatalf("ExchangeAuthCode() error = %v", err)
}
if data.AccessToken != "new-access" || data.UserID != "user-new" {
t.Fatalf("ExchangeAuthCode() data = %#v", data)
}
if got := calls.Load(); got != 1 {
t.Fatalf("HTTP calls = %d, want 1", got)
}
}
-441
View File
@@ -1,441 +0,0 @@
// 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
@@ -1,367 +0,0 @@
// 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
@@ -1,270 +0,0 @@
// 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
@@ -1,119 +0,0 @@
// 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)
}
}
-58
View File
@@ -1,58 +0,0 @@
// 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.
//go:build darwin || linux
package keychain
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func authTokenCiphertextPaths(service string) ([]string, error) {
dir := StorageDir(service)
dirEntries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read keychain storage: %w", err)
}
paths := make([]string, 0, len(dirEntries))
for _, dirEntry := range dirEntries {
if !isAuthTokenCiphertextFile(dirEntry.Name()) {
continue
}
info, err := dirEntry.Info()
if err != nil {
return nil, fmt.Errorf("inspect keychain entry %q: %w", dirEntry.Name(), err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("keychain entry %q is not a regular file", dirEntry.Name())
}
paths = append(paths, filepath.Join(dir, dirEntry.Name()))
}
sort.Strings(paths)
return paths, nil
}
func isAuthTokenCiphertextFile(name string) bool {
legacyName := safeFileName(AccountToken)
return name == legacyName ||
(strings.HasPrefix(name, strings.TrimSuffix(legacyName, ".enc")+"_") && strings.HasSuffix(name, ".enc"))
}
-22
View File
@@ -1,22 +0,0 @@
// 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.
//go:build windows
package keychain
// Windows stores token entries in the DPAPI-protected user registry rather
// than auth-token*.enc files, so there is no file inventory to validate.
func platformValidateAuthTokenEntries(string) error {
return nil
}
+4 -29
View File
@@ -42,16 +42,10 @@ const (
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
)
var (
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
// because a fresh key cannot decrypt existing ciphertext.
ErrDEKMissing = errors.New("dek missing")
// ErrCiphertextKeyMismatch means encrypted data exists but none of the
// available DEKs can decrypt it. Write paths must not overwrite the data.
ErrCiphertextKeyMismatch = errors.New("ciphertext key mismatch")
)
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
// because a fresh key cannot decrypt existing ciphertext.
var ErrDEKMissing = errors.New("dek missing")
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
type KeychainAccess interface {
@@ -114,10 +108,6 @@ func IsDEKMissing(err error) bool {
return errors.Is(err, ErrDEKMissing)
}
func IsCiphertextKeyMismatch(err error) bool {
return errors.Is(err, ErrCiphertextKeyMismatch)
}
func Diagnose() Diagnostic {
return platformDiagnose()
}
@@ -139,21 +129,6 @@ func Remove(service, account string) error {
return platformRemove(service, account)
}
// MigrateToFileDEK re-encrypts the legacy and profile-scoped auth token entries
// for service with the macOS file DEK. It is supported only on macOS and must
// be invoked from a process that can still access the system Keychain. When
// dryRun is true, all selected entries are validated without modifying data.
func MigrateToFileDEK(service string, dryRun bool) (int, error) {
return platformMigrateToFileDEK(service, dryRun)
}
// ValidateAuthTokenEntries verifies every persisted auth-token ciphertext,
// including profile slots not yet registered in profiles.json, without
// creating or rotating key material.
func ValidateAuthTokenEntries(service string) error {
return platformValidateAuthTokenEntries(service)
}
// Exists checks if an entry exists in the keychain.
func Exists(service, account string) bool {
val, err := Get(service, account)
+21 -101
View File
@@ -165,7 +165,19 @@ func platformDiagnose() Diagnostic {
}
}
func getSystemDEKReadOnly(service string) ([]byte, error) {
// getDEK retrieves or generates the Data Encryption Key.
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
// where Keychain APIs are blocked), falls back to a file-based DEK
// identical to the Linux scheme. See DisableKeychainEnv docs for the
// security tradeoff.
func getDEK(service string) ([]byte, error) {
return getOrCreateDEK(service)
}
func getDEKReadOnly(service string) ([]byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return fileDEKReadOnly(service)
}
if err := checkDefaultKeychainAvailable(); err != nil {
return nil, err
}
@@ -308,71 +320,6 @@ func decryptData(data []byte, key []byte) (string, error) {
return string(plaintext), nil
}
// decryptWithAvailableDEK decrypts an existing entry without creating or
// migrating key material. In normal macOS mode, entries previously written by
// the explicit file-DEK fallback remain readable so another local process
// cannot replace them with ciphertext encrypted by a different key.
func decryptWithAvailableDEK(service string, data []byte) (string, []byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
key, err := fileDEKReadOnly(service)
if err != nil {
return "", nil, err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", nil, fmt.Errorf("%w: file-DEK cannot decrypt existing entry", ErrCiphertextKeyMismatch)
}
return plaintext, key, nil
}
systemKey, systemKeyErr := getSystemDEKReadOnly(service)
if IsUnavailable(systemKeyErr) {
return "", nil, systemKeyErr
}
if systemKeyErr == nil {
if plaintext, err := decryptData(data, systemKey); err == nil {
return plaintext, systemKey, nil
}
}
fileKey, fileKeyErr := fileDEKReadOnly(service)
if fileKeyErr == nil {
if plaintext, err := decryptData(data, fileKey); err == nil {
return plaintext, fileKey, nil
}
}
if systemKeyErr != nil && fileKeyErr != nil {
return "", nil, systemKeyErr
}
return "", nil, fmt.Errorf("%w: available DEKs cannot decrypt existing entry", ErrCiphertextKeyMismatch)
}
// keyForNewEntry preserves the backend used by the canonical auth-token entry
// when a related account is added from a normal macOS process. This prevents
// profile-scoped token slots from mixing DEK backends without allowing an
// unrelated file-backed secret to downgrade a system-Keychain-backed login.
func keyForNewEntry(service, account string) ([]byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return getOrCreateDEK(service)
}
if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") {
return getOrCreateDEK(service)
}
anchorPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
anchor, err := os.ReadFile(anchorPath)
if err == nil {
_, key, decryptErr := decryptWithAvailableDEK(service, anchor)
return key, decryptErr
}
if !os.IsNotExist(err) {
return nil, err
}
return getOrCreateDEK(service)
}
func platformGet(service, account string) (string, error) {
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
@@ -381,7 +328,11 @@ func platformGet(service, account string) (string, error) {
}
return "", err
}
plaintext, _, err := decryptWithAvailableDEK(service, data)
key, err := getDEKReadOnly(service)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
@@ -389,26 +340,11 @@ func platformGet(service, account string) (string, error) {
}
func platformSet(service, account, data string) error {
dir := StorageDir(service)
targetPath := filepath.Join(dir, safeFileName(account))
var (
key []byte
err error
)
existing, readErr := os.ReadFile(targetPath)
switch {
case readErr == nil:
_, key, err = decryptWithAvailableDEK(service, existing)
case os.IsNotExist(readErr):
key, err = keyForNewEntry(service, account)
default:
return readErr
}
key, err := getOrCreateDEK(service)
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
@@ -417,6 +353,7 @@ func platformSet(service, account, data string) error {
return err
}
targetPath := filepath.Join(dir, safeFileName(account))
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
defer os.Remove(tmpPath)
@@ -431,23 +368,6 @@ func platformSet(service, account, data string) error {
return nil
}
func platformValidateAuthTokenEntries(service string) error {
paths, err := authTokenCiphertextPaths(service)
if err != nil {
return err
}
for _, path := range paths {
ciphertext, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
}
if _, _, err := decryptWithAvailableDEK(service, ciphertext); err != nil {
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
}
}
return nil
}
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
-214
View File
@@ -16,8 +16,6 @@
package keychain
import (
"bytes"
"encoding/base64"
"errors"
"os"
"path/filepath"
@@ -224,215 +222,3 @@ func TestDisableKeychainOverwrite(t *testing.T) {
t.Fatalf("Get() = %q, want %q", got, "overwritten")
}
}
func stubMacOSSystemDEK(t *testing.T, systemDEK []byte, getErr error) *int {
t.Helper()
defaultKeychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(defaultKeychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + defaultKeychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
if account != "dek" {
return "", errors.New("unexpected keyring account")
}
if getErr != nil {
return "", getErr
}
return base64.StdEncoding.EncodeToString(systemDEK), nil
}
setCalls := 0
keyringSet = func(service, account, value string) error {
setCalls++
return errors.New("unexpected keyring.Set")
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
return &setCalls
}
func TestSystemKeychainModePreservesFileDEKCredentials(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
setCalls := stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-dek-mode-switch"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-dek-token"); err != nil {
t.Fatalf("Set() in file-DEK mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() after switching to system Keychain mode error = %v", err)
}
if got != "file-dek-token" {
t.Fatalf("Get() after switching modes = %q, want file-dek-token", got)
}
if err := Set(service, account, "updated-token"); err != nil {
t.Fatalf("Set() after switching to system Keychain mode error = %v", err)
}
profileAccount := account + ":corp"
if err := Set(service, profileAccount, "profile-token"); err != nil {
t.Fatalf("Set() new profile account in system Keychain mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "1")
got, err = Get(service, account)
if err != nil {
t.Fatalf("Get() after switching back to file-DEK mode error = %v", err)
}
if got != "updated-token" {
t.Fatalf("Get() after switching back = %q, want updated-token", got)
}
got, err = Get(service, profileAccount)
if err != nil {
t.Fatalf("Get() new profile account in file-DEK mode error = %v", err)
}
if got != "profile-token" {
t.Fatalf("Get() new profile account = %q, want profile-token", got)
}
if *setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
}
}
func TestSystemKeychainModeFallsBackWhenSystemDEKIsMissing(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
setCalls := stubMacOSSystemDEK(t, nil, keyringpkg.ErrNotFound)
service := "test-missing-system-dek-fallback"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-dek-token"); err != nil {
t.Fatalf("Set() in file-DEK mode error = %v", err)
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() with missing system DEK error = %v", err)
}
if got != "file-dek-token" {
t.Fatalf("Get() with missing system DEK = %q, want file-dek-token", got)
}
if *setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
}
}
func TestFileDEKModeDoesNotOverwriteSystemKeychainCredentials(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-system-ciphertext-preserved"
account := "auth-token"
t.Setenv(DisableKeychainEnv, "")
if err := Set(service, account, "system-token"); err != nil {
t.Fatalf("Set() in system Keychain mode error = %v", err)
}
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
t.Fatalf("system Keychain Set created file DEK; stat error = %v", err)
}
targetPath := filepath.Join(StorageDir(service), safeFileName(account))
before, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext) error = %v", err)
}
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
if err := os.WriteFile(filepath.Join(StorageDir(service), "dek"), fileDEK, 0600); err != nil {
t.Fatalf("WriteFile(file DEK) error = %v", err)
}
t.Setenv(DisableKeychainEnv, "1")
if err := Set(service, account, "file-token"); !IsCiphertextKeyMismatch(err) {
t.Fatalf("Set() error = %v, want ciphertext key mismatch", err)
}
after, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
}
if !bytes.Equal(after, before) {
t.Fatal("rejected file-DEK Set overwrote system Keychain ciphertext")
}
t.Setenv(DisableKeychainEnv, "")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() in system Keychain mode error = %v", err)
}
if got != "system-token" {
t.Fatalf("Get() after rejected overwrite = %q, want system-token", got)
}
profileAccount := account + ":corp"
if err := Set(service, profileAccount, "system-profile-token"); err != nil {
t.Fatalf("Set() new profile beside system-backed root error = %v", err)
}
got, err = Get(service, profileAccount)
if err != nil || got != "system-profile-token" {
t.Fatalf("Get() new system-backed profile = %q, %v", got, err)
}
t.Setenv(DisableKeychainEnv, "1")
if _, err := Get(service, profileAccount); !IsCiphertextKeyMismatch(err) {
t.Fatalf("file-DEK Get() of system-backed profile error = %v, want ciphertext key mismatch", err)
}
}
func TestSetDoesNotOverwriteCiphertextWithUnknownDEK(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-unknown-ciphertext-key"
account := "auth-token"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
if err := os.WriteFile(filepath.Join(dir, "dek"), fileDEK, 0600); err != nil {
t.Fatalf("WriteFile(file DEK) error = %v", err)
}
unknownDEK := bytes.Repeat([]byte{0x33}, dekBytes)
ciphertext, err := encryptData("unknown-token", unknownDEK)
if err != nil {
t.Fatalf("encryptData() error = %v", err)
}
targetPath := filepath.Join(dir, safeFileName(account))
if err := os.WriteFile(targetPath, ciphertext, 0600); err != nil {
t.Fatalf("WriteFile(ciphertext) error = %v", err)
}
for _, disableKeychain := range []string{"", "1"} {
t.Setenv(DisableKeychainEnv, disableKeychain)
if err := Set(service, account, "replacement"); !IsCiphertextKeyMismatch(err) {
t.Fatalf("Set() with %s=%q error = %v, want ciphertext key mismatch", DisableKeychainEnv, disableKeychain, err)
}
after, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
}
if !bytes.Equal(after, ciphertext) {
t.Fatalf("rejected Set with %s=%q overwrote ciphertext", DisableKeychainEnv, disableKeychain)
}
}
}
-21
View File
@@ -157,27 +157,6 @@ func platformSet(service, account, data string) error {
return nil
}
func platformValidateAuthTokenEntries(service string) error {
paths, err := authTokenCiphertextPaths(service)
if err != nil || len(paths) == 0 {
return err
}
key, err := getDEKReadOnly(service)
if err != nil {
return fmt.Errorf("read DEK for auth token validation: %w", err)
}
for _, path := range paths {
ciphertext, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
}
if _, err := decryptData(ciphertext, key); err != nil {
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
}
}
return nil
}
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
-92
View File
@@ -1,92 +0,0 @@
// 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.
//go:build darwin
package keychain
import (
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
type fileDEKMigrationEntry struct {
path string
plaintext string
encrypted []byte
}
func platformMigrateToFileDEK(service string, dryRun bool) (int, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return 0, fmt.Errorf("file-DEK migration requires system Keychain mode; unset %s and retry", DisableKeychainEnv)
}
paths, err := authTokenCiphertextPaths(service)
if err != nil {
return 0, err
}
entries := make([]fileDEKMigrationEntry, 0, len(paths))
for _, path := range paths {
ciphertext, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
}
plaintext, _, err := decryptWithAvailableDEK(service, ciphertext)
if err != nil {
return 0, fmt.Errorf("validate keychain entry %q before migration: %w", filepath.Base(path), err)
}
entries = append(entries, fileDEKMigrationEntry{path: path, plaintext: plaintext})
}
if dryRun || len(entries) == 0 {
return len(entries), nil
}
fileKey, err := fileDEK(service)
if err != nil {
return 0, fmt.Errorf("prepare file DEK: %w", err)
}
for i := range entries {
entries[i].encrypted, err = encryptData(entries[i].plaintext, fileKey)
if err != nil {
return 0, fmt.Errorf("encrypt keychain entry %q: %w", filepath.Base(entries[i].path), err)
}
if _, err := decryptData(entries[i].encrypted, fileKey); err != nil {
return 0, fmt.Errorf("verify migrated keychain entry %q: %w", filepath.Base(entries[i].path), err)
}
}
tempPaths := make([]string, 0, len(entries))
defer func() {
for _, path := range tempPaths {
_ = os.Remove(path)
}
}()
for _, entry := range entries {
tmpPath := entry.path + "." + uuid.New().String() + ".migrate.tmp"
if err := os.WriteFile(tmpPath, entry.encrypted, 0600); err != nil {
return 0, fmt.Errorf("stage keychain entry %q: %w", filepath.Base(entry.path), err)
}
tempPaths = append(tempPaths, tmpPath)
}
for i, entry := range entries {
if err := os.Rename(tempPaths[i], entry.path); err != nil {
return 0, fmt.Errorf("commit keychain entry %q: %w; rerun the migration to finish", filepath.Base(entry.path), err)
}
tempPaths[i] = ""
}
return len(entries), nil
}
-177
View File
@@ -1,177 +0,0 @@
// 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.
//go:build darwin
package keychain
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMigrateToFileDEKReencryptsSystemEntries(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x42}, dekBytes), nil)
service := "test-migrate-system-to-file"
values := map[string]string{
AccountToken: "legacy-token",
AccountToken + ":corp-a": "profile-token",
}
if err := Set(service, "appsecret_demo", "app-secret"); err != nil {
t.Fatalf("Set(unrelated secret) error = %v", err)
}
unrelatedPath := filepath.Join(StorageDir(service), safeFileName("appsecret_demo"))
unrelatedCiphertext := []byte("unreadable unrelated ciphertext")
if err := os.WriteFile(unrelatedPath, unrelatedCiphertext, 0600); err != nil {
t.Fatalf("WriteFile(unrelated secret) error = %v", err)
}
for account, value := range values {
if err := Set(service, account, value); err != nil {
t.Fatalf("Set(%q) error = %v", account, err)
}
}
count, err := MigrateToFileDEK(service, true)
if err != nil {
t.Fatalf("MigrateToFileDEK(dry-run) error = %v", err)
}
if count != len(values) {
t.Fatalf("dry-run count = %d, want %d", count, len(values))
}
dekPath := filepath.Join(StorageDir(service), "dek")
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
t.Fatalf("dry-run created file DEK; stat error = %v", err)
}
count, err = MigrateToFileDEK(service, false)
if err != nil {
t.Fatalf("MigrateToFileDEK() error = %v", err)
}
if count != len(values) {
t.Fatalf("migration count = %d, want %d", count, len(values))
}
if info, err := os.Stat(dekPath); err != nil || info.Mode().Perm() != 0600 {
t.Fatalf("file DEK stat = %v, %v; want mode 0600", info, err)
}
for _, disableKeychain := range []string{"1", ""} {
t.Setenv(DisableKeychainEnv, disableKeychain)
for account, want := range values {
got, err := Get(service, account)
if err != nil || got != want {
t.Fatalf("Get(%q) with %s=%q = %q, %v; want %q", account, DisableKeychainEnv, disableKeychain, got, err, want)
}
}
}
if count, err := MigrateToFileDEK(service, true); err != nil || count != len(values) {
t.Fatalf("repeat dry-run = %d, %v; want %d auth entries", count, err, len(values))
}
if got, err := os.ReadFile(unrelatedPath); err != nil || !bytes.Equal(got, unrelatedCiphertext) {
t.Fatalf("unrelated secret after migration = %q, %v; want byte-for-byte preserved", got, err)
}
}
func TestMigrateToFileDEKKeepsNewNonAuthSecretsOnSystemKeychain(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
systemDEK := bytes.Repeat([]byte{0x52}, dekBytes)
stubMacOSSystemDEK(t, systemDEK, nil)
service := "test-migrate-secret-isolation"
if err := Set(service, AccountToken, "system-token"); err != nil {
t.Fatalf("Set(auth token) error = %v", err)
}
if _, err := MigrateToFileDEK(service, false); err != nil {
t.Fatalf("MigrateToFileDEK() error = %v", err)
}
nonAuthEntries := map[string]string{
"client-secret:demo": "client-secret",
"app-token:demo": "app-token",
"appsecret:demo": "stored-secret",
}
for account, value := range nonAuthEntries {
if err := Set(service, account, value); err != nil {
t.Fatalf("Set(%q) after migration error = %v", account, err)
}
ciphertext, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", account, err)
}
got, err := decryptData(ciphertext, systemDEK)
if err != nil || got != value {
t.Fatalf("system Keychain DEK decrypt %q = %q, %v; want %q", account, got, err, value)
}
}
t.Setenv(DisableKeychainEnv, "1")
if got, err := Get(service, AccountToken); err != nil || got != "system-token" {
t.Fatalf("file-DEK Get(auth token) = %q, %v; want migrated token", got, err)
}
for account := range nonAuthEntries {
if got, err := Get(service, account); !IsCiphertextKeyMismatch(err) {
t.Fatalf("file-DEK Get(%q) = %q, %v; want ciphertext key mismatch", account, got, err)
}
}
}
func TestMigrateToFileDEKAbortsBeforeWritingWhenAnyEntryIsUnreadable(t *testing.T) {
root := t.TempDir()
t.Setenv(StorageDirEnv, root)
t.Setenv(DisableKeychainEnv, "")
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x24}, dekBytes), nil)
service := "test-migrate-preflight"
if err := Set(service, AccountToken, "preserve-me"); err != nil {
t.Fatalf("Set() error = %v", err)
}
goodPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
goodBefore, err := os.ReadFile(goodPath)
if err != nil {
t.Fatalf("ReadFile(good entry) error = %v", err)
}
badPath := filepath.Join(StorageDir(service), safeFileName(AccountToken+":bad"))
if err := os.WriteFile(badPath, []byte("corrupt ciphertext"), 0600); err != nil {
t.Fatalf("WriteFile(bad entry) error = %v", err)
}
if _, err := MigrateToFileDEK(service, false); err == nil || !strings.Contains(err.Error(), "validate keychain entry") {
t.Fatalf("MigrateToFileDEK() error = %v, want preflight failure", err)
}
goodAfter, err := os.ReadFile(goodPath)
if err != nil {
t.Fatalf("ReadFile(good entry after failure) error = %v", err)
}
if !bytes.Equal(goodAfter, goodBefore) {
t.Fatal("failed migration modified a readable entry")
}
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
t.Fatalf("failed migration created file DEK; stat error = %v", err)
}
}
func TestMigrateToFileDEKRequiresSystemKeychainMode(t *testing.T) {
t.Setenv(StorageDirEnv, t.TempDir())
t.Setenv(DisableKeychainEnv, "1")
if _, err := MigrateToFileDEK("test-migrate-mode", true); err == nil || !strings.Contains(err.Error(), DisableKeychainEnv) {
t.Fatalf("MigrateToFileDEK() error = %v, want mode guidance", err)
}
}
@@ -1,22 +0,0 @@
// 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.
//go:build !darwin
package keychain
import "fmt"
func platformMigrateToFileDEK(_ string, _ bool) (int, error) {
return 0, fmt.Errorf("system Keychain to file-DEK migration is supported only on macOS")
}
+4 -6
View File
@@ -6,15 +6,13 @@ 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
- `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)
- `make package`: build all release artifacts locally via `goreleaser --snapshot`
- `make release`: build and publish a release via `goreleaser`
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-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`
- 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`
+36 -8
View File
@@ -50,6 +50,40 @@ gitee_api() {
return 1
}
gitee_release_has_assets() {
_version="$1"
shift
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
for _asset in "$@"; do
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
done
return 0
}
resolve_gitee_latest_version() {
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
_candidates="$(printf '%s' "$_tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -Vr)"
_newest="$(printf '%s\n' "$_candidates" | head -1)"
[ -n "$_newest" ] || return 1
for _candidate in $_candidates; do
if gitee_release_has_assets "$_candidate" "$@"; then
VERSION="$_candidate"
if [ "$_candidate" != "$_newest" ]; then
printf ' ⚠ Gitee tag %s has no complete release assets; using %s.\n' "$_newest" "$_candidate"
fi
return 0
fi
done
return 1
}
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
@@ -61,13 +95,7 @@ pick_source() {
resolve_version() {
if [ "$VERSION" = "latest" ]; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable, so
# resolve the newest vN.N.N tag from the git tags endpoint instead.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
resolve_gitee_latest_version "$@" || VERSION=""
else
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
@@ -210,7 +238,7 @@ install_skills_to_root() {
main() {
need_cmd curl
pick_source
resolve_version
resolve_version dws-skills.zip
printf '\n'
printf ' ┌──────────────────────────────────────┐\n'
+39 -10
View File
@@ -152,6 +152,24 @@ function Get-GiteeAssetUrl {
return ""
}
function Test-GiteeReleaseAssets {
param(
[string]$Candidate,
[string[]]$RequiredAssets
)
try {
$release = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Candidate"
} catch {
return $false
}
if (-not $release -or -not $release.assets) { return $false }
$assetNames = @($release.assets | ForEach-Object { $_.name })
foreach ($required in $RequiredAssets) {
if ($assetNames -notcontains $required) { return $false }
}
return $true
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
@@ -167,18 +185,26 @@ function Resolve-Source {
}
function Resolve-LatestVersion {
param([string[]]$RequiredAssets = @())
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
try {
# Gitee's /releases/latest and /releases endpoints are unreliable
# (404 / empty even when releases exist), so resolve the newest
# vN.N.N tag from the git tags endpoint instead.
# Tags can be mirrored before release attachments. Select the
# newest stable tag with all assets required by this install.
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
$latest = $tags.name |
$candidates = @($tags.name |
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
ForEach-Object { [version]($_.TrimStart('v')) } |
Sort-Object | Select-Object -Last 1
if ($latest) { $script:Version = "v$latest"; return }
Sort-Object { [version]($_.TrimStart('v')) } -Descending)
$newest = $candidates | Select-Object -First 1
foreach ($candidate in $candidates) {
if (Test-GiteeReleaseAssets $candidate $RequiredAssets) {
$script:Version = $candidate
if ($candidate -ne $newest) {
Write-Say "⚠ Gitee tag $newest has no complete release assets; using $candidate."
}
return
}
}
} catch {}
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
return
@@ -349,9 +375,12 @@ function Write-MultiModeNotice {
function Install-Binary {
$arch = Get-Arch
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
$requiredAssets = @($archiveName, "checksums.txt")
if (!$NoSkills -and $SkillMode -ne "multi") {
$requiredAssets += "dws-skills.zip"
}
Resolve-LatestVersion $requiredAssets
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
@@ -545,7 +574,7 @@ function Install-BinaryFromSource {
function Install-Skills {
Write-Say ""
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
Resolve-LatestVersion @("dws-skills.zip")
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
+44 -13
View File
@@ -105,6 +105,40 @@ gitee_api() {
return 1
}
gitee_release_has_assets() {
_version="$1"
shift
_release="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${_version}" 2>/dev/null || true)"
[ -n "$_release" ] && [ "$_release" != "null" ] || return 1
_compact="$(printf '%s' "$_release" | tr -d '[:space:]')"
for _asset in "$@"; do
printf '%s' "$_compact" | grep -Fq "\"name\":\"${_asset}\"" || return 1
done
return 0
}
resolve_gitee_latest_version() {
_tags="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" 2>/dev/null || true)"
_candidates="$(printf '%s' "$_tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -Vr)"
_newest="$(printf '%s\n' "$_candidates" | head -1)"
[ -n "$_newest" ] || return 1
for _candidate in $_candidates; do
if gitee_release_has_assets "$_candidate" "$@"; then
VERSION="$_candidate"
if [ "$_candidate" != "$_newest" ]; then
say "⚠ Gitee tag ${_newest} has no complete release assets; using ${_candidate}."
fi
return 0
fi
done
return 1
}
# Resolve the download URL of a release asset by file name.
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
# Gitee : query the release API and pick the asset whose name matches
@@ -173,19 +207,13 @@ pick_source() {
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
}
# Resolve the latest version tag from GitHub
# Resolve the latest installable release from the selected source.
resolve_version() {
if [ "$VERSION" = "latest" ]; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable — they
# return 404 / an empty list even when releases exist — so resolve the
# newest version from the git tags endpoint instead: keep only vN.N.N
# tags and pick the highest by version order.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
# Tags can be mirrored before release attachments. Select the newest
# stable tag whose release contains every asset needed by this install.
resolve_gitee_latest_version "$@" || VERSION=""
elif need_cmd curl; then
# Follow the redirect from /releases/latest to get the tag
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
@@ -472,9 +500,12 @@ _copy_skill() {
install_binary() {
os="$(detect_os)"
arch="$(detect_arch)"
resolve_version
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
if [ "$NO_SKILLS" != "1" ] && [ "$SKILL_MODE" != "multi" ]; then
resolve_version "$archive_name" checksums.txt dws-skills.zip
else
resolve_version "$archive_name" checksums.txt
fi
download_url="$(asset_url "$archive_name")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
@@ -555,8 +586,8 @@ install_skills() {
say ""
say "📦 Installing agent skills from GitHub Releases..."
resolve_version
skills_archive="dws-skills.zip"
resolve_version "$skills_archive"
download_url="$(asset_url "$skills_archive")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
@@ -1,122 +0,0 @@
#!/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"
+48 -5
View File
@@ -1,7 +1,50 @@
#!/bin/sh
#!/usr/bin/env bash
# Build release artifacts locally in the current runner and publish them to Gitee.
set -eu
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
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
-322
View File
@@ -1,322 +0,0 @@
#!/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" "$@"
@@ -1,85 +0,0 @@
#!/bin/sh
set -eu
# Replace GoReleaser's original assets with post-processed artifacts, verify
# that GitHub serves exactly those bytes, and only then make the Draft public.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
PUBLISH_RELEASE="${DWS_PUBLISH_RELEASE:-true}"
DIGEST_ATTEMPTS="${DWS_RELEASE_DIGEST_ATTEMPTS:-5}"
DIGEST_RETRY_DELAY="${DWS_RELEASE_DIGEST_RETRY_DELAY:-2}"
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
sha256_file() {
target="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$target" | awk '{print $1}'
return
fi
shasum -a 256 "$target" | awk '{print $1}'
}
case "$PUBLISH_RELEASE" in
1|true|yes) publish_release=1 ;;
0|false|no) publish_release=0 ;;
*) err "invalid DWS_PUBLISH_RELEASE value: $PUBLISH_RELEASE" ;;
esac
case "$DIGEST_ATTEMPTS" in
''|*[!0-9]*) err "DWS_RELEASE_DIGEST_ATTEMPTS must be a positive integer" ;;
0) err "DWS_RELEASE_DIGEST_ATTEMPTS must be greater than zero" ;;
esac
command -v gh >/dev/null 2>&1 || err "gh is required"
set -- \
"$DIST_DIR/dws-darwin-amd64.tar.gz" \
"$DIST_DIR/dws-darwin-arm64.tar.gz" \
"$DIST_DIR/checksums.txt" \
"$DIST_DIR/dws-skills.zip"
for asset in "$@"; do
[ -f "$asset" ] || err "finalized release asset missing: $asset"
done
gh release upload "$TAG" "$@" \
--repo "$REPOSITORY" \
--clobber
# Fail before publication if GitHub still serves a pre-signing archive. Asset
# digests can take a moment to settle after replacement, so retry.
for asset in "$@"; do
name="$(basename "$asset")"
local_digest="sha256:$(sha256_file "$asset")"
attempt=1
while [ "$attempt" -le "$DIGEST_ATTEMPTS" ]; do
remote_digest="$(
gh release view "$TAG" --repo "$REPOSITORY" --json assets \
--jq ".assets[] | select(.name == \"$name\") | .digest"
)"
if [ "$remote_digest" = "$local_digest" ]; then
break
fi
if [ "$attempt" -eq "$DIGEST_ATTEMPTS" ]; then
printf 'release asset digest mismatch for %s\n' "$name" >&2
printf ' local: %s\n' "$local_digest" >&2
printf ' remote: %s\n' "${remote_digest:-missing}" >&2
exit 1
fi
attempt=$((attempt + 1))
sleep "$DIGEST_RETRY_DELAY"
done
done
if [ "$publish_release" -eq 1 ]; then
gh release edit "$TAG" --repo "$REPOSITORY" --draft=false
else
printf 'finalized assets verified; keeping release %s as Draft\n' "$TAG"
fi
-51
View File
@@ -1,51 +0,0 @@
#!/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"
+28 -61
View File
@@ -10,9 +10,6 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-}"
RELEASE_BASE_URL="${DWS_RELEASE_BASE_URL:-}"
APPLE_CERTIFICATE_P12="${DWS_APPLE_CERTIFICATE_P12:-}"
APPLE_CERTIFICATE_PASSWORD_FILE="${DWS_APPLE_CERTIFICATE_PASSWORD_FILE:-}"
REQUIRE_DEVELOPER_ID_SIGNING="${DWS_REQUIRE_DEVELOPER_ID_SIGNING:-false}"
export LANG=C
export LC_ALL=C
@@ -90,27 +87,36 @@ resolve_release_base_url() {
stage_npm_package() {
version="$1"
DWS_PACKAGE_SOURCE_ROOT="$ROOT" \
DWS_PACKAGE_DIST_DIR="$DIST_DIR" \
"$ROOT/scripts/release/stage-npm-package.sh" "$version"
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
}
# ---------- Homebrew formula staging ----------
render_homebrew_formula() {
class_name="$1"
formula_version="$2"
archive_url="$3"
skills_url="$4"
archive_sha="$5"
skills_sha="$6"
keg_only_line="$7"
output_path="$8"
archive_url="$2"
skills_url="$3"
archive_sha="$4"
skills_sha="$5"
keg_only_line="$6"
output_path="$7"
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" \
@@ -140,7 +146,6 @@ stage_homebrew_formula() {
render_homebrew_formula \
"DingtalkWorkspaceCliLocal" \
"$version" \
"file://$archive_path" \
"file://$DIST_DIR/dws-skills.zip" \
"$archive_sha" \
@@ -150,7 +155,6 @@ stage_homebrew_formula() {
render_homebrew_formula \
"DingtalkWorkspaceCli" \
"$version" \
"$release_url_base/$archive_name" \
"$release_url_base/$skills_name" \
"$archive_sha" \
@@ -191,47 +195,14 @@ create_skills_zip() {
rm -rf "$staging"
}
# ---------- darwin signing ----------
# ---------- darwin ad-hoc signing ----------
#
# Unsigned arm64 binaries are SIGKILL'd by amfid on Apple Silicon (macOS 11+).
# Official releases use an Apple Developer ID certificate loaded from GitHub
# Secrets. Fork/local builds retain ad-hoc signing so they remain runnable.
# We unpack each dws-darwin-*.tar.gz, sign the dws binary, repack deterministically,
# and rewrite the corresponding line in checksums.txt.
configure_darwin_signing() {
case "$REQUIRE_DEVELOPER_ID_SIGNING" in
1|true|yes) require_developer_id=1 ;;
0|false|no|"") require_developer_id=0 ;;
*) err "invalid DWS_REQUIRE_DEVELOPER_ID_SIGNING value: $REQUIRE_DEVELOPER_ID_SIGNING" ;;
esac
if [ -n "$APPLE_CERTIFICATE_P12" ] || [ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ]; then
[ -n "$APPLE_CERTIFICATE_P12" ] || err "DWS_APPLE_CERTIFICATE_P12 is required when Developer ID signing is configured"
[ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "DWS_APPLE_CERTIFICATE_PASSWORD_FILE is required when Developer ID signing is configured"
[ -f "$APPLE_CERTIFICATE_P12" ] || err "Developer ID P12 file not found: $APPLE_CERTIFICATE_P12"
[ -f "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "Developer ID password file not found: $APPLE_CERTIFICATE_PASSWORD_FILE"
command -v rcodesign >/dev/null 2>&1 || err "rcodesign is required for Developer ID signing"
DARWIN_SIGNING_MODE="developer-id"
return
fi
if [ "$require_developer_id" -eq 1 ]; then
err "Developer ID signing is required but DWS_APPLE_CERTIFICATE_P12 and DWS_APPLE_CERTIFICATE_PASSWORD_FILE are not configured"
fi
DARWIN_SIGNING_MODE="ad-hoc"
}
# We unpack each dws-darwin-*.tar.gz, ad-hoc sign the dws binary, repack
# deterministically, and rewrite the corresponding line in checksums.txt.
sign_one_darwin_binary() {
bin="$1"
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
rcodesign sign \
--p12-file "$APPLE_CERTIFICATE_P12" \
--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE" \
--for-notarization \
"$bin"
return
fi
if command -v codesign >/dev/null 2>&1; then
codesign --force --sign - "$bin"
return
@@ -301,22 +272,18 @@ sign_darwin_archives() {
}
write_checksums() {
# Keep this idempotent: workflow retries must not leave duplicate entries.
checksum_path="$DIST_DIR/checksums.txt"
# Append skills zip checksum to goreleaser's checksums file
if [ -f "$DIST_DIR/dws-skills.zip" ]; then
update_checksum_entry "dws-skills.zip" "$(sha256_file "$DIST_DIR/dws-skills.zip")"
printf '%s %s\n' "$(sha256_file "$DIST_DIR/dws-skills.zip")" "dws-skills.zip" >> "$checksum_path"
fi
}
# ---------- main ----------
version="$(resolve_version)"
configure_darwin_signing
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
say "==> Developer ID signing darwin binaries"
else
say "==> Ad-hoc signing darwin binaries"
fi
say "==> Ad-hoc signing darwin binaries"
sign_darwin_archives
say "==> Creating skills zip"
-112
View File
@@ -1,112 +0,0 @@
#!/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
+145 -5
View File
@@ -1,7 +1,147 @@
#!/bin/sh
#!/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.
set -eu
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
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}"
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
./scripts/release/verify-gitee-release.sh
-221
View File
@@ -1,221 +0,0 @@
#!/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
@@ -1,167 +0,0 @@
#!/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
@@ -1,353 +0,0 @@
#!/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
@@ -1,56 +0,0 @@
#!/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"
+15 -82
View File
@@ -24,9 +24,8 @@
# 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 -euo pipefail
set -eu
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}"
@@ -40,17 +39,12 @@ 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}"
@@ -78,18 +72,10 @@ gitee_tag_commit() {
}'
}
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
echo " Syncing Gitee tag ${VERSION} -> ${target_commit}"
git push --force "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
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
@@ -138,10 +124,8 @@ 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.
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
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
try:
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
@@ -150,13 +134,7 @@ try:
if n and i!="":
print("%s\t%s\t%s" % (n, i, u))
except Exception:
sys.exit(1)'
}
assets_map="$(load_assets_map)" || {
echo "❌ Could not list Gitee release attachments; refusing a blind upload." >&2
exit 1
}
pass' 2>/dev/null || true)"
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}';
@@ -185,13 +163,12 @@ 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
>/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; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
@@ -203,12 +180,7 @@ 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 failed for ${fn}" >&2
failed=$((failed + 1))
fi
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
continue
fi
@@ -225,58 +197,19 @@ 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.
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
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
done
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
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; 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)."
VERSION="$VERSION" GITEE_REPO="$GITEE_REPO" GITEE_API="$GITEE_API" \
GITEE_TOKEN="$GITEE_TOKEN" DIST_DIR="$DIST_DIR" \
./scripts/release/verify-gitee-release.sh
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+27 -133
View File
@@ -2,16 +2,19 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# 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.
# Sync release artifacts to a China-accessible OSS mirror so that the install
# scripts' DWS_RELEASE_BASE switch can serve domestic users.
#
# Mirror layout produced:
# Mirror layout produced (matches install.sh RELEASE_BASE="<base>/<version>/<file>"):
# 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 (stable version only)
# oss://$OSS_BUCKET/$OSS_PREFIX/beta.txt (prerelease version only)
# 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
#
# Required environment (CI secrets):
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_ENDPOINT, OSS_BUCKET
@@ -25,9 +28,6 @@
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,10 +38,6 @@ 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
@@ -49,60 +45,20 @@ 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 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
echo "⬇ ossutil not found; installing to ./.ossutil ..."
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
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"
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"
fi
oss_cp() {
@@ -114,59 +70,8 @@ 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
@@ -180,27 +85,16 @@ if [ "$uploaded" -eq 0 ]; then
exit 1
fi
# ── 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 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"
# 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
# ── 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
echo "✅ Synced ${uploaded} artifact(s) + ${pointer_summary}${installer_summary} to the OSS mirror."
echo " Mirror path: ${OSS_PREFIX}/download/${VERSION}/"
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"
@@ -1,84 +0,0 @@
#!/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"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# Verify that a Gitee release exposes every standard DWS release asset.
set -eu
VERSION="${VERSION:-}"
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
DIST_DIR="${DIST_DIR:-dist}"
GITEE_VERIFY_RETRIES="${GITEE_VERIFY_RETRIES:-6}"
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
REQUIRED_ASSETS="${GITEE_REQUIRED_ASSETS:-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}"
[ -n "$VERSION" ] || {
echo "error: VERSION is required" >&2
exit 2
}
for asset in $REQUIRED_ASSETS; do
[ -f "$DIST_DIR/$asset" ] || {
echo "error: local release asset is missing: $DIST_DIR/$asset" >&2
exit 1
}
done
endpoint="${GITEE_API}/repos/${GITEE_REPO}/releases/tags/${VERSION}"
if [ -n "${GITEE_TOKEN:-}" ]; then
endpoint="${endpoint}?access_token=${GITEE_TOKEN}"
fi
attempt=1
missing=""
while [ "$attempt" -le "$GITEE_VERIFY_RETRIES" ]; do
release_json="$(curl -fsSL "$endpoint" 2>/dev/null || true)"
compact="$(printf '%s' "$release_json" | tr -d '[:space:]')"
missing=""
for asset in $REQUIRED_ASSETS; do
count="$(printf '%s' "$compact" | grep -oF "\"name\":\"${asset}\"" | wc -l | tr -d ' ')"
if [ "$count" -ne 1 ]; then
missing="${missing} ${asset}(count=${count})"
fi
done
if [ -n "$release_json" ] && [ "$release_json" != "null" ] && [ -z "$missing" ]; then
echo "Gitee release ${VERSION} is complete (${GITEE_REPO})."
exit 0
fi
attempt=$((attempt + 1))
[ "$attempt" -le "$GITEE_VERIFY_RETRIES" ] && sleep "$GITEE_VERIFY_RETRY_DELAY"
done
echo "error: Gitee release ${VERSION} is incomplete:${missing:- release not found}" >&2
exit 1
@@ -1,35 +0,0 @@
#!/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"
@@ -1,37 +0,0 @@
#!/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"
+24 -71
View File
@@ -5,10 +5,6 @@ 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' "$*"
@@ -27,34 +23,6 @@ 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
@@ -92,11 +60,13 @@ HOME_SKILL_TARGETS="
.hermes/skills/dws
"
cleanup() {
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
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
fi
rm -rf "$TMP_ROOT"
}
@@ -141,18 +111,8 @@ 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"
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"
"$npm_prefix/bin/dws" --help >/dev/null
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
@@ -163,10 +123,6 @@ 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"
@@ -174,39 +130,36 @@ verify_brew() {
BREW_TAP_NAME="local/dws-package-verify-$$"
say "==> verifying Homebrew formula install"
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
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 \
brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null
tap_repo="$(
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --repository "$BREW_TAP_NAME"
)"
mkdir -p "$tap_repo/Formula"
sed "s|skill_home_override = \"\"|skill_home_override = \"$brew_home\"|" \
"$FORMULA_PATH" > "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
prefix="$(
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --prefix "$BREW_TAP_NAME/dingtalk-workspace-cli-local"
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
brew --prefix dingtalk-workspace-cli-local
)"
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
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"
"$prefix/bin/dws" --help >/dev/null
verify_skill_targets "$brew_home"
}
need_file "$DIST_DIR/dws-skills.zip"
[ "$RUN_NPM" -eq 0 ] || verify_npm
[ "$RUN_BREW" -eq 0 ] || verify_brew
verify_npm
verify_brew
say "Package-manager verification complete."
-107
View File
@@ -1,107 +0,0 @@
#!/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"
+1 -6
View File
@@ -9,14 +9,10 @@ dws auth login
# 查看状态
dws auth status
# macOS: 将系统 Keychain 登录态迁移为沙箱可读的 file-DEK(先预检)
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
# 退出
dws auth logout
# 重置全部本地凭证(仅在迁移/按 profile 恢复均失败且确认可丢弃全部登录时使用)
# 重置本地凭证 (Token 解密失败时使用)
dws auth reset
```
@@ -31,7 +27,6 @@ dws auth reset
### 认证失败处理
- 命令返回 `AUTH_TOKEN_EXPIRED` / `USER_TOKEN_ILLEGAL` / "Token验证失败" → 执行 `dws auth login` 重新登录
- macOS 返回 `ciphertext_key_mismatch`,且普通终端仍能登录 → 先用系统 Keychain 模式执行 `auth migrate-keychain --to file-dek --dry-run`,通过后加 `--yes`;禁止直接 `auth reset`
### Headless 环境 (CI/CD)
+1 -2
View File
@@ -19,7 +19,6 @@ metadata:
## 认证
- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权
- `dws auth status [--profile <名称|corpId>]` 查看认证状态
- macOS 出现 `ciphertext_key_mismatch` 且普通终端仍可登录时:先运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json`,通过后加 `--yes` 迁移;不要直接 `auth reset`
## 全局参数
- 所有命令加 `--format json` 取可解析输出
@@ -36,5 +35,5 @@ dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织
## 错误处理
- `unknown command` / `unknown flag`:先跑 `dws <path> --help` 查证再修正一次,别把自然语言当命令 / flag
- 服务端 token 过期:提示用户 `dws auth login` 重新登录;本地密钥不匹配按上面的 macOS 迁移流程处理,不要混为 token 过期
- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录
- 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因
-219
View File
@@ -1,219 +0,0 @@
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
}
+88
View File
@@ -0,0 +1,88 @@
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestVerifyGiteeReleaseRequiresEveryAsset(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remote string
wantError bool
}{
{
name: "complete",
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"},{"name":"dws-skills.zip"}]}`,
},
{
name: "missing remote asset",
remote: `{"assets":[{"name":"checksums.txt"},{"name":"dws-linux-amd64.tar.gz"}]}`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
distDir := filepath.Join(root, "dist")
for _, name := range []string{"checksums.txt", "dws-linux-amd64.tar.gz", "dws-skills.zip"} {
mustWriteFile(t, filepath.Join(distDir, name), []byte(name), 0o644)
}
stubRoot := filepath.Join(root, "stubs")
mustWriteFile(t, filepath.Join(stubRoot, "curl"), []byte("#!/bin/sh\nprintf '%s' \"$FAKE_GITEE_RELEASE\"\n"), 0o755)
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-gitee-release.sh"))
if err != nil {
t.Fatalf("Abs(verify-gitee-release.sh) error = %v", err)
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+stubRoot+":"+os.Getenv("PATH"),
"VERSION=v1.2.3",
"DIST_DIR="+distDir,
"GITEE_REQUIRED_ASSETS=checksums.txt dws-linux-amd64.tar.gz dws-skills.zip",
"GITEE_VERIFY_RETRIES=1",
"FAKE_GITEE_RELEASE="+tt.remote,
)
output, err := cmd.CombinedOutput()
if tt.wantError {
if err == nil {
t.Fatalf("verify-gitee-release.sh error = nil, want failure\noutput:\n%s", string(output))
}
if !strings.Contains(string(output), "dws-skills.zip(count=0)") {
t.Fatalf("missing asset not reported:\n%s", string(output))
}
return
}
if err != nil {
t.Fatalf("verify-gitee-release.sh error = %v\noutput:\n%s", err, string(output))
}
if !strings.Contains(string(output), "Gitee release v1.2.3 is complete") {
t.Fatalf("success output missing completeness result:\n%s", string(output))
}
})
}
}
func TestGiteePublishersRunCompletenessVerification(t *testing.T) {
t.Parallel()
for _, relPath := range []string{
filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh"),
filepath.Join("..", "..", "scripts", "release", "publish-gitee-local.sh"),
filepath.Join("..", "..", ".github", "workflows", "verify-gitee-release.yml"),
} {
data, err := os.ReadFile(relPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", relPath, err)
}
if !strings.Contains(string(data), "verify-gitee-release.sh") {
t.Fatalf("%s does not run verify-gitee-release.sh", relPath)
}
}
}
+141
View File
@@ -4,6 +4,8 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -191,6 +193,104 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) {
}
}
func TestInstallScriptGiteeLatestUsesNewestCompleteRelease(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell installer test is for unix-like hosts")
}
root := t.TempDir()
installDir := filepath.Join(root, "bin")
stubRoot := filepath.Join(root, "stubs")
releaseDir := filepath.Join(root, "release")
archiveName := "dws-linux-amd64.tar.gz"
archivePath := filepath.Join(releaseDir, archiveName)
writeTarGz(t, archivePath, map[string]string{
"dws": "#!/bin/sh\nprintf 'Version: v1.0.49\\n'\n",
})
archiveData, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", archivePath, err)
}
checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(archiveData), archiveName)
mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(checksum), 0o644)
scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", "install.sh"))
if err != nil {
t.Fatalf("ReadFile(install.sh) error = %v", err)
}
scriptPath := filepath.Join(root, "standalone", "install.sh")
mustWriteFile(t, scriptPath, scriptData, 0o755)
writeFakeGiteeCurl(t, filepath.Join(stubRoot, "curl"))
mustWriteFile(t, filepath.Join(stubRoot, "uname"), []byte(`#!/bin/sh
case "${1:-}" in
-s) echo Linux ;;
-m) echo x86_64 ;;
*) echo Linux ;;
esac
`), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"HOME="+filepath.Join(root, "home"),
"PATH="+stubRoot+":"+os.Getenv("PATH"),
"DWS_INSTALL_DIR="+installDir,
"DWS_GITEE_REPO=example/dws",
"DWS_VERSION=latest",
"DWS_NO_SKILLS=1",
"FAKE_RELEASE_DIR="+releaseDir,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
got := string(output)
if !strings.Contains(got, "Gitee tag v1.0.51 has no complete release assets; using v1.0.49") {
t.Fatalf("install output missing incomplete-release fallback:\n%s", got)
}
installed := filepath.Join(installDir, "dws")
versionOutput, err := exec.Command(installed).CombinedOutput()
if err != nil {
t.Fatalf("installed dws error = %v\noutput:\n%s", err, string(versionOutput))
}
if !strings.Contains(string(versionOutput), "Version: v1.0.49") {
t.Fatalf("installed version output = %q, want v1.0.49", string(versionOutput))
}
}
func TestInstallersRequireCompleteGiteeReleaseAssets(t *testing.T) {
t.Parallel()
checks := []struct {
relPath string
wants []string
}{
{
relPath: filepath.Join("..", "..", "scripts", "install.sh"),
wants: []string{"resolve_gitee_latest_version", `resolve_version "$archive_name" checksums.txt dws-skills.zip`, `resolve_version "$skills_archive"`},
},
{
relPath: filepath.Join("..", "..", "scripts", "install-skills.sh"),
wants: []string{"resolve_gitee_latest_version", "resolve_version dws-skills.zip"},
},
{
relPath: filepath.Join("..", "..", "scripts", "install.ps1"),
wants: []string{"Test-GiteeReleaseAssets", `$requiredAssets += "dws-skills.zip"`, `Resolve-LatestVersion @("dws-skills.zip")`},
},
}
for _, tc := range checks {
data, err := os.ReadFile(tc.relPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", tc.relPath, err)
}
for _, want := range tc.wants {
if !strings.Contains(string(data), want) {
t.Fatalf("%s missing %q", tc.relPath, want)
}
}
}
}
func TestInstallEventScriptStaticExpectations(t *testing.T) {
t.Parallel()
@@ -822,6 +922,47 @@ func writeFakeGH(t *testing.T, path, version string) {
mustWriteFile(t, path, []byte(script), 0o755)
}
func writeFakeGiteeCurl(t *testing.T, path string) {
t.Helper()
const script = `#!/bin/sh
set -eu
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
case "$url" in
*/tags)
printf '%s' '[{"name":"v1.0.49"},{"name":"v1.0.51"},{"name":"v1.0.50"}]'
;;
*/releases/tags/v1.0.51)
printf '%s' 'null'
;;
*/releases/tags/v1.0.50)
printf '%s' '{"tag_name":"v1.0.50","assets":[{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
;;
*/releases/tags/v1.0.49)
printf '%s' '{"tag_name":"v1.0.49","assets":[{"name":"dws-linux-amd64.tar.gz","browser_download_url":"https://assets.invalid/dws-linux-amd64.tar.gz"},{"name":"checksums.txt","browser_download_url":"https://assets.invalid/checksums.txt"}]}'
;;
https://assets.invalid/dws-linux-amd64.tar.gz)
cp "$FAKE_RELEASE_DIR/dws-linux-amd64.tar.gz" "$out"
;;
https://assets.invalid/checksums.txt)
cp "$FAKE_RELEASE_DIR/checksums.txt" "$out"
;;
*)
echo "fake curl: unexpected URL $url" >&2
exit 1
;;
esac
`
mustWriteFile(t, path, []byte(script), 0o755)
}
func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+19 -467
View File
@@ -1,10 +1,6 @@
package scripts_test
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -32,41 +28,9 @@ var expectedPackagedSkillTargets = []string{
".hermes/skills/dws",
}
func writeDarwinArchive(t *testing.T, path string) {
t.Helper()
archive, err := os.Create(path)
if err != nil {
t.Fatalf("Create(%s) error = %v", path, err)
}
gzipWriter := gzip.NewWriter(archive)
tarWriter := tar.NewWriter(gzipWriter)
binary := []byte("#!/bin/sh\nexit 0\n")
if err := tarWriter.WriteHeader(&tar.Header{
Name: "dws",
Mode: 0o755,
Size: int64(len(binary)),
}); err != nil {
t.Fatalf("WriteHeader(%s) error = %v", path, err)
}
if _, err := tarWriter.Write(binary); err != nil {
t.Fatalf("Write(%s) error = %v", path, err)
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("tar Close(%s) error = %v", path, err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatalf("gzip Close(%s) error = %v", path, err)
}
if err := archive.Close(); err != nil {
t.Fatalf("Close(%s) error = %v", path, err)
}
}
// seedDistArtifacts creates minimal goreleaser output archives and a
// checksums.txt stub so post-goreleaser.sh can run without a real build.
// Darwin archives must be valid tar.gz files because the packaging script
// extracts and signs their dws binaries.
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
// files) and a checksums.txt stub so that post-goreleaser.sh can run without
// an actual goreleaser build.
func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
t.Helper()
if err := os.MkdirAll(distDir, 0o755); err != nil {
@@ -75,10 +39,6 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
for _, target := range targets {
p := filepath.Join(distDir, target)
if strings.HasPrefix(target, "dws-darwin-") && strings.HasSuffix(target, ".tar.gz") {
writeDarwinArchive(t, p)
continue
}
if err := os.WriteFile(p, []byte("fake-archive"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", p, err)
}
@@ -95,23 +55,6 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
}
}
func postGoreleaserEnv(t *testing.T, distDir, version, releaseBaseURL string) []string {
t.Helper()
binDir := t.TempDir()
fakeCodesign := filepath.Join(binDir, "codesign")
if err := os.WriteFile(fakeCodesign, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("WriteFile(fake codesign) error = %v", err)
}
return append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"DWS_PACKAGE_VERSION="+version,
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
)
}
func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
t.Parallel()
@@ -134,7 +77,10 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=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,8 +107,6 @@ 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",
} {
@@ -179,7 +123,6 @@ 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",
} {
@@ -222,20 +165,13 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
}
}
// 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.
// Verify checksums.txt was updated to include skills zip
checksumsData, err := os.ReadFile(filepath.Join(distDir, "checksums.txt"))
if err != nil {
t.Fatalf("ReadFile(checksums.txt) error = %v", err)
}
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)
if !strings.Contains(string(checksumsData), "dws-skills.zip") {
t.Fatalf("checksums.txt missing dws-skills.zip entry:\n%s", string(checksumsData))
}
}
@@ -263,7 +199,10 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
seedDistArtifacts(t, distDir, allArchives)
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "v9.9.9", "https://downloads.example.com/dws/releases/v9.9.9")
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=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))
@@ -277,7 +216,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
}
packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets")
for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") {
for _, rel := range append(allArchives, "dws-skills.zip") {
if _, err := os.Stat(filepath.Join(packageAssetsDir, rel)); err != nil {
t.Fatalf("npm asset missing %q: %v", rel, err)
}
@@ -340,7 +279,10 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0")
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=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))
@@ -388,393 +330,3 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
t.Fatalf("multi/ does not contain any dingtalk-* skill: %v", multiEntries)
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
build := strings.Index(workflow, "Build release artifacts without publishing")
postProcess := strings.Index(workflow, "./scripts/release/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")
}
buildSection := workflow[build:verifyJob]
for _, required := range []string{
"--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",
"--clobber",
"verify-release-artifacts.sh",
} {
if !strings.Contains(publishSection, required) {
t.Errorf("immutable publication stage is missing %q", required)
}
}
}
func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate")
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 ||
prepare > goReleaser || goReleaser > postProcess || cleanup < postProcess {
t.Fatalf("Developer ID material must be validated before GoReleaser and removed after post-processing")
}
for _, required := range []string{
`RCS_VERSION="0.29.0"`,
"secrets.APPLE_CERTIFICATE_P12_BASE64",
"secrets.APPLE_CERTIFICATE_PASSWORD",
"base64 --decode",
"openssl pkcs12 -legacy",
"DWS_APPLE_CERTIFICATE_P12",
"DWS_APPLE_CERTIFICATE_PASSWORD_FILE",
"DWS_REQUIRE_DEVELOPER_ID_SIGNING",
`GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI`,
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Developer ID configuration %q", required)
}
}
}
func TestPostGoreleaserSupportsDeveloperIDSigning(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
if err != nil {
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, required := range []string{
`APPLE_CERTIFICATE_P12="${DWS_APPLE_CERTIFICATE_P12:-}"`,
`APPLE_CERTIFICATE_PASSWORD_FILE="${DWS_APPLE_CERTIFICATE_PASSWORD_FILE:-}"`,
`REQUIRE_DEVELOPER_ID_SIGNING="${DWS_REQUIRE_DEVELOPER_ID_SIGNING:-false}"`,
`--p12-file "$APPLE_CERTIFICATE_P12"`,
`--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE"`,
"--for-notarization",
} {
if !strings.Contains(script, required) {
t.Errorf("post-goreleaser.sh is missing Developer ID signing behavior %q", required)
}
}
if strings.Contains(script, `rcodesign verify "$bin"`) {
t.Fatal("rcodesign verify must not be treated as authoritative Apple signature validation")
}
}
func TestReleaseWorkflowVerifiesRcodesignArchiveChecksum(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
hash := strings.Index(workflow, `RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"`)
checksum := strings.Index(workflow, "sha256sum --check --strict")
extract := strings.Index(workflow, "tar -xzf /tmp/rcodesign.tar.gz")
execute := strings.Index(workflow, "rcodesign --version")
if hash == -1 || checksum == -1 || extract == -1 || execute == -1 ||
!(hash < checksum && checksum < extract && extract < execute) {
t.Fatal("rcodesign archive must match the pinned SHA-256 before extraction or execution")
}
}
func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
preserve := strings.Index(workflow, "Preserve finalized distribution files")
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
publishJob := strings.Index(workflow, "publish-release:")
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" --draft=false`)
if codesign == -1 || publish == -1 {
t.Fatal("macOS codesign verification and explicit Draft publication are required")
}
buildSection := workflow[preserve:verifyJob]
for _, required := range []string{
"actions/upload-artifact@v4",
"finalized-release-dist",
} {
if !strings.Contains(buildSection, required) {
t.Errorf("signed build stage is missing %q", required)
}
}
verifySection := workflow[verifyJob:publishJob]
for _, required := range []string{
"runs-on: macos-latest",
"actions/download-artifact@v4",
"finalized-release-dist",
`dws-darwin-${arch}.tar.gz`,
"codesign --verify --strict --verbose=4",
} {
if !strings.Contains(verifySection, required) {
t.Errorf("Apple verification stage is missing %q", required)
}
}
publishSection := workflow[publishJob:]
for _, required := range []string{
"verify-darwin-signatures",
"actions/download-artifact@v4",
"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)
}
}
}
func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) {
t.Parallel()
goreleaserPath, err := filepath.Abs(filepath.Join("..", "..", ".goreleaser.yaml"))
if err != nil {
t.Fatalf("Abs(.goreleaser.yaml) error = %v", err)
}
goreleaserData, err := os.ReadFile(goreleaserPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", goreleaserPath, err)
}
if !strings.Contains(string(goreleaserData), "draft: true") {
t.Fatal("GoReleaser must keep the release as Draft during post-processing")
}
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)
upload := strings.Index(finalize, "gh release upload")
digestFailure := strings.Index(finalize, "release asset digest mismatch")
publish := strings.Index(finalize, "gh release edit")
if upload == -1 || digestFailure == -1 || publish == -1 || !(upload < digestFailure && digestFailure < publish) {
t.Fatal("Draft publication must happen after finalized asset upload and digest verification")
}
}
func TestFinalizeGitHubReleaseDoesNotPublishAfterUploadFailure(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
root := t.TempDir()
distDir := filepath.Join(root, "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
}
for _, name := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"checksums.txt",
"dws-skills.zip",
} {
if err := os.WriteFile(filepath.Join(distDir, name), []byte("finalized"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
}
logPath := filepath.Join(root, "gh.log")
fakeGH := `#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
exit 42
fi
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
exit 0
fi
exit 1
`
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
t.Fatalf("WriteFile(fake gh) error = %v", err)
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_GH_LOG="+logPath,
"GITHUB_REF_NAME=v-test",
"GITHUB_REPOSITORY=example/dws",
"DWS_PACKAGE_DIST_DIR="+distDir,
)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("finalize-github-release.sh unexpectedly succeeded after upload failure:\n%s", output)
}
logData, readErr := os.ReadFile(logPath)
if readErr != nil {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
logText := string(logData)
if !strings.Contains(logText, "release upload") {
t.Fatalf("fake gh did not observe release upload:\n%s", logText)
}
if strings.Contains(logText, "release edit") {
t.Fatalf("Draft release was published after upload failure:\n%s", logText)
}
}
func TestFinalizeGitHubReleaseCanVerifyWithoutPublishing(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
root := t.TempDir()
distDir := filepath.Join(root, "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
}
assetContent := []byte("finalized")
for _, name := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"checksums.txt",
"dws-skills.zip",
} {
if err := os.WriteFile(filepath.Join(distDir, name), assetContent, 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
}
logPath := filepath.Join(root, "gh.log")
fakeGH := `#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
exit 0
fi
if [ "$1" = "release" ] && [ "$2" = "view" ]; then
printf '%s\n' "$FAKE_REMOTE_DIGEST"
exit 0
fi
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
exit 0
fi
exit 1
`
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
t.Fatalf("WriteFile(fake gh) error = %v", err)
}
digest := sha256.Sum256(assetContent)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_GH_LOG="+logPath,
"FAKE_REMOTE_DIGEST="+fmt.Sprintf("sha256:%x", digest),
"GITHUB_REF_NAME=v-test",
"GITHUB_REPOSITORY=example/dws",
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_PUBLISH_RELEASE=false",
"DWS_RELEASE_DIGEST_ATTEMPTS=1",
"DWS_RELEASE_DIGEST_RETRY_DELAY=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("finalize-github-release.sh error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), "keeping release v-test as Draft") {
t.Fatalf("finalizer did not report preserved Draft:\n%s", output)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", logPath, err)
}
logText := string(logData)
if !strings.Contains(logText, "release upload") || !strings.Contains(logText, "release view") {
t.Fatalf("finalizer did not upload and verify assets:\n%s", logText)
}
if strings.Contains(logText, "release edit") {
t.Fatalf("finalizer published a release configured to remain Draft:\n%s", logText)
}
}
-301
View File
@@ -1,301 +0,0 @@
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
@@ -1,960 +0,0 @@
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
@@ -1,212 +0,0 @@
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
}