Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d2f03d1b | ||
|
|
2fe57aee89 | ||
|
|
e605e43a71 | ||
|
|
82bb8f3026 | ||
|
|
e3cedc8f67 |
@@ -0,0 +1,6 @@
|
||||
paths:
|
||||
.github/workflows/release.yml:
|
||||
ignore:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
+160
-28
@@ -7,8 +7,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -59,7 +58,17 @@ jobs:
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
run: |
|
||||
go test -v -race -count=1 -timeout=5m \
|
||||
./cmd/... \
|
||||
./internal/... \
|
||||
./pkg/... \
|
||||
./test/unit/...
|
||||
go test -v -count=1 -timeout=5m \
|
||||
./test/cli/...
|
||||
|
||||
- name: Test engineering scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
@@ -136,30 +145,6 @@ jobs:
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
- name: Update coverage badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
mkdir -p .github/badges
|
||||
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
|
||||
|
||||
- name: Commit badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/badges/coverage.svg || true
|
||||
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
|
||||
git push || true
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -198,11 +183,158 @@ jobs:
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
code-check:
|
||||
name: Code Check
|
||||
needs: [lint, test, test-darwin, test-windows, coverage, policy, edition-tests]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify code checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
set -eu
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
command-compatibility:
|
||||
name: Command Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch official release tags
|
||||
run: |
|
||||
git fetch --force --no-tags \
|
||||
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||
'+refs/tags/v*:refs/tags/v*'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check command compatibility
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
fi
|
||||
stable_ref="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')"
|
||||
test -n "$base_ref"
|
||||
test -n "$stable_ref"
|
||||
stable_commit="$(git rev-parse "$stable_ref^{commit}")"
|
||||
./scripts/release/verify-delivered-stable.sh "$stable_ref" "$stable_commit"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$base_ref" \
|
||||
--stable-ref "$stable_ref"
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run CLI smoke tests
|
||||
run: go test -count=1 -timeout=5m ./test/smoke/...
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Mock MCP smoke tests
|
||||
run: go test -count=1 -timeout=5m ./test/mock_mcp/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs: [code-check, command-compatibility, cli-smoke, mock-mcp-smoke]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
CODE_CHECK_RESULT: ${{ needs.code-check.result }}
|
||||
COMMAND_COMPATIBILITY_RESULT: ${{ needs.command-compatibility.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
run: |
|
||||
set -eu
|
||||
failed=0
|
||||
for check in \
|
||||
"Code Check:$CODE_CHECK_RESULT" \
|
||||
"Command Compatibility:$COMMAND_COMPATIBILITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
|
||||
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
|
||||
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
@@ -10,8 +11,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -23,13 +22,14 @@ concurrency:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
- name: Checkout main history
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -41,15 +41,7 @@ jobs:
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
@@ -81,7 +73,6 @@ jobs:
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Publish npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to publish to npm (e.g. v1.0.48)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+797
-113
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
+2
-7
@@ -1,19 +1,14 @@
|
||||
# GoReleaser configuration for dws
|
||||
# Docs: https://goreleaser.com
|
||||
#
|
||||
# To release:
|
||||
# git tag -a v0.1.0 -m "Release v0.1.0"
|
||||
# git push origin v0.1.0
|
||||
# To release, use scripts/release/release.sh. It seals main, validates the
|
||||
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
|
||||
#
|
||||
# To test locally (no publish):
|
||||
# goreleaser release --snapshot --clean
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd
|
||||
binary: dws
|
||||
|
||||
@@ -14,6 +14,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, and optional Gitee delivery with fail-closed recovery checks.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ git diff --check
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
GO ?= go
|
||||
REMOTE ?=
|
||||
PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -11,8 +14,11 @@ help:
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and command-surface checks\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -38,8 +44,8 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
package:
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
|
||||
|
||||
publish-homebrew-formula:
|
||||
@./scripts/release/publish-homebrew-formula.sh
|
||||
@@ -47,6 +53,32 @@ publish-homebrew-formula:
|
||||
setup-hooks:
|
||||
@git config core.hooksPath scripts/hooks 2>/dev/null || true
|
||||
|
||||
changelog-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
|
||||
|
||||
changelog-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
|
||||
|
||||
release-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release:
|
||||
goreleaser release --clean
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
|
||||
@exit 2
|
||||
|
||||
+19
-16
@@ -2,6 +2,7 @@ class __CLASS_NAME__ < Formula
|
||||
desc "DingTalk Workspace CLI"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
version "__VERSION__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
@@ -39,23 +40,25 @@ __KEG_ONLY_LINE__
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
skill_home_override = "__SKILL_HOME_OVERRIDE__"
|
||||
skill_home = skill_home_override.empty? ? Dir.home : skill_home_override
|
||||
targets = [
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// interface-snapshot is an internal CI helper. It is intentionally a separate
|
||||
// binary so it can be copied into a temporary worktree and compiled against an
|
||||
// older revision's real Cobra root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
if err := runGenerate(args[1:], stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
case "compare":
|
||||
compatible, err := runCompare(args[1:], stdout, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
if !compatible {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "-", "snapshot output path, or - for stdout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("generate accepts no positional arguments")
|
||||
}
|
||||
|
||||
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated home: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(home)
|
||||
|
||||
environment := map[string]string{
|
||||
"DWS_CONFIG_DIR": home,
|
||||
"DWS_LANG": "en",
|
||||
"HOME": home,
|
||||
"NO_COLOR": "1",
|
||||
"USERPROFILE": home,
|
||||
}
|
||||
type previousEnv struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
previous := make(map[string]previousEnv, len(environment))
|
||||
for key, value := range environment {
|
||||
oldValue, wasSet := os.LookupEnv(key)
|
||||
previous[key] = previousEnv{value: oldValue, set: wasSet}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for key, old := range previous {
|
||||
if old.set {
|
||||
_ = os.Setenv(key, old.value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
previousLang := i18n.Lang()
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(*output))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create snapshot %q: %w", *output, err)
|
||||
}
|
||||
writeErr := interfacesnapshot.Write(file, snapshot)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return false, fmt.Errorf("compare accepts no positional arguments")
|
||||
}
|
||||
if *currentPath == "" {
|
||||
return false, fmt.Errorf("compare requires --current")
|
||||
}
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read current snapshot: %w", err)
|
||||
}
|
||||
references := make(map[string]interfacesnapshot.Snapshot, 2)
|
||||
if *basePath != "" {
|
||||
references["main"], err = readSnapshot(*basePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if *stablePath != "" {
|
||||
references["stable"], err = readSnapshot(*stablePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read stable snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
return false, fmt.Errorf("write comparison report: %w", err)
|
||||
}
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.Snapshot{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.Read(file)
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode generated snapshot: %v", err)
|
||||
}
|
||||
|
||||
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
commands[command.Path] = command
|
||||
}
|
||||
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
|
||||
if _, ok := commands[path]; !ok {
|
||||
t.Errorf("actual root snapshot is missing %q", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"dws completion", "dws help"} {
|
||||
if _, ok := commands[path]; ok {
|
||||
t.Errorf("framework-noise path %q leaked into snapshot", path)
|
||||
}
|
||||
}
|
||||
|
||||
create := commands["dws dev app create"]
|
||||
if !hasFlag(create.LocalFlags, "name", "string") {
|
||||
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
|
||||
}
|
||||
if !hasFlag(create.InheritedFlags, "profile", "string") {
|
||||
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
stable := commandSnapshot("dws", "dws legacy")
|
||||
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", current)
|
||||
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", stable)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", mergeBasePath,
|
||||
"--stable", stablePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
|
||||
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
commands = append(commands, interfacesnapshot.Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if err := interfacesnapshot.Write(file, snapshot); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
|
||||
## 日常只用一个入口
|
||||
|
||||
安装发布 Skill 后直接运行:
|
||||
|
||||
```bash
|
||||
dws-release
|
||||
```
|
||||
|
||||
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
|
||||
|
||||
```bash
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
|
||||
## 发布模型
|
||||
|
||||
```text
|
||||
main 上的候选代码 + beta CHANGELOG
|
||||
→ vX.Y.Z-beta.N(预发验证)
|
||||
→ 只允许补正式 CHANGELOG,源码不得再变化
|
||||
→ vX.Y.Z(正式发布)
|
||||
```
|
||||
|
||||
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
|
||||
## 预发发布
|
||||
|
||||
运行统一入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
beta 验证通过后,运行正式版入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
|
||||
|
||||
```markdown
|
||||
## [1.2.3-beta.1] - 2026-07-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 本次 beta 验证的用户可见变化。
|
||||
```
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。
|
||||
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
|
||||
|
||||
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Report combines comparisons against independently supplied compatibility
|
||||
// references, normally the PR merge-base and the latest stable GA release.
|
||||
type Report struct {
|
||||
Compatible bool `json:"compatible"`
|
||||
Comparisons []Comparison `json:"comparisons"`
|
||||
}
|
||||
|
||||
// Comparison is the result for one reference snapshot.
|
||||
type Comparison struct {
|
||||
Reference string `json:"reference"`
|
||||
Compatible bool `json:"compatible"`
|
||||
Blocking []Change `json:"blocking"`
|
||||
Additions []Change `json:"additions"`
|
||||
}
|
||||
|
||||
// Change describes one compatibility decision. Before and After are concise,
|
||||
// human-readable values intended for CI annotations.
|
||||
type Change struct {
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
Flag string `json:"flag,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// CompareAll compares current against every named reference in one pass.
|
||||
// Additions are reported but remain compatible.
|
||||
func CompareAll(current Snapshot, references map[string]Snapshot) Report {
|
||||
labels := make([]string, 0, len(references))
|
||||
for label := range references {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
|
||||
report := Report{Compatible: true, Comparisons: []Comparison{}}
|
||||
for _, label := range labels {
|
||||
comparison := Compare(current, references[label], label)
|
||||
report.Comparisons = append(report.Comparisons, comparison)
|
||||
if !comparison.Compatible {
|
||||
report.Compatible = false
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// Compare enforces the deliberately small admission policy:
|
||||
// - every previously accepted command path must still resolve to a runnable,
|
||||
// visible-compatible target (a rename may preserve the old path as an
|
||||
// alias),
|
||||
// - flags accepted at each command path may not disappear, change type, or
|
||||
// become required; an existing path may not gain a new required flag,
|
||||
// - new commands and flags are allowed.
|
||||
//
|
||||
// Comparing the effective local + inherited set is intentional. It catches a
|
||||
// persistent flag whose scope is accidentally narrowed to its declaring
|
||||
// command, while allowing a local flag to move to an ancestor without breaking
|
||||
// the old invocation path.
|
||||
func Compare(current, baseline Snapshot, reference string) Comparison {
|
||||
result := Comparison{
|
||||
Reference: reference,
|
||||
Compatible: true,
|
||||
Blocking: []Change{},
|
||||
Additions: []Change{},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(current.Rules, baseline.Rules) {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "snapshot_rules_changed",
|
||||
Path: "dws",
|
||||
Before: fmt.Sprintf("%v", baseline.Rules),
|
||||
After: fmt.Sprintf("%v", current.Rules),
|
||||
})
|
||||
}
|
||||
result.Blocking = append(result.Blocking, aliasCollisionChanges(current)...)
|
||||
|
||||
currentCommands := commandIndex(current)
|
||||
baselineCommands := commandIndex(baseline)
|
||||
currentAccepted := acceptedPathIndex(current)
|
||||
baselineAccepted := acceptedPathIndex(baseline)
|
||||
|
||||
removedPaths := make([]string, 0)
|
||||
for path := range baselineAccepted {
|
||||
if _, ok := currentAccepted[path]; !ok {
|
||||
removedPaths = append(removedPaths, path)
|
||||
}
|
||||
}
|
||||
for _, path := range minimalPaths(removedPaths) {
|
||||
kind := "command_alias_removed"
|
||||
if _, canonical := baselineCommands[path]; canonical {
|
||||
kind = "command_removed"
|
||||
}
|
||||
result.Blocking = append(result.Blocking, Change{Kind: kind, Path: path})
|
||||
}
|
||||
|
||||
mappedCurrent := make(map[string]bool)
|
||||
for _, acceptedPath := range sortedAcceptedPaths(baselineAccepted) {
|
||||
oldPath := baselineAccepted[acceptedPath]
|
||||
oldCommand := baselineCommands[oldPath]
|
||||
newPath, ok := currentAccepted[acceptedPath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newCommand, ok := currentCommands[newPath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mappedCurrent[newPath] = true
|
||||
compareCommandContract(&result, acceptedPath, oldCommand, newCommand)
|
||||
compareEffectiveFlags(&result, acceptedPath, oldCommand, newCommand)
|
||||
}
|
||||
|
||||
for _, path := range sortedCommandPaths(current.Commands) {
|
||||
if mappedCurrent[path] {
|
||||
continue
|
||||
}
|
||||
if _, existed := baselineCommands[path]; !existed {
|
||||
result.Additions = append(result.Additions, Change{Kind: "command_added", Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
sortChanges(result.Blocking)
|
||||
sortChanges(result.Additions)
|
||||
result.Blocking = dedupeChanges(result.Blocking)
|
||||
result.Additions = dedupeChanges(result.Additions)
|
||||
result.Compatible = len(result.Blocking) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
|
||||
if oldCommand.Runnable && !newCommand.Runnable {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "command_became_non_runnable",
|
||||
Path: acceptedPath,
|
||||
Before: "runnable",
|
||||
After: "non-runnable",
|
||||
})
|
||||
}
|
||||
if !oldCommand.Hidden && newCommand.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "command_became_hidden",
|
||||
Path: acceptedPath,
|
||||
Before: "visible",
|
||||
After: "hidden",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
|
||||
oldFlags := effectiveFlagIndex(oldCommand)
|
||||
newFlags := effectiveFlagIndex(newCommand)
|
||||
|
||||
for _, name := range sortedFlagNames(oldFlags) {
|
||||
oldFlag := oldFlags[name]
|
||||
newFlag, ok := newFlags[name]
|
||||
if !ok {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_removed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if oldFlag.Type != newFlag.Type {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_required",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "optional",
|
||||
After: "required",
|
||||
})
|
||||
}
|
||||
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_shorthand_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Shorthand,
|
||||
After: newFlag.Shorthand,
|
||||
})
|
||||
}
|
||||
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_no_opt_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.NoOpt,
|
||||
After: newFlag.NoOpt,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_hidden",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "visible",
|
||||
After: "hidden",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range sortedFlagNames(newFlags) {
|
||||
newFlag := newFlags[name]
|
||||
if _, existed := oldFlags[name]; existed {
|
||||
continue
|
||||
}
|
||||
if newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "required_flag_added",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "absent",
|
||||
After: "required",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Report optional additions once where they are declared. Required
|
||||
// additions were handled against the effective set above because they can
|
||||
// break every descendant invocation.
|
||||
newLocalFlags := flagIndex(newCommand.LocalFlags)
|
||||
for _, name := range sortedFlagNames(newLocalFlags) {
|
||||
newFlag := newLocalFlags[name]
|
||||
if _, existed := oldFlags[name]; existed || newFlag.Required {
|
||||
continue
|
||||
}
|
||||
result.Additions = append(result.Additions, Change{Kind: "flag_added", Path: newCommand.Path, Flag: name})
|
||||
}
|
||||
}
|
||||
|
||||
func commandIndex(snapshot Snapshot) map[string]Command {
|
||||
out := make(map[string]Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
out[command.Path] = command
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aliasCollisionChanges(snapshot Snapshot) []Change {
|
||||
acceptedSiblings := make(map[string]string)
|
||||
changes := []Change{}
|
||||
for _, command := range snapshot.Commands {
|
||||
parent, name := splitParent(command.Path)
|
||||
for _, acceptedName := range append([]string{name}, command.Aliases...) {
|
||||
acceptedName = strings.TrimSpace(acceptedName)
|
||||
if acceptedName == "" {
|
||||
continue
|
||||
}
|
||||
key := parent + "\x00" + acceptedName
|
||||
if previous, exists := acceptedSiblings[key]; exists && previous != command.Path {
|
||||
changes = append(changes, Change{
|
||||
Kind: "command_alias_collision",
|
||||
Path: strings.TrimSpace(parent + " " + acceptedName),
|
||||
Before: previous,
|
||||
After: command.Path,
|
||||
})
|
||||
continue
|
||||
}
|
||||
acceptedSiblings[key] = command.Path
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
// acceptedPathIndex expands aliases at every path segment. For example, if
|
||||
// "dws chat" has alias "im", then "dws im message send" remains accepted for
|
||||
// every descendant even though descendants only store their canonical paths.
|
||||
func acceptedPathIndex(snapshot Snapshot) map[string]string {
|
||||
commands := append([]Command(nil), snapshot.Commands...)
|
||||
sort.Slice(commands, func(i, j int) bool {
|
||||
leftDepth, rightDepth := pathDepth(commands[i].Path), pathDepth(commands[j].Path)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return commands[i].Path < commands[j].Path
|
||||
})
|
||||
|
||||
variants := make(map[string][]string, len(commands))
|
||||
accepted := make(map[string]string)
|
||||
// Canonical paths always win over an alias collision.
|
||||
for _, command := range commands {
|
||||
accepted[command.Path] = command.Path
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
parent, name := splitParent(command.Path)
|
||||
names := compactSorted(append([]string{name}, command.Aliases...))
|
||||
parentVariants := variants[parent]
|
||||
if parent == "" {
|
||||
parentVariants = []string{""}
|
||||
} else if len(parentVariants) == 0 {
|
||||
parentVariants = []string{parent}
|
||||
}
|
||||
|
||||
commandVariants := make([]string, 0, len(parentVariants)*len(names))
|
||||
for _, parentVariant := range parentVariants {
|
||||
for _, candidateName := range names {
|
||||
path := strings.TrimSpace(parentVariant + " " + candidateName)
|
||||
commandVariants = append(commandVariants, path)
|
||||
if _, canonical := accepted[path]; !canonical {
|
||||
accepted[path] = command.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
variants[command.Path] = compactSorted(commandVariants)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func flagIndex(flags []Flag) map[string]Flag {
|
||||
out := make(map[string]Flag, len(flags))
|
||||
for _, flag := range flags {
|
||||
out[flag.Name] = flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func effectiveFlagIndex(command Command) map[string]Flag {
|
||||
out := flagIndex(command.InheritedFlags)
|
||||
for _, flag := range command.LocalFlags {
|
||||
// A local flag is the callable definition when it shadows an inherited
|
||||
// flag with the same name.
|
||||
out[flag.Name] = flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCommandPaths(commands []Command) []string {
|
||||
paths := make([]string, 0, len(commands))
|
||||
for _, command := range commands {
|
||||
paths = append(paths, command.Path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedAcceptedPaths(accepted map[string]string) []string {
|
||||
paths := make([]string, 0, len(accepted))
|
||||
for path := range accepted {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedFlagNames(flags map[string]Flag) []string {
|
||||
names := make([]string, 0, len(flags))
|
||||
for name := range flags {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func minimalPaths(paths []string) []string {
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
leftDepth, rightDepth := pathDepth(paths[i]), pathDepth(paths[j])
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return paths[i] < paths[j]
|
||||
})
|
||||
minimal := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
covered := false
|
||||
for _, parent := range minimal {
|
||||
if strings.HasPrefix(path, parent+" ") {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
minimal = append(minimal, path)
|
||||
}
|
||||
}
|
||||
return minimal
|
||||
}
|
||||
|
||||
func splitParent(path string) (string, string) {
|
||||
index := strings.LastIndexByte(path, ' ')
|
||||
if index < 0 {
|
||||
return "", path
|
||||
}
|
||||
return path[:index], path[index+1:]
|
||||
}
|
||||
|
||||
func pathDepth(path string) int {
|
||||
return len(strings.Fields(path))
|
||||
}
|
||||
|
||||
func sortChanges(changes []Change) {
|
||||
sort.Slice(changes, func(i, j int) bool {
|
||||
left := changes[i].Path + "\x00" + changes[i].Flag + "\x00" + changes[i].Kind
|
||||
right := changes[j].Path + "\x00" + changes[j].Flag + "\x00" + changes[j].Kind
|
||||
return left < right
|
||||
})
|
||||
}
|
||||
|
||||
func dedupeChanges(changes []Change) []Change {
|
||||
if len(changes) < 2 {
|
||||
return changes
|
||||
}
|
||||
out := changes[:1]
|
||||
for _, change := range changes[1:] {
|
||||
if change == out[len(out)-1] {
|
||||
continue
|
||||
}
|
||||
out = append(out, change)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareAdmissionPolicy(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
compatible bool
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "command addition allowed",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
testCommand("dws status"),
|
||||
),
|
||||
compatible: true,
|
||||
},
|
||||
{
|
||||
name: "flag addition allowed",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search",
|
||||
testFlag("limit", "int", false),
|
||||
testFlag("query", "string", false),
|
||||
),
|
||||
),
|
||||
compatible: true,
|
||||
},
|
||||
{
|
||||
name: "required flag addition blocked on existing command",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search",
|
||||
testFlag("query", "string", false),
|
||||
testFlag("tenant", "string", true),
|
||||
),
|
||||
),
|
||||
kind: "required_flag_added",
|
||||
},
|
||||
{
|
||||
name: "command deletion blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
),
|
||||
kind: "command_removed",
|
||||
},
|
||||
{
|
||||
name: "rename without alias blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws find", testFlag("query", "string", false)),
|
||||
),
|
||||
kind: "command_removed",
|
||||
},
|
||||
{
|
||||
name: "flag deletion blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search"),
|
||||
),
|
||||
kind: "flag_removed",
|
||||
},
|
||||
{
|
||||
name: "optional becoming required blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", true)),
|
||||
),
|
||||
kind: "flag_became_required",
|
||||
},
|
||||
{
|
||||
name: "flag type change blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "stringSlice", false)),
|
||||
),
|
||||
kind: "flag_type_changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
comparison := Compare(test.current, base, "base")
|
||||
if comparison.Compatible != test.compatible {
|
||||
t.Fatalf("Compatible = %v, want %v; blocking=%#v", comparison.Compatible, test.compatible, comparison.Blocking)
|
||||
}
|
||||
if test.kind == "" {
|
||||
return
|
||||
}
|
||||
if !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareAllowsRenameWhenOldPathIsAlias(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws find", []string{"search"}, testFlag("query", "string", false)),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if !comparison.Compatible {
|
||||
t.Fatalf("rename with compatibility alias was blocked: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksRemovedAlias(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search"),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_removed") {
|
||||
t.Fatalf("removed alias was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksAliasRetargetedToIncompatibleCommand(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
testCommand("dws find"),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws find", "query") {
|
||||
t.Fatalf("alias retarget was not checked against its old contract: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksSnapshotRuleChanges(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(testCommand("dws"))
|
||||
current.Rules.ExcludedFlags = append(current.Rules.ExcludedFlags, "legacy")
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "snapshot_rules_changed") {
|
||||
t.Fatalf("snapshot rule change was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksCallableMetadataRegressions(t *testing.T) {
|
||||
baseFlag := testFlag("format", "string", false)
|
||||
baseFlag.Shorthand = "f"
|
||||
baseFlag.NoOpt = "json"
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws export", baseFlag),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command Command
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "command became non-runnable",
|
||||
command: func() Command {
|
||||
command := testCommand("dws export", baseFlag)
|
||||
command.Runnable = false
|
||||
return command
|
||||
}(),
|
||||
kind: "command_became_non_runnable",
|
||||
},
|
||||
{
|
||||
name: "command became hidden",
|
||||
command: func() Command {
|
||||
command := testCommand("dws export", baseFlag)
|
||||
command.Hidden = true
|
||||
return command
|
||||
}(),
|
||||
kind: "command_became_hidden",
|
||||
},
|
||||
{
|
||||
name: "flag shorthand removed",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.Shorthand = ""
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_shorthand_changed",
|
||||
},
|
||||
{
|
||||
name: "flag no-opt behavior removed",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.NoOpt = ""
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_no_opt_changed",
|
||||
},
|
||||
{
|
||||
name: "flag became hidden",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.Hidden = true
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := testSnapshot(testCommand("dws"), test.command)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("metadata regression %q was not blocked: %#v", test.kind, comparison)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
testCommandWithFlagScopes(
|
||||
"dws search",
|
||||
[]Flag{testFlag("query", "string", false)},
|
||||
[]Flag{testFlag("profile", "string", false)},
|
||||
),
|
||||
)
|
||||
|
||||
t.Run("persistent flag scope narrowing is blocked", func(t *testing.T) {
|
||||
current := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws search", "profile") {
|
||||
t.Fatalf("lost inherited flag was not blocked: %#v", comparison)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local flag moved to inherited remains compatible", func(t *testing.T) {
|
||||
current := testSnapshot(
|
||||
testCommand("dws",
|
||||
testFlag("profile", "string", false),
|
||||
testFlag("query", "string", false),
|
||||
),
|
||||
testCommandWithFlagScopes(
|
||||
"dws search",
|
||||
nil,
|
||||
[]Flag{
|
||||
testFlag("profile", "string", false),
|
||||
testFlag("query", "string", false),
|
||||
},
|
||||
),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if !comparison.Compatible {
|
||||
t.Fatalf("flag moved to an inherited scope was blocked: %#v", comparison.Blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompareAllRequiresBothReferencesToPass(t *testing.T) {
|
||||
current := testSnapshot(testCommand("dws"), testCommand("dws status"))
|
||||
mergeBase := testSnapshot(testCommand("dws"))
|
||||
stable := testSnapshot(testCommand("dws"), testCommand("dws legacy"))
|
||||
|
||||
report := CompareAll(current, map[string]Snapshot{
|
||||
"stable": stable,
|
||||
"merge-base": mergeBase,
|
||||
})
|
||||
if report.Compatible {
|
||||
t.Fatal("aggregate report passed even though stable comparison removed a command")
|
||||
}
|
||||
if len(report.Comparisons) != 2 || report.Comparisons[0].Reference != "merge-base" || report.Comparisons[1].Reference != "stable" {
|
||||
t.Fatalf("comparisons are not deterministic: %#v", report.Comparisons)
|
||||
}
|
||||
if !report.Comparisons[0].Compatible || report.Comparisons[1].Compatible {
|
||||
t.Fatalf("unexpected per-reference results: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func testCommand(path string, flags ...Flag) Command {
|
||||
return testCommandWithAliases(path, nil, flags...)
|
||||
}
|
||||
|
||||
func testCommandWithAliases(path string, aliases []string, flags ...Flag) Command {
|
||||
return Command{
|
||||
Path: path,
|
||||
Runnable: true,
|
||||
Aliases: aliases,
|
||||
LocalFlags: flags,
|
||||
InheritedFlags: []Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func testCommandWithFlagScopes(path string, local, inherited []Flag) Command {
|
||||
return Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: local,
|
||||
InheritedFlags: inherited,
|
||||
}
|
||||
}
|
||||
|
||||
func testFlag(name, flagType string, required bool) Flag {
|
||||
return Flag{Name: name, Type: flagType, Required: required}
|
||||
}
|
||||
|
||||
func hasChangeKind(changes []Change, kind string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasFlagChange(changes []Change, kind, path, flag string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind && change.Path == path && change.Flag == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package interfacesnapshot captures and compares the public Cobra command
|
||||
// surface without executing commands or contacting runtime services.
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
var (
|
||||
// Framework-owned command subtrees are deliberately excluded. They are
|
||||
// generated by Cobra or shell-completion plumbing rather than DWS product
|
||||
// code, so changes to them are not command-surface compatibility changes.
|
||||
excludedCommandSubtrees = []string{
|
||||
"dws __complete",
|
||||
"dws __completeNoDesc",
|
||||
"dws completion",
|
||||
"dws help",
|
||||
}
|
||||
|
||||
// Cobra installs --help lazily on every command. Keeping it would make the
|
||||
// snapshot depend on whether help happened to be rendered before capture.
|
||||
excludedFlags = map[string]bool{"help": true}
|
||||
)
|
||||
|
||||
// Rules records the noise filtering contract in every snapshot. A future rule
|
||||
// change is therefore visible instead of silently changing comparison scope.
|
||||
type Rules struct {
|
||||
ExcludedCommandSubtrees []string `json:"excluded_command_subtrees"`
|
||||
ExcludedFlags []string `json:"excluded_flags"`
|
||||
}
|
||||
|
||||
// Snapshot is a deterministic representation of a Cobra command tree.
|
||||
type Snapshot struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Rules Rules `json:"rules"`
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// Command contains compatibility-relevant command metadata. Path always
|
||||
// includes the root command name (for example, "dws chat message send").
|
||||
type Command struct {
|
||||
Path string `json:"path"`
|
||||
Runnable bool `json:"runnable"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
Aliases []string `json:"aliases"`
|
||||
LocalFlags []Flag `json:"local_flags"`
|
||||
InheritedFlags []Flag `json:"inherited_flags"`
|
||||
}
|
||||
|
||||
// Flag contains the stable pflag contract visible at a command node.
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Default string `json:"default"`
|
||||
NoOpt string `json:"no_opt,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
}
|
||||
|
||||
// Capture walks root without rendering help or executing any command. Both
|
||||
// hidden compatibility commands and hidden flags are retained unless they are
|
||||
// covered by the explicit framework-noise rules above.
|
||||
func Capture(root *cobra.Command) Snapshot {
|
||||
snapshot := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: []Command{},
|
||||
}
|
||||
if root == nil {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// --version is also installed lazily by Cobra, but unlike --help it is a
|
||||
// real root CLI contract and should be captured.
|
||||
root.InitDefaultVersionFlag()
|
||||
|
||||
var walk func(*cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
path := normalizePath(cmd.CommandPath())
|
||||
if commandExcluded(path) {
|
||||
return
|
||||
}
|
||||
|
||||
aliases := append([]string{}, cmd.Aliases...)
|
||||
for i := range aliases {
|
||||
aliases[i] = strings.TrimSpace(aliases[i])
|
||||
}
|
||||
aliases = compactSorted(aliases)
|
||||
|
||||
snapshot.Commands = append(snapshot.Commands, Command{
|
||||
Path: path,
|
||||
Runnable: cmd.Runnable(),
|
||||
Hidden: cmd.Hidden,
|
||||
Deprecated: strings.TrimSpace(cmd.Deprecated),
|
||||
Aliases: aliases,
|
||||
LocalFlags: captureFlags(cmd.LocalFlags()),
|
||||
InheritedFlags: captureFlags(cmd.InheritedFlags()),
|
||||
})
|
||||
|
||||
children := append([]*cobra.Command(nil), cmd.Commands()...)
|
||||
sort.Slice(children, func(i, j int) bool {
|
||||
return children[i].Name() < children[j].Name()
|
||||
})
|
||||
for _, child := range children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
sort.Slice(snapshot.Commands, func(i, j int) bool {
|
||||
return snapshot.Commands[i].Path < snapshot.Commands[j].Path
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// Write emits canonical, indented JSON with a trailing newline.
|
||||
func Write(w io.Writer, snapshot Snapshot) error {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(snapshot)
|
||||
}
|
||||
|
||||
// Read decodes and validates a snapshot. Unknown fields are rejected so a
|
||||
// comparison never silently ignores a newer contract it does not understand.
|
||||
func Read(r io.Reader) (Snapshot, error) {
|
||||
var snapshot Snapshot
|
||||
decoder := json.NewDecoder(r)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return Snapshot{}, fmt.Errorf("interface snapshot contains multiple JSON values")
|
||||
}
|
||||
return Snapshot{}, fmt.Errorf("read trailing interface snapshot data: %w", err)
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// Validate checks the invariants needed by the comparison algorithm.
|
||||
func (s Snapshot) Validate() error {
|
||||
if s.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported interface snapshot schema_version %d (want %d)", s.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
seenCommands := make(map[string]bool, len(s.Commands))
|
||||
for _, command := range s.Commands {
|
||||
if command.Path == "" {
|
||||
return fmt.Errorf("interface snapshot contains an empty command path")
|
||||
}
|
||||
if seenCommands[command.Path] {
|
||||
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
|
||||
}
|
||||
seenCommands[command.Path] = true
|
||||
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureFlags(set *pflag.FlagSet) []Flag {
|
||||
flags := []Flag{}
|
||||
if set == nil {
|
||||
return flags
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || excludedFlags[flag.Name] {
|
||||
return
|
||||
}
|
||||
flags = append(flags, Flag{
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
})
|
||||
})
|
||||
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
|
||||
return flags
|
||||
}
|
||||
|
||||
func isRequired(flag *pflag.Flag) bool {
|
||||
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
|
||||
if value == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandExcluded(path string) bool {
|
||||
for _, prefix := range excludedCommandSubtrees {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePath(path string) string {
|
||||
return strings.Join(strings.Fields(path), " ")
|
||||
}
|
||||
|
||||
func compactSorted(values []string) []string {
|
||||
sort.Strings(values)
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if value == "" || (len(out) > 0 && out[len(out)-1] == value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateFlags(path, scope string, flags []Flag) error {
|
||||
seen := make(map[string]bool, len(flags))
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "" {
|
||||
return fmt.Errorf("command %q contains an empty %s flag name", path, scope)
|
||||
}
|
||||
if seen[flag.Name] {
|
||||
return fmt.Errorf("command %q contains duplicate %s flag %q", path, scope, flag.Name)
|
||||
}
|
||||
seen[flag.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCaptureUsesStableNoiseRulesAndFlagScopes(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Version: "test"}
|
||||
root.PersistentFlags().String("profile", "", "profile")
|
||||
|
||||
service := &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc", "api", "svc"},
|
||||
Hidden: true,
|
||||
Deprecated: "use replacement",
|
||||
}
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("name", "", "name")
|
||||
leaf.Flags().Bool("force", false, "force")
|
||||
if err := leaf.MarkFlagRequired("name"); err != nil {
|
||||
t.Fatalf("MarkFlagRequired: %v", err)
|
||||
}
|
||||
leaf.InitDefaultHelpFlag()
|
||||
service.AddCommand(leaf)
|
||||
|
||||
help := &cobra.Command{Use: "help"}
|
||||
completion := &cobra.Command{Use: "completion"}
|
||||
completion.AddCommand(&cobra.Command{Use: "zsh"})
|
||||
root.AddCommand(service, help, completion)
|
||||
|
||||
snapshot := Capture(root)
|
||||
wantPaths := []string{"dws", "dws service", "dws service run"}
|
||||
if got := sortedCommandPaths(snapshot.Commands); !reflect.DeepEqual(got, wantPaths) {
|
||||
t.Fatalf("paths = %v, want %v", got, wantPaths)
|
||||
}
|
||||
|
||||
serviceSnapshot := commandIndex(snapshot)["dws service"]
|
||||
if !serviceSnapshot.Hidden || serviceSnapshot.Deprecated != "use replacement" {
|
||||
t.Fatalf("service metadata = %#v", serviceSnapshot)
|
||||
}
|
||||
if want := []string{"api", "svc"}; !reflect.DeepEqual(serviceSnapshot.Aliases, want) {
|
||||
t.Fatalf("aliases = %v, want %v", serviceSnapshot.Aliases, want)
|
||||
}
|
||||
|
||||
leafSnapshot := commandIndex(snapshot)["dws service run"]
|
||||
if !leafSnapshot.Runnable {
|
||||
t.Fatal("runnable leaf was not recorded as runnable")
|
||||
}
|
||||
local := flagIndex(leafSnapshot.LocalFlags)
|
||||
if len(local) != 2 {
|
||||
t.Fatalf("local flags = %#v, want two business flags and no auto help flag", leafSnapshot.LocalFlags)
|
||||
}
|
||||
if local["name"].Type != "string" || !local["name"].Required {
|
||||
t.Fatalf("name flag = %#v, want required string", local["name"])
|
||||
}
|
||||
if local["force"].Type != "bool" || local["force"].Required {
|
||||
t.Fatalf("force flag = %#v, want optional bool", local["force"])
|
||||
}
|
||||
inherited := flagIndex(leafSnapshot.InheritedFlags)
|
||||
if inherited["profile"].Type != "string" {
|
||||
t.Fatalf("inherited flags = %#v, want root --profile", leafSnapshot.InheritedFlags)
|
||||
}
|
||||
if _, exists := inherited["help"]; exists {
|
||||
t.Fatal("auto --help leaked into inherited flags")
|
||||
}
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := Write(&first, snapshot); err != nil {
|
||||
t.Fatalf("first Write: %v", err)
|
||||
}
|
||||
if err := Write(&second, Capture(root)); err != nil {
|
||||
t.Fatalf("second Write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first.Bytes(), second.Bytes()) {
|
||||
t.Fatal("capturing the same Cobra root twice produced different JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
input := bytes.NewBufferString(`{
|
||||
"schema_version": 1,
|
||||
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
|
||||
"commands": [],
|
||||
"future_field": true
|
||||
}`)
|
||||
if _, err := Read(input); err == nil {
|
||||
t.Fatal("Read accepted an unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}),
|
||||
testCommand("dws find"),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_collision") {
|
||||
t.Fatalf("candidate sibling alias collision was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -6,13 +6,15 @@ These repo-local entrypoints are the supported shell entrypoints for building, t
|
||||
- `make test`: run `go test ./...`
|
||||
- `make lint`: run formatting checks and required `golangci-lint`
|
||||
- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/`
|
||||
- `make package`: build all release artifacts locally via `goreleaser --snapshot`
|
||||
- `make release`: build and publish a release via `goreleaser`
|
||||
- `make package`: build all release artifacts locally
|
||||
- `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag)
|
||||
- `make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N`: validate a stable promotion (`PUBLISH=1` pushes its tag)
|
||||
|
||||
Script groups:
|
||||
|
||||
- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`
|
||||
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
|
||||
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
|
||||
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-open-source-assets.sh`
|
||||
- Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
|
||||
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-open-source-assets.sh`
|
||||
- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md)
|
||||
- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Compare the current Cobra command surface with two local Git revisions:
|
||||
# the PR merge-base/main reference and the latest stable GA tag. The caller must
|
||||
# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag
|
||||
# governance treats that tag as proof of a successfully published GA release;
|
||||
# release automation must not leave a GA tag behind when publication fails.
|
||||
#
|
||||
# The script never fetches refs or snapshots from the network. With checkout
|
||||
# fetch-depth=0, both references are materialized in temporary worktrees. A
|
||||
# reference uses its own snapshot helper when available; older revisions are
|
||||
# bootstrapped with the candidate helper.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
BASE_REF=""
|
||||
STABLE_REF=""
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref>" >&2
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--base-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
BASE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stable-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
STABLE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'unknown argument: %s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; }
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || {
|
||||
printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")"
|
||||
BASE_WORKTREE="$TMP_ROOT/base-worktree"
|
||||
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
|
||||
|
||||
cleanup() {
|
||||
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
|
||||
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
ensure_snapshot_helper() {
|
||||
worktree="$1"
|
||||
|
||||
# Once a baseline contains the helper, use that revision's own capture
|
||||
# rules. This makes a rule/filter change in the candidate visible as a
|
||||
# snapshot_rules_changed failure instead of applying the new exclusion to
|
||||
# both sides and accidentally hiding a removed command. Older revisions
|
||||
# created before this helper existed are bootstrapped with the candidate
|
||||
# implementation.
|
||||
if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \
|
||||
[ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \
|
||||
[ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then
|
||||
return
|
||||
fi
|
||||
if [ -e "$worktree/cmd/interface-snapshot" ] || \
|
||||
[ -e "$worktree/internal/interfacesnapshot" ]; then
|
||||
printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot"
|
||||
cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/"
|
||||
cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/"
|
||||
}
|
||||
|
||||
generate_ref_snapshot() {
|
||||
ref="$1"
|
||||
worktree="$2"
|
||||
output="$3"
|
||||
|
||||
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
|
||||
ensure_snapshot_helper "$worktree"
|
||||
(
|
||||
cd "$worktree"
|
||||
go run ./cmd/interface-snapshot generate --output "$output"
|
||||
)
|
||||
}
|
||||
|
||||
CANDIDATE="$TMP_ROOT/candidate.json"
|
||||
BASELINE="$TMP_ROOT/merge-base.json"
|
||||
STABLE="$TMP_ROOT/stable.json"
|
||||
|
||||
go run ./cmd/interface-snapshot generate --output "$CANDIDATE"
|
||||
generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE"
|
||||
generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE"
|
||||
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
--current "$CANDIDATE" \
|
||||
--base "$BASELINE" \
|
||||
--stable "$STABLE"
|
||||
@@ -1,50 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build release artifacts locally in the current runner and publish them to Gitee.
|
||||
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
SCRIPT_ROOT="$ROOT"
|
||||
cd "$SCRIPT_ROOT"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || {
|
||||
echo "error: VERSION is required or HEAD must be an exact release tag" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$VERSION" in
|
||||
v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;;
|
||||
*) TAG="v$VERSION"; SEMVER="$VERSION" ;;
|
||||
esac
|
||||
|
||||
git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true
|
||||
target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)"
|
||||
[ -n "$target_commit" ] || {
|
||||
echo "error: could not resolve tag ${TAG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
current_commit="$(git rev-parse HEAD)"
|
||||
WORKDIR="$SCRIPT_ROOT"
|
||||
cleanup_worktree() {
|
||||
if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then
|
||||
git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup_worktree EXIT
|
||||
|
||||
if [ "$current_commit" != "$target_commit" ]; then
|
||||
WORKDIR="$(mktemp -d)"
|
||||
rm -rf "$WORKDIR"
|
||||
git worktree add --detach "$WORKDIR" "$TAG"
|
||||
mkdir -p "$WORKDIR/scripts/release"
|
||||
cp "$SCRIPT_ROOT/scripts/release/publish-gitee-local.sh" "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
chmod +x "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
echo "==> Building ${TAG} locally for Gitee"
|
||||
VERSION="$SEMVER" ./scripts/dev/build-all.sh
|
||||
DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh
|
||||
VERSION="$TAG" ./scripts/release/publish-gitee-local.sh
|
||||
printf '%s\n' \
|
||||
'Direct Gitee release builds are disabled.' \
|
||||
'Use make release-pre/release-stable; release.yml mirrors the exact immutable GitHub assets.' >&2
|
||||
exit 2
|
||||
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
VERSION=""
|
||||
FROM_BETA=""
|
||||
REMOTE=""
|
||||
MODE="check"
|
||||
MODE_SET=0
|
||||
WIZARD=0
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: dws-release [version] [options]
|
||||
dws-release config [--remote <name>]
|
||||
|
||||
With no arguments, starts an interactive release guide. For a version that has
|
||||
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
|
||||
full guarded preflight; add --publish to publish after the preflight succeeds.
|
||||
|
||||
Options:
|
||||
--from-beta <tag> Required stable promotion baseline
|
||||
--remote <name> Override the configured release remote
|
||||
--check Validate only (default)
|
||||
--publish Run the preflight, then create and push the tag
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
dws-release config --remote origin
|
||||
dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
EOF
|
||||
}
|
||||
|
||||
die_usage() {
|
||||
printf '%s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
is_interactive() {
|
||||
[ -t 0 ] && [ -t 1 ]
|
||||
}
|
||||
|
||||
repository_identity() {
|
||||
identity_url="$1"
|
||||
case "$identity_url" in
|
||||
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
|
||||
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
|
||||
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
|
||||
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
|
||||
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
|
||||
*) printf '%s\n' "$identity_url"; return 0 ;;
|
||||
esac
|
||||
identity_path="${identity_path%/}"
|
||||
identity_path="${identity_path%.git}"
|
||||
printf 'github.com/%s\n' "$identity_path"
|
||||
}
|
||||
|
||||
release_remote_identity() {
|
||||
identity_remote="$1"
|
||||
identity_fetch_url="$(git remote get-url "$identity_remote" 2>/dev/null)" || {
|
||||
printf 'configured release remote does not exist: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_urls="$(git remote get-url --push --all "$identity_remote" 2>/dev/null)" || {
|
||||
printf 'could not resolve push URL for release remote: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_count="$(printf '%s\n' "$identity_push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||
[ "$identity_push_count" -eq 1 ] || {
|
||||
printf 'release remote must have exactly one push URL: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_url="$(printf '%s\n' "$identity_push_urls" | sed -n '1p')"
|
||||
identity_fetch="$(repository_identity "$identity_fetch_url")"
|
||||
identity_push="$(repository_identity "$identity_push_url")"
|
||||
[ "$identity_fetch" = "$identity_push" ] || {
|
||||
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' \
|
||||
"$identity_fetch_url" "$identity_push_url" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$identity_fetch"
|
||||
}
|
||||
|
||||
set_mode() {
|
||||
requested_mode="$1"
|
||||
if [ "$MODE_SET" -eq 1 ] && [ "$MODE" != "$requested_mode" ]; then
|
||||
die_usage '--check and --publish cannot be used together'
|
||||
fi
|
||||
MODE="$requested_mode"
|
||||
MODE_SET=1
|
||||
}
|
||||
|
||||
require_remote() {
|
||||
if [ -z "$REMOTE" ]; then
|
||||
REMOTE="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$REMOTE" ] && is_interactive; then
|
||||
printf 'Configured remotes:\n' >&2
|
||||
git remote -v >&2
|
||||
printf 'Release remote: ' >&2
|
||||
IFS= read -r REMOTE
|
||||
fi
|
||||
[ -n "$REMOTE" ] || {
|
||||
printf '%s\n' 'release remote is not configured; run: dws-release config --remote <name>' >&2
|
||||
exit 1
|
||||
}
|
||||
expected_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
|
||||
[ -n "$expected_repository" ] || {
|
||||
printf '%s\n' 'release repository identity is not configured; rerun: dws-release config --remote <name>' >&2
|
||||
exit 1
|
||||
}
|
||||
actual_repository="$(release_remote_identity "$REMOTE")" || exit 1
|
||||
[ "$actual_repository" = "$expected_repository" ] || {
|
||||
printf 'release remote %s changed repository identity:\n configured: %s\n current: %s\n' \
|
||||
"$REMOTE" "$expected_repository" "$actual_repository" >&2
|
||||
printf '%s\n' 'Review the remote and run dws-release config again only if this change is intentional.' >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
sync_main_if_safe() {
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "main" ] || {
|
||||
printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf '%s\n' 'release main worktree must be clean before synchronization' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf '==> Synchronizing %s/main\n' "$REMOTE"
|
||||
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
|
||||
remote_main="refs/remotes/$REMOTE/main"
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
remote_commit="$(git rev-parse "$remote_main^{commit}")"
|
||||
if [ "$head_commit" = "$remote_commit" ]; then
|
||||
return 0
|
||||
fi
|
||||
if git merge-base --is-ancestor HEAD "$remote_main"; then
|
||||
git merge --ff-only "$remote_main"
|
||||
return 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$remote_main" HEAD; then
|
||||
printf 'local main is ahead of %s/main; publish only after the commits are reviewed and pushed\n' "$REMOTE" >&2
|
||||
else
|
||||
printf 'local main has diverged from %s/main; resolve it explicitly before release\n' "$REMOTE" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
configure_remote() {
|
||||
shift
|
||||
config_remote=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote)
|
||||
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
|
||||
config_remote="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) die_usage "unknown config argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$ROOT"
|
||||
if [ -z "$config_remote" ]; then
|
||||
configured="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
|
||||
configured_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
|
||||
printf 'Configured release remote: %s\n' "${configured:-none}"
|
||||
printf 'Configured repository: %s\n' "${configured_repository:-none}"
|
||||
git remote -v
|
||||
exit 0
|
||||
fi
|
||||
configured_repository="$(release_remote_identity "$config_remote")" || exit 1
|
||||
git config --local dws.releaseRemote "$config_remote"
|
||||
git config --local dws.releaseRepository "$configured_repository"
|
||||
printf 'Configured release remote: %s\n' "$config_remote"
|
||||
printf 'Configured repository: %s\n' "$configured_repository"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "config" ]; then
|
||||
configure_remote "$@"
|
||||
fi
|
||||
|
||||
[ "$#" -gt 0 ] || WIZARD=1
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from-beta)
|
||||
[ "$#" -ge 2 ] || die_usage '--from-beta requires a value'
|
||||
FROM_BETA="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote)
|
||||
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
|
||||
REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--check) set_mode check; shift ;;
|
||||
--publish) set_mode publish; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) die_usage "unknown argument: $1" ;;
|
||||
*)
|
||||
[ -z "$VERSION" ] || die_usage "unexpected argument: $1"
|
||||
VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
is_interactive || { usage; exit 2; }
|
||||
printf 'Release version (vX.Y.Z-beta.N or vX.Y.Z): ' >&2
|
||||
IFS= read -r VERSION
|
||||
fi
|
||||
|
||||
CHANNEL="$(release_channel_for_version "$VERSION")" || exit 2
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
if [ -z "$FROM_BETA" ] && is_interactive; then
|
||||
printf 'Validated beta tag for %s: ' "$VERSION" >&2
|
||||
IFS= read -r FROM_BETA
|
||||
fi
|
||||
[ -n "$FROM_BETA" ] || die_usage 'stable release requires --from-beta <tag>'
|
||||
release_validate_version_channel prerelease "$FROM_BETA" || exit 2
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 2
|
||||
}
|
||||
else
|
||||
[ -z "$FROM_BETA" ] || die_usage '--from-beta is only valid for stable releases'
|
||||
fi
|
||||
|
||||
if [ "$WIZARD" -eq 1 ]; then
|
||||
printf 'Mode [check/publish] (default: check): ' >&2
|
||||
IFS= read -r selected_mode
|
||||
case "$selected_mode" in
|
||||
''|check) MODE=check ;;
|
||||
publish) MODE=publish ;;
|
||||
*) die_usage "invalid mode: $selected_mode" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE is test-only and cannot be set through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "${DWS_RELEASE_OFFICIAL_TAGS_URL:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_OFFICIAL_TAGS_URL cannot override release authority through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "${DWS_RELEASE_OFFICIAL_REPOSITORY:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_OFFICIAL_REPOSITORY cannot override release authority through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "$ROOT"
|
||||
CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
MAIN_SYNCED=0
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
if [ "$current_branch" = "main" ]; then
|
||||
require_remote
|
||||
sync_main_if_safe
|
||||
MAIN_SYNCED=1
|
||||
fi
|
||||
semver="$(release_semver "$VERSION")"
|
||||
section_count="$(awk -v wanted="$semver" '
|
||||
BEGIN { prefix = "## [" wanted "] - "; count = 0 }
|
||||
index($0, prefix) == 1 { count++ }
|
||||
END { print count }
|
||||
' "$CHANGELOG")"
|
||||
|
||||
if [ "$section_count" -eq 0 ]; then
|
||||
printf '==> Preparing the missing CHANGELOG section for %s\n' "$VERSION"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
"$SCRIPT_DIR/prepare-changelog.sh" stable "$VERSION" --from-beta "$FROM_BETA"
|
||||
else
|
||||
"$SCRIPT_DIR/prepare-changelog.sh" prerelease "$VERSION"
|
||||
fi
|
||||
printf '\nCHANGELOG preparation is complete; publishing intentionally stopped.\n'
|
||||
printf 'Replace TODO, review the diff, commit it, and merge it to main. Then run dws-release for %s again.\n' "$VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-entry.XXXXXX")"
|
||||
cleanup() { rm -f "$notes_tmp"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
|
||||
|
||||
if [ "$MAIN_SYNCED" -ne 1 ]; then
|
||||
require_remote
|
||||
sync_main_if_safe
|
||||
fi
|
||||
|
||||
printf '==> DWS release entry\n'
|
||||
printf ' mode: %s\n' "$MODE"
|
||||
printf ' channel: %s\n' "$CHANNEL"
|
||||
printf ' version: %s\n' "$VERSION"
|
||||
printf ' remote: %s\n' "$REMOTE"
|
||||
[ -z "$FROM_BETA" ] || printf ' beta: %s\n' "$FROM_BETA"
|
||||
|
||||
set -- "$CHANNEL" "$VERSION" --remote "$REMOTE"
|
||||
if [ -n "$FROM_BETA" ]; then
|
||||
set -- "$@" --from-beta "$FROM_BETA"
|
||||
fi
|
||||
if [ "$MODE" = "publish" ]; then
|
||||
set -- "$@" --publish
|
||||
fi
|
||||
cleanup
|
||||
trap - EXIT HUP INT TERM
|
||||
exec "$SCRIPT_DIR/release.sh" "$@"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PACKAGE_DIR="${1:-}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
[ -n "$PACKAGE_DIR" ] && [ -n "$OUTPUT" ] || {
|
||||
printf 'usage: pack-npm-package.sh <package-dir> <output.tgz>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
[ -f "$PACKAGE_DIR/package.json" ] || {
|
||||
printf 'npm package manifest not found: %s/package.json\n' "$PACKAGE_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v npx >/dev/null 2>&1 || { printf 'npx is required\n' >&2; exit 1; }
|
||||
command -v node >/dev/null 2>&1 || { printf 'node is required\n' >&2; exit 1; }
|
||||
NPM_PACK_VERSION="10.9.2"
|
||||
|
||||
output_dir="$(CDPATH= cd -- "$(dirname -- "$OUTPUT")" && pwd)"
|
||||
output_name="$(basename -- "$OUTPUT")"
|
||||
rm -f "$output_dir/$output_name"
|
||||
|
||||
pack_json="$(
|
||||
npx --yes --package "npm@$NPM_PACK_VERSION" -- \
|
||||
npm pack "$PACKAGE_DIR" --pack-destination "$output_dir" --json --ignore-scripts
|
||||
)"
|
||||
pack_metadata="$(printf '%s' "$pack_json" | node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const entries = JSON.parse(input);
|
||||
if (!Array.isArray(entries) || entries.length !== 1) {
|
||||
throw new Error("npm pack did not return exactly one package");
|
||||
}
|
||||
const entry = entries[0];
|
||||
if (!entry.filename || !entry.integrity) {
|
||||
throw new Error("npm pack output is missing filename or integrity");
|
||||
}
|
||||
process.stdout.write(`${entry.filename}\n${entry.integrity}\n`);
|
||||
});
|
||||
')"
|
||||
packed_name="$(printf '%s\n' "$pack_metadata" | sed -n '1p')"
|
||||
integrity="$(printf '%s\n' "$pack_metadata" | sed -n '2p')"
|
||||
[ -f "$output_dir/$packed_name" ] || {
|
||||
printf 'npm pack did not create expected tarball: %s\n' "$packed_name" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$packed_name" != "$output_name" ]; then
|
||||
mv "$output_dir/$packed_name" "$output_dir/$output_name"
|
||||
fi
|
||||
printf '%s\n' "$integrity"
|
||||
@@ -90,36 +90,27 @@ resolve_release_base_url() {
|
||||
|
||||
stage_npm_package() {
|
||||
version="$1"
|
||||
pkg_root="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
|
||||
cp "$ROOT/build/npm/install.js" "$pkg_root/install.js"
|
||||
cp "$ROOT/build/npm/bin/dws.js" "$pkg_root/bin/dws.js"
|
||||
cp "$ROOT/build/npm/README.md" "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|$version|g" "$ROOT/build/npm/package.json.tmpl" > "$pkg_root/package.json"
|
||||
|
||||
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/dws-skills.zip; do
|
||||
if [ -f "$artifact" ]; then
|
||||
cp "$artifact" "$pkg_root/assets/"
|
||||
fi
|
||||
done
|
||||
DWS_PACKAGE_SOURCE_ROOT="$ROOT" \
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" \
|
||||
"$ROOT/scripts/release/stage-npm-package.sh" "$version"
|
||||
}
|
||||
|
||||
# ---------- Homebrew formula staging ----------
|
||||
|
||||
render_homebrew_formula() {
|
||||
class_name="$1"
|
||||
archive_url="$2"
|
||||
skills_url="$3"
|
||||
archive_sha="$4"
|
||||
skills_sha="$5"
|
||||
keg_only_line="$6"
|
||||
output_path="$7"
|
||||
formula_version="$2"
|
||||
archive_url="$3"
|
||||
skills_url="$4"
|
||||
archive_sha="$5"
|
||||
skills_sha="$6"
|
||||
keg_only_line="$7"
|
||||
output_path="$8"
|
||||
|
||||
sed \
|
||||
-e "s|__CLASS_NAME__|$class_name|g" \
|
||||
-e "s|__VERSION__|$formula_version|g" \
|
||||
-e "s|__SKILL_HOME_OVERRIDE__||g" \
|
||||
-e "s|__ARCHIVE_URL__|$archive_url|g" \
|
||||
-e "s|__ARCHIVE_SHA256__|$archive_sha|g" \
|
||||
-e "s|__SKILLS_URL__|$skills_url|g" \
|
||||
@@ -149,6 +140,7 @@ stage_homebrew_formula() {
|
||||
|
||||
render_homebrew_formula \
|
||||
"DingtalkWorkspaceCliLocal" \
|
||||
"$version" \
|
||||
"file://$archive_path" \
|
||||
"file://$DIST_DIR/dws-skills.zip" \
|
||||
"$archive_sha" \
|
||||
@@ -158,6 +150,7 @@ stage_homebrew_formula() {
|
||||
|
||||
render_homebrew_formula \
|
||||
"DingtalkWorkspaceCli" \
|
||||
"$version" \
|
||||
"$release_url_base/$archive_name" \
|
||||
"$release_url_base/$skills_name" \
|
||||
"$archive_sha" \
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
FROM_BETA=""
|
||||
FROM_REF=""
|
||||
CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: prepare-changelog.sh <prerelease|stable> <version> [options]
|
||||
|
||||
Options:
|
||||
--from-beta <tag> Required for stable release notes
|
||||
--from-ref <ref> Commit-list baseline for prerelease notes
|
||||
--changelog <path> Override CHANGELOG.md path
|
||||
EOF
|
||||
}
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
shift 2
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;;
|
||||
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
cd "$ROOT"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf 'prepare changelog requires a clean worktree\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
semver="$(release_semver "$VERSION")"
|
||||
if grep -Fq "## [$semver] - " "$CHANGELOG"; then
|
||||
printf 'CHANGELOG section already exists for %s\n' "$semver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
[ -n "$FROM_BETA" ] || { printf 'stable changelog requires --from-beta\n' >&2; exit 1; }
|
||||
release_is_prerelease_version "$FROM_BETA" || { printf 'invalid beta baseline: %s\n' "$FROM_BETA" >&2; exit 1; }
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
FROM_REF="$FROM_BETA"
|
||||
fi
|
||||
|
||||
release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
|
||||
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
|
||||
cleanup() { rm -f "$section" "$output"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
{
|
||||
printf '## [%s] - %s\n\n' "$semver" "$release_date"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
|
||||
else
|
||||
printf '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\n\n'
|
||||
fi
|
||||
printf '### Changed\n\n'
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
|
||||
else
|
||||
printf -- '- TODO: summarize this beta candidate and its validation scope.\n'
|
||||
fi
|
||||
} > "$section"
|
||||
|
||||
inserted=0
|
||||
in_unreleased=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
'## [Unreleased]')
|
||||
in_unreleased=1
|
||||
;;
|
||||
'## '*)
|
||||
if [ "$in_unreleased" -eq 1 ]; then
|
||||
cat "$section" >> "$output"
|
||||
printf '\n' >> "$output"
|
||||
inserted=1
|
||||
in_unreleased=0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$line" >> "$output"
|
||||
done < "$CHANGELOG"
|
||||
|
||||
if [ "$in_unreleased" -eq 1 ]; then
|
||||
printf '\n' >> "$output"
|
||||
cat "$section" >> "$output"
|
||||
inserted=1
|
||||
fi
|
||||
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
|
||||
cp "$output" "$CHANGELOG"
|
||||
|
||||
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
|
||||
if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then
|
||||
printf '\nCommits since %s:\n' "$FROM_REF"
|
||||
git log --oneline "$FROM_REF..HEAD"
|
||||
fi
|
||||
@@ -1,144 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish locally-built release artifacts to the matching Gitee release.
|
||||
#
|
||||
# Intended to run inside Gitee Go after building artifacts in China. This avoids
|
||||
# the unreliable GitHub Actions -> Gitee cross-border upload path.
|
||||
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
|
||||
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
|
||||
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required"
|
||||
[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag"
|
||||
case "$VERSION" in
|
||||
v*) ;;
|
||||
*) VERSION="v$VERSION" ;;
|
||||
esac
|
||||
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}'
|
||||
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
api_get() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@"
|
||||
}
|
||||
|
||||
echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
|
||||
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)"
|
||||
|
||||
rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X POST "${base}/releases" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "tag_name=${VERSION}" \
|
||||
-F "name=${VERSION}" \
|
||||
-F "body=Gitee-local build of ${VERSION} for China users." \
|
||||
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
fi
|
||||
[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}"
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
assets_map="$(api_get "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
for a in rows:
|
||||
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
pass' 2>/dev/null || true)"
|
||||
|
||||
gitee_attach() {
|
||||
file="$1"
|
||||
fn="$(basename "$file")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
|
||||
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
|
||||
--retry 2 --retry-delay 5 --retry-all-errors \
|
||||
-X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
|
||||
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
|
||||
return 0
|
||||
fi
|
||||
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_delete() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt "$DIST_DIR"/dws-skills.zip; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
local_sha="$(sha256_of "$f")"
|
||||
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
|
||||
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
|
||||
count="$(printf '%s' "$ids" | grep -c . || true)"
|
||||
|
||||
if [ "$count" -eq 1 ]; then
|
||||
gitee_sha="$(api_get "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
if [ "$gitee_sha" = "$local_sha" ]; then
|
||||
echo " ✓ ${fn} already correct on Gitee — skip"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
echo " ↻ ${fn} differs on Gitee — replacing"
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo " ↻ ${fn} has ${count} copies on Gitee — replacing"
|
||||
else
|
||||
echo " ⬆ ${fn} (new)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
done
|
||||
if gitee_attach "$f"; then
|
||||
if [ "$count" -eq 0 ]; then uploaded=$((uploaded + 1)); else replaced=$((replaced + 1)); fi
|
||||
else
|
||||
echo " ❌ upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
|
||||
printf '%s\n' \
|
||||
'Direct Gitee artifact publication is disabled.' \
|
||||
'Use the guarded Release workflow so Gitee receives the exact immutable GitHub assets.' >&2
|
||||
exit 2
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
CHANNEL=""
|
||||
VERSION=""
|
||||
CONTEXT="local"
|
||||
REMOTE="origin"
|
||||
BRANCH="main"
|
||||
FROM_BETA=""
|
||||
FROM_BETA_COMMIT=""
|
||||
CHANGELOG=""
|
||||
NOTES_OUTPUT=""
|
||||
METADATA_OUTPUT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release-contract.sh --channel <prerelease|stable> --version <tag> [options]
|
||||
|
||||
Options:
|
||||
--context <local|ci> Local pre-push checks or tag-workflow checks
|
||||
--remote <name> Release remote (default: origin)
|
||||
--branch <name> Sealed release branch (default: main)
|
||||
--from-beta <tag> Required stable promotion baseline
|
||||
--repo-root <path> Override repository root (primarily for tests)
|
||||
--changelog <path> Override CHANGELOG.md path
|
||||
--notes-output <path> Write the exact CHANGELOG section body
|
||||
--metadata-output <path> Append channel/version/baseline key-value output
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANNEL="$2"; shift 2 ;;
|
||||
--version) [ "$#" -ge 2 ] || { usage; exit 2; }; VERSION="$2"; shift 2 ;;
|
||||
--context) [ "$#" -ge 2 ] || { usage; exit 2; }; CONTEXT="$2"; shift 2 ;;
|
||||
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
|
||||
--branch) [ "$#" -ge 2 ] || { usage; exit 2; }; BRANCH="$2"; shift 2 ;;
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--repo-root) [ "$#" -ge 2 ] || { usage; exit 2; }; ROOT="$2"; shift 2 ;;
|
||||
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
|
||||
--notes-output) [ "$#" -ge 2 ] || { usage; exit 2; }; NOTES_OUTPUT="$2"; shift 2 ;;
|
||||
--metadata-output) [ "$#" -ge 2 ] || { usage; exit 2; }; METADATA_OUTPUT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
case "$CONTEXT" in local|ci) ;; *) printf 'invalid context: %s\n' "$CONTEXT" >&2; exit 2 ;; esac
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
|
||||
printf 'not a Git worktree: %s\n' "$ROOT" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -n "$CHANGELOG" ] || CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
remote_main="refs/remotes/$REMOTE/$BRANCH"
|
||||
git remote get-url "$REMOTE" >/dev/null 2>&1 || {
|
||||
printf 'release remote does not exist: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || {
|
||||
printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
remote_main_commit="$(git rev-parse "$remote_main^{commit}")"
|
||||
|
||||
if [ "$CONTEXT" = "local" ]; then
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2
|
||||
exit 1
|
||||
}
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "$BRANCH" ] || {
|
||||
printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$head_commit" = "$remote_main_commit" ] || {
|
||||
printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
|
||||
printf 'release tag already exists locally: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || {
|
||||
printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$remote_tag" ] || {
|
||||
printf 'release tag already exists on %s: %s\n' "$REMOTE" "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || {
|
||||
printf 'CI release tag is not available: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(git rev-parse "refs/tags/$VERSION^{commit}")" = "$head_commit" ] || {
|
||||
printf 'CI checkout HEAD does not match tag %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(git cat-file -t "refs/tags/$VERSION")" = "tag" ] || {
|
||||
printf 'release tag must be annotated: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
git merge-base --is-ancestor HEAD "$remote_main" || {
|
||||
printf 'release tag commit must be contained in %s/%s\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
previous_stable=""
|
||||
previous_stable_commit=""
|
||||
for tag in $(git tag --list 'v*' --sort=-version:refname); do
|
||||
[ "$tag" = "$VERSION" ] && continue
|
||||
if release_is_stable_version "$tag"; then
|
||||
previous_stable="$tag"
|
||||
previous_stable_commit="$(git rev-parse "$tag^{commit}")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then
|
||||
printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
core_tag="$(release_core_tag "$VERSION")"
|
||||
if [ "$CHANNEL" = "prerelease" ]; then
|
||||
[ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; }
|
||||
if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then
|
||||
printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
previous_beta=""
|
||||
for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do
|
||||
[ "$tag" = "$VERSION" ] && continue
|
||||
if release_is_prerelease_version "$tag"; then
|
||||
previous_beta="$tag"
|
||||
break
|
||||
fi
|
||||
done
|
||||
beta_number="$(release_beta_number "$VERSION")"
|
||||
if [ -z "$previous_beta" ]; then
|
||||
[ "$beta_number" -eq 1 ] || {
|
||||
printf 'the first prerelease for %s must be beta.1\n' "$core_tag" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
previous_beta_number="$(release_beta_number "$previous_beta")"
|
||||
expected_beta_number=$((previous_beta_number + 1))
|
||||
[ "$beta_number" -eq "$expected_beta_number" ] || {
|
||||
printf 'prerelease after %s must be %s-beta.%s\n' "$previous_beta" "$core_tag" "$expected_beta_number" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
if [ -z "$FROM_BETA" ] && [ "$CONTEXT" = "ci" ]; then
|
||||
FROM_BETA="$(git for-each-ref "refs/tags/$VERSION" --format='%(contents)' | awk '/^From-Beta:[[:space:]]*/ { sub(/^From-Beta:[[:space:]]*/, ""); print }')"
|
||||
fi
|
||||
[ -n "$FROM_BETA" ] || {
|
||||
printf 'stable release requires an explicit --from-beta tag\n' >&2
|
||||
exit 1
|
||||
}
|
||||
release_is_prerelease_version "$FROM_BETA" || {
|
||||
printf 'invalid stable beta baseline: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || {
|
||||
printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
FROM_BETA_COMMIT="$(git rev-parse "refs/tags/$FROM_BETA^{commit}")"
|
||||
git merge-base --is-ancestor "$FROM_BETA^{commit}" HEAD || {
|
||||
printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then
|
||||
printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2
|
||||
git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
semver="$(release_semver "$VERSION")"
|
||||
if [ -n "$NOTES_OUTPUT" ]; then
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$NOTES_OUTPUT"
|
||||
else
|
||||
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-contract.XXXXXX")"
|
||||
trap 'rm -f "$notes_tmp"' EXIT HUP INT TERM
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
|
||||
fi
|
||||
|
||||
if [ -n "$METADATA_OUTPUT" ]; then
|
||||
mkdir -p "$(dirname "$METADATA_OUTPUT")"
|
||||
{
|
||||
printf 'channel=%s\n' "$CHANNEL"
|
||||
printf 'version=%s\n' "$VERSION"
|
||||
printf 'semver=%s\n' "$semver"
|
||||
printf 'previous_stable=%s\n' "$previous_stable"
|
||||
printf 'previous_stable_commit=%s\n' "$previous_stable_commit"
|
||||
printf 'from_beta=%s\n' "$FROM_BETA"
|
||||
printf 'from_beta_commit=%s\n' "$FROM_BETA_COMMIT"
|
||||
} >> "$METADATA_OUTPUT"
|
||||
fi
|
||||
|
||||
printf 'Release contract passed: channel=%s version=%s commit=%s' "$CHANNEL" "$VERSION" "$head_commit"
|
||||
[ -z "$FROM_BETA" ] || printf ' from-beta=%s' "$FROM_BETA"
|
||||
printf '\n'
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Shared release version and CHANGELOG helpers. This file is sourced by the
|
||||
# local release command, the CI contract, and mirror publishing so every stage
|
||||
# agrees on what "prerelease" and "stable" mean.
|
||||
|
||||
release_stable_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
release_prerelease_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.[1-9][0-9]*$'
|
||||
|
||||
release_is_stable_version() {
|
||||
printf '%s\n' "$1" | grep -Eq "$release_stable_pattern"
|
||||
}
|
||||
|
||||
release_is_prerelease_version() {
|
||||
printf '%s\n' "$1" | grep -Eq "$release_prerelease_pattern"
|
||||
}
|
||||
|
||||
release_channel_for_version() {
|
||||
version="$1"
|
||||
if release_is_stable_version "$version"; then
|
||||
printf '%s\n' stable
|
||||
return 0
|
||||
fi
|
||||
if release_is_prerelease_version "$version"; then
|
||||
printf '%s\n' prerelease
|
||||
return 0
|
||||
fi
|
||||
printf 'invalid release version: %s (expected vX.Y.Z or vX.Y.Z-beta.N)\n' "$version" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
release_validate_version_channel() {
|
||||
expected="$1"
|
||||
version="$2"
|
||||
case "$expected" in
|
||||
stable|prerelease) ;;
|
||||
*)
|
||||
printf 'invalid release channel: %s (expected prerelease or stable)\n' "$expected" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
actual="$(release_channel_for_version "$version")" || return 1
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
printf 'release channel/version mismatch: channel=%s version=%s\n' "$expected" "$version" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
release_semver() {
|
||||
printf '%s\n' "${1#v}"
|
||||
}
|
||||
|
||||
release_core_tag() {
|
||||
version="$1"
|
||||
printf '%s\n' "${version%%-beta.*}"
|
||||
}
|
||||
|
||||
release_beta_number() {
|
||||
printf '%s\n' "${1##*.}"
|
||||
}
|
||||
|
||||
release_core_is_greater() {
|
||||
candidate="$(release_core_tag "$1")"
|
||||
baseline="$(release_core_tag "$2")"
|
||||
candidate="${candidate#v}"
|
||||
baseline="${baseline#v}"
|
||||
awk -v candidate="$candidate" -v baseline="$baseline" 'BEGIN {
|
||||
split(candidate, c, ".")
|
||||
split(baseline, b, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
if ((c[i] + 0) > (b[i] + 0)) exit 0
|
||||
if ((c[i] + 0) < (b[i] + 0)) exit 1
|
||||
}
|
||||
exit 1
|
||||
}'
|
||||
}
|
||||
|
||||
release_version_is_greater() {
|
||||
_rvig_candidate="$1"
|
||||
_rvig_baseline="$2"
|
||||
_rvig_candidate_channel="$(release_channel_for_version "$_rvig_candidate")" || return 1
|
||||
_rvig_baseline_channel="$(release_channel_for_version "$_rvig_baseline")" || return 1
|
||||
|
||||
if release_core_is_greater "$_rvig_candidate" "$_rvig_baseline"; then
|
||||
return 0
|
||||
fi
|
||||
if release_core_is_greater "$_rvig_baseline" "$_rvig_candidate"; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$_rvig_candidate_channel" = "stable" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$_rvig_candidate_channel" = "prerelease" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
|
||||
[ "$(release_beta_number "$_rvig_candidate")" -gt "$(release_beta_number "$_rvig_baseline")" ]
|
||||
return
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract one exact CHANGELOG section (without its H2 heading) and validate that
|
||||
# it is dated, unique, non-placeholder content with at least one bullet.
|
||||
release_extract_changelog() {
|
||||
changelog="$1"
|
||||
semver="$2"
|
||||
output="$3"
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-notes.XXXXXX")"
|
||||
|
||||
set +e
|
||||
awk -v wanted="$semver" '
|
||||
BEGIN {
|
||||
prefix = "## [" wanted "] - "
|
||||
found = 0
|
||||
active = 0
|
||||
invalid_date = 0
|
||||
meaningful = 0
|
||||
bullet = 0
|
||||
placeholder = 0
|
||||
}
|
||||
/^## / {
|
||||
active = 0
|
||||
if (index($0, prefix) == 1) {
|
||||
found++
|
||||
active = 1
|
||||
date = substr($0, length(prefix) + 1)
|
||||
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) invalid_date = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
active {
|
||||
print
|
||||
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||
if ($0 ~ /^- /) bullet = 1
|
||||
lowered = tolower($0)
|
||||
if (lowered ~ /todo|tbd/) placeholder = 1
|
||||
}
|
||||
END {
|
||||
if (found != 1) exit 41
|
||||
if (invalid_date) exit 42
|
||||
if (!meaningful || !bullet) exit 43
|
||||
if (placeholder) exit 44
|
||||
}
|
||||
' "$changelog" > "$tmp"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
case "$status" in
|
||||
0) ;;
|
||||
41) printf 'CHANGELOG must contain exactly one section: ## [%s] - YYYY-MM-DD\n' "$semver" >&2 ;;
|
||||
42) printf 'CHANGELOG section for %s has an invalid date\n' "$semver" >&2 ;;
|
||||
43) printf 'CHANGELOG section for %s must contain release notes and at least one bullet\n' "$semver" >&2 ;;
|
||||
44) printf 'CHANGELOG section for %s still contains TODO/TBD placeholders\n' "$semver" >&2 ;;
|
||||
*) printf 'failed to parse CHANGELOG section for %s\n' "$semver" >&2 ;;
|
||||
esac
|
||||
if [ "$status" -ne 0 ]; then
|
||||
rm -f "$tmp"
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
if [ "$output" = "-" ]; then
|
||||
cat "$tmp"
|
||||
else
|
||||
mkdir -p "$(dirname "$output")"
|
||||
mv "$tmp" "$output"
|
||||
tmp=""
|
||||
fi
|
||||
[ -z "$tmp" ] || rm -f "$tmp"
|
||||
}
|
||||
Executable
+353
@@ -0,0 +1,353 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
REMOTE=""
|
||||
BRANCH="main"
|
||||
FROM_BETA=""
|
||||
PUBLISH=0
|
||||
YES=0
|
||||
OFFICIAL_TAGS_URL="${DWS_RELEASE_OFFICIAL_TAGS_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release.sh <prerelease|stable> <version> [options]
|
||||
|
||||
Runs the full test, command-compatibility, package, and install preflight.
|
||||
The default is validation only; add --publish to create and push the tag.
|
||||
|
||||
Options:
|
||||
--remote <name> Required tag destination
|
||||
--from-beta <tag> Required for stable releases
|
||||
--publish Create and push the annotated release tag
|
||||
--yes Skip the interactive version confirmation
|
||||
EOF
|
||||
}
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
shift 2
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--publish) PUBLISH=1; shift ;;
|
||||
--yes) YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
|
||||
cd "$ROOT"
|
||||
fetch_url="$(git remote get-url "$REMOTE" 2>/dev/null)" || { printf 'unknown release remote: %s\n' "$REMOTE" >&2; exit 1; }
|
||||
push_urls="$(git remote get-url --push --all "$REMOTE" 2>/dev/null)" || {
|
||||
printf 'could not resolve push URL for release remote: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(printf '%s\n' "$push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf 'release remote must have exactly one push URL: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
push_url="$(printf '%s\n' "$push_urls" | sed -n '1p')"
|
||||
|
||||
repository_identity() {
|
||||
identity_url="$1"
|
||||
case "$identity_url" in
|
||||
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
|
||||
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
|
||||
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
|
||||
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
|
||||
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
|
||||
*) printf '%s\n' "$identity_url"; return 0 ;;
|
||||
esac
|
||||
identity_path="${identity_path%/}"
|
||||
identity_path="${identity_path%.git}"
|
||||
printf 'github.com/%s\n' "$identity_path"
|
||||
}
|
||||
|
||||
fetch_identity="$(repository_identity "$fetch_url")"
|
||||
push_identity="$(repository_identity "$push_url")"
|
||||
[ "$fetch_identity" = "$push_identity" ] || {
|
||||
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' "$fetch_url" "$push_url" >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url"
|
||||
|
||||
github_repository_from_url() {
|
||||
github_identity="$(repository_identity "$1")"
|
||||
case "$github_identity" in
|
||||
github.com/*) printf '%s\n' "${github_identity#github.com/}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_delivered_previous_stable() {
|
||||
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
|
||||
if [ -z "$github_repository" ]; then
|
||||
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
|
||||
printf 'release validation requires a github.com remote to prove the stable baseline was delivered\n' >&2
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
[ -n "$previous_stable" ] || {
|
||||
printf 'a delivered previous stable baseline is required\n' >&2
|
||||
return 1
|
||||
}
|
||||
stable_commit="$(git rev-parse "$previous_stable^{commit}")"
|
||||
DWS_RELEASE_OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" \
|
||||
"$SCRIPT_DIR/verify-delivered-stable.sh" "$previous_stable" "$stable_commit"
|
||||
}
|
||||
|
||||
require_github_publication_authority() {
|
||||
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
|
||||
if [ -z "$github_repository" ]; then
|
||||
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
|
||||
printf 'publishing requires a github.com remote so CI and delivery authority can be verified\n' >&2
|
||||
return 1
|
||||
}
|
||||
printf 'warning: GitHub publication checks explicitly disabled for non-GitHub test remote\n' >&2
|
||||
return 0
|
||||
fi
|
||||
command -v gh >/dev/null 2>&1 || {
|
||||
printf 'gh is required to verify CI and release authority before publishing\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
immutable_enabled="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$github_repository/immutable-releases" \
|
||||
--jq '.enabled'
|
||||
)" || {
|
||||
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$immutable_enabled" = "true" ] || {
|
||||
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
active_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
|
||||
--jq '.workflow_runs[] | select(.status != "completed") | [.id, .event, .status, .head_branch] | @tsv'
|
||||
)" || {
|
||||
printf 'could not query active Release workflow runs for %s\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ -z "$active_runs" ] || {
|
||||
printf 'another Release workflow is still active; wait for it before allocating a new tag:\n%s\n' "$active_runs" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
sealed_commit="$(git rev-parse HEAD)"
|
||||
passed_gate="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \
|
||||
--jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length'
|
||||
)" || {
|
||||
printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$passed_gate" -gt 0 ] || {
|
||||
printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
beta_state="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/releases/tags/$FROM_BETA" \
|
||||
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
|
||||
)" || {
|
||||
printf 'could not query beta release %s in %s\n' "$FROM_BETA" "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$beta_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" ] || {
|
||||
printf '%s must be a public immutable prerelease before stable tag allocation (got: %s)\n' "$FROM_BETA" "$beta_state" >&2
|
||||
return 1
|
||||
}
|
||||
beta_assets="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/releases/tags/$FROM_BETA" \
|
||||
--jq '.assets[].name'
|
||||
)" || return 1
|
||||
[ "$(printf '%s\n' "$beta_assets" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 8 ] || {
|
||||
printf 'beta release %s must contain exactly eight supported assets\n' "$FROM_BETA" >&2
|
||||
return 1
|
||||
}
|
||||
for beta_asset in \
|
||||
dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip dws-windows-arm64.zip \
|
||||
dws-skills.zip checksums.txt; do
|
||||
[ "$(printf '%s\n' "$beta_assets" | grep -Fxc "$beta_asset")" -eq 1 ] || {
|
||||
printf 'beta release %s must contain %s exactly once\n' "$FROM_BETA" "$beta_asset" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
|
||||
beta_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
|
||||
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
|
||||
)" || return 1
|
||||
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
|
||||
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' || {
|
||||
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_release_tags() {
|
||||
git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*'
|
||||
git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*'
|
||||
}
|
||||
|
||||
printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH"
|
||||
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
|
||||
fetch_release_tags
|
||||
|
||||
metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-metadata.XXXXXX")"
|
||||
final_metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-final-metadata.XXXXXX")"
|
||||
cleanup() { rm -f "$metadata" "$final_metadata"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
run_contract() {
|
||||
if [ -n "$FROM_BETA" ]; then
|
||||
"$SCRIPT_DIR/release-contract.sh" \
|
||||
--channel "$CHANNEL" \
|
||||
--version "$VERSION" \
|
||||
--context local \
|
||||
--remote "$REMOTE" \
|
||||
--branch "$BRANCH" \
|
||||
--from-beta "$FROM_BETA" \
|
||||
"$@"
|
||||
else
|
||||
"$SCRIPT_DIR/release-contract.sh" \
|
||||
--channel "$CHANNEL" \
|
||||
--version "$VERSION" \
|
||||
--context local \
|
||||
--remote "$REMOTE" \
|
||||
--branch "$BRANCH" \
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_contract --metadata-output "$metadata"
|
||||
previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)"
|
||||
|
||||
printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}"
|
||||
require_delivered_previous_stable
|
||||
|
||||
if [ "$PUBLISH" -eq 1 ]; then
|
||||
printf '==> Verifying GitHub publication authority before local gates\n'
|
||||
require_github_publication_authority
|
||||
fi
|
||||
|
||||
printf '==> Running repository test and policy gates\n'
|
||||
make test
|
||||
make policy
|
||||
|
||||
if [ -n "$previous_stable" ]; then
|
||||
printf '==> Comparing command tree with %s\n' "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$previous_stable"
|
||||
fi
|
||||
|
||||
printf '==> Building local release artifacts for %s\n' "$VERSION"
|
||||
make package VERSION="$VERSION"
|
||||
|
||||
printf '==> Verifying release artifact set and checksums\n'
|
||||
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
printf '==> Verifying npm package installation\n'
|
||||
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
printf '==> Verifying Homebrew package installation\n'
|
||||
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
|
||||
fi
|
||||
|
||||
# Collect human confirmation before the final authority refresh. Nothing may
|
||||
# block between that refresh and tag creation.
|
||||
if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
|
||||
if [ ! -t 0 ]; then
|
||||
printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Type %s to create and push the release tag: ' "$VERSION"
|
||||
IFS= read -r confirmation
|
||||
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Re-check after every local gate so tag creation cannot race with a modified
|
||||
# source tree. dist/ is ignored and therefore does not make the tree dirty.
|
||||
printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH"
|
||||
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
|
||||
fetch_release_tags
|
||||
run_contract --metadata-output "$final_metadata"
|
||||
final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
|
||||
if [ "$final_previous_stable" != "$previous_stable" ]; then
|
||||
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
|
||||
"${previous_stable:-none}" "${final_previous_stable:-none}"
|
||||
[ -n "$final_previous_stable" ] || {
|
||||
printf 'latest stable authority unexpectedly became empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$final_previous_stable"
|
||||
fi
|
||||
|
||||
if [ "$PUBLISH" -ne 1 ]; then
|
||||
printf '\nPreflight passed. No tag was created.\n'
|
||||
printf 'Publish with the same command plus --publish.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
|
||||
require_github_publication_authority
|
||||
|
||||
printf '==> Creating annotated tag %s\n' "$VERSION"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA"
|
||||
else
|
||||
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
|
||||
fi
|
||||
|
||||
if ! git push "$push_url" "refs/tags/$VERSION"; then
|
||||
set +e
|
||||
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
|
||||
query_status=$?
|
||||
set -e
|
||||
if [ "$query_status" -ne 0 ]; then
|
||||
printf 'tag push reported failure and remote state could not be verified; keeping local tag %s for investigation\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_object="$(printf '%s\n' "$remote_refs" | awk '$2 !~ /\^\{\}$/ { print $1; exit }')"
|
||||
local_object="$(git rev-parse "refs/tags/$VERSION")"
|
||||
if [ -z "$remote_object" ]; then
|
||||
git tag -d "$VERSION" >/dev/null 2>&1 || true
|
||||
printf 'tag push failed; remote has no tag and the new local tag was removed: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$remote_object" != "$local_object" ]; then
|
||||
printf 'tag push failed and remote %s points elsewhere; keeping local tag for investigation\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'warning: push reported failure, but push target %s has the exact sealed tag; treating it as published\n' "$push_url" >&2
|
||||
fi
|
||||
|
||||
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
DEFAULT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
SOURCE_ROOT="${DWS_PACKAGE_SOURCE_ROOT:-$DEFAULT_ROOT}"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$DEFAULT_ROOT/dist}"
|
||||
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
|
||||
|
||||
[ -n "$VERSION" ] || { printf 'package version is required\n' >&2; exit 2; }
|
||||
SEMVER="${VERSION#v}"
|
||||
printf '%s\n' "$SEMVER" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' || {
|
||||
printf 'invalid npm package version: %s\n' "$VERSION" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
PKG_ROOT="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$PKG_ROOT"
|
||||
mkdir -p "$PKG_ROOT/assets" "$PKG_ROOT/bin"
|
||||
|
||||
cp "$SOURCE_ROOT/build/npm/install.js" "$PKG_ROOT/install.js"
|
||||
cp "$SOURCE_ROOT/build/npm/bin/dws.js" "$PKG_ROOT/bin/dws.js"
|
||||
cp "$SOURCE_ROOT/build/npm/README.md" "$PKG_ROOT/README.md"
|
||||
sed "s|__VERSION__|$SEMVER|g" "$SOURCE_ROOT/build/npm/package.json.tmpl" > "$PKG_ROOT/package.json"
|
||||
|
||||
command -v node >/dev/null 2>&1 || { printf 'node is required to validate the npm manifest\n' >&2; exit 1; }
|
||||
node - "$PKG_ROOT/package.json" "$SEMVER" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const [manifestPath, version] = process.argv.slice(2);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const expectedScripts = { postinstall: "node install.js" };
|
||||
if (manifest.name !== "dingtalk-workspace-cli") {
|
||||
throw new Error(`unexpected npm package name: ${manifest.name}`);
|
||||
}
|
||||
if (manifest.version !== version) {
|
||||
throw new Error(`unexpected npm package version: ${manifest.version}`);
|
||||
}
|
||||
if (JSON.stringify(manifest.scripts) !== JSON.stringify(expectedScripts)) {
|
||||
throw new Error(`unexpected npm lifecycle scripts: ${JSON.stringify(manifest.scripts)}`);
|
||||
}
|
||||
if (!manifest.bin || manifest.bin.dws !== "./bin/dws.js") {
|
||||
throw new Error("unexpected npm binary entrypoint");
|
||||
}
|
||||
NODE
|
||||
|
||||
copied=0
|
||||
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$artifact" ] || continue
|
||||
cp "$artifact" "$PKG_ROOT/assets/"
|
||||
copied=$((copied + 1))
|
||||
done
|
||||
[ "$copied" -gt 0 ] || { printf 'no release assets found in %s\n' "$DIST_DIR" >&2; exit 1; }
|
||||
[ -f "$PKG_ROOT/assets/dws-skills.zip" ] || { printf 'dws-skills.zip is missing\n' >&2; exit 1; }
|
||||
[ -f "$PKG_ROOT/assets/checksums.txt" ] || { printf 'checksums.txt is missing\n' >&2; exit 1; }
|
||||
|
||||
printf 'Staged npm package %s at %s\n' "$SEMVER" "$PKG_ROOT"
|
||||
@@ -24,8 +24,9 @@
|
||||
# Gating: if GITEE_TOKEN / GITEE_USER / GITEE_REPO are unset, exit 0 with a
|
||||
# notice so the step can live in release.yml without breaking forks.
|
||||
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
@@ -39,12 +40,17 @@ missing=""
|
||||
[ -z "${GITEE_USER:-}" ] && missing="$missing GITEE_USER"
|
||||
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
|
||||
if [ -n "$missing" ]; then
|
||||
if [ "${DWS_REQUIRE_GITEE:-0}" = "1" ]; then
|
||||
echo "❌ Gitee mirror sync is enabled but credentials are missing:${missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
@@ -72,10 +78,18 @@ gitee_tag_commit() {
|
||||
}'
|
||||
}
|
||||
|
||||
echo " Syncing Gitee tag ${VERSION} -> ${target_commit}"
|
||||
git push --force "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
|
||||
gitee_commit="$(gitee_tag_commit || true)"
|
||||
if [ -n "$gitee_commit" ] && [ "$gitee_commit" != "$target_commit" ]; then
|
||||
echo "❌ Existing Gitee tag ${VERSION} points to ${gitee_commit}, expected ${target_commit}; refusing to move it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$gitee_commit" ]; then
|
||||
echo " Creating Gitee tag ${VERSION} -> ${target_commit}"
|
||||
git push "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
|
||||
else
|
||||
echo " Gitee tag ${VERSION} already points to ${target_commit}."
|
||||
fi
|
||||
|
||||
gitee_commit=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
gitee_commit="$(gitee_tag_commit || true)"
|
||||
[ "$gitee_commit" = "$target_commit" ] && break
|
||||
@@ -124,8 +138,10 @@ echo " Gitee release id = ${release_id}"
|
||||
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
|
||||
# id, so DELETE /attach_files/{id} was previously called with an empty id and
|
||||
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
|
||||
assets_map="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
load_assets_map() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
"${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
@@ -134,7 +150,13 @@ try:
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
pass' 2>/dev/null || true)"
|
||||
sys.exit(1)'
|
||||
}
|
||||
|
||||
assets_map="$(load_assets_map)" || {
|
||||
echo "❌ Could not list Gitee release attachments; refusing a blind upload." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
|
||||
@@ -163,12 +185,13 @@ gitee_attach() { # upload file $1; success when the response carries a download
|
||||
gitee_delete() { # delete attachment by id $1
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
>/dev/null
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
@@ -180,7 +203,12 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo " ⬆ ${fn} (new)"
|
||||
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
|
||||
if gitee_attach "$f"; then
|
||||
uploaded=$((uploaded + 1))
|
||||
else
|
||||
echo " ❌ upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -197,16 +225,58 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
|
||||
fi
|
||||
|
||||
# Delete every copy, then upload exactly one fresh, correct file.
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
delete_failed=0
|
||||
for aid in $ids; do
|
||||
if ! gitee_delete "$aid"; then
|
||||
echo " ❌ failed to delete stale Gitee attachment ${aid} for ${fn}" >&2
|
||||
delete_failed=1
|
||||
fi
|
||||
done
|
||||
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
|
||||
if [ "$delete_failed" -ne 0 ]; then
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
if gitee_attach "$f"; then
|
||||
replaced=$((replaced + 1))
|
||||
else
|
||||
echo " ❌ re-upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
|
||||
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitee may accept an upload before the attachment list is consistent. Re-read
|
||||
# the release and require every supported asset to be unique and byte-identical.
|
||||
verified=0
|
||||
for verify_attempt in 1 2 3 4 5; do
|
||||
final_map="$(load_assets_map || true)"
|
||||
verify_failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
fn="$(basename "$f")"
|
||||
rows="$(printf '%s\n' "$final_map" | awk -F'\t' -v n="$fn" '$1==n')"
|
||||
count="$(printf '%s\n' "$rows" | grep -c . || true)"
|
||||
if [ "$count" -ne 1 ]; then
|
||||
verify_failed=1
|
||||
continue
|
||||
fi
|
||||
aurl="$(printf '%s\n' "$rows" | awk -F'\t' 'NR==1 {print $3}')"
|
||||
remote_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
[ "$remote_sha" = "$(sha256_of "$f")" ] || verify_failed=1
|
||||
done
|
||||
if [ "$verify_failed" -eq 0 ] && [ "$failed" -eq 0 ]; then
|
||||
verified=1
|
||||
break
|
||||
fi
|
||||
[ "$verify_attempt" -lt 5 ] && sleep 5
|
||||
done
|
||||
[ "$verified" -eq 1 ] || {
|
||||
echo "❌ Gitee release ${VERSION} does not contain one byte-identical copy of every release asset." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
|
||||
+133
-27
@@ -2,19 +2,16 @@
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# Sync release artifacts to a China-accessible OSS mirror so that the install
|
||||
# scripts' DWS_RELEASE_BASE switch can serve domestic users.
|
||||
# Sync release artifacts to a China-accessible OSS mirror. Channel pointers are
|
||||
# mirror metadata; repository installers currently resolve GitHub/Gitee and do
|
||||
# not consume these OSS pointers directly.
|
||||
#
|
||||
# Mirror layout produced (matches install.sh RELEASE_BASE="<base>/<version>/<file>"):
|
||||
# Mirror layout produced:
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-<os>-<arch>.tar.gz|.zip
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/checksums.txt
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-skills.zip
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (plain version string)
|
||||
#
|
||||
# So with the CDN/custom domain CNAME'd to the bucket, China users run:
|
||||
# DWS_RELEASE_BASE=https://<domain>/$OSS_PREFIX/download \
|
||||
# DWS_VERSION=<version> \
|
||||
# curl -fsSL https://<domain>/$OSS_PREFIX/install.sh | sh
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (stable version only)
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/beta.txt (prerelease version only)
|
||||
#
|
||||
# Required environment (CI secrets):
|
||||
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_ENDPOINT, OSS_BUCKET
|
||||
@@ -28,6 +25,9 @@
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
OSS_PREFIX="${OSS_PREFIX:-dws}"
|
||||
|
||||
@@ -38,6 +38,10 @@ for v in OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET OSS_ENDPOINT OSS_BUCKET; do
|
||||
[ -z "$val" ] && missing="$missing $v"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
if [ "${DWS_REQUIRE_OSS:-0}" = "1" ]; then
|
||||
echo "❌ OSS mirror sync is required but credentials are missing:${missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ OSS mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-publish releases to the China mirror."
|
||||
exit 0
|
||||
@@ -45,20 +49,60 @@ fi
|
||||
|
||||
# ── Resolve version ──────────────────────────────────────────────────────────
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
echo "📦 Syncing release ${VERSION} → oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${VERSION}/"
|
||||
|
||||
# Never move a channel pointer to an incomplete or mixed-version directory,
|
||||
# even when this helper is invoked outside the normal workflow.
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
# ── Ensure ossutil is available ───────────────────────────────────────────────
|
||||
OSSUTIL="${OSSUTIL:-ossutil}"
|
||||
OSSUTIL_INSTALL_DIR=""
|
||||
if ! command -v "$OSSUTIL" >/dev/null 2>&1; then
|
||||
echo "⬇ ossutil not found; installing to ./.ossutil ..."
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
echo "⬇ ossutil not found; installing a verified temporary copy ..."
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=mac ;;
|
||||
*) printf 'unsupported ossutil operating system: %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
|
||||
curl -fsSL "https://gosspublic.alicdn.com/ossutil/v2/2.0.0/ossutil-2.0.0-${os}-${arch}.zip" -o /tmp/ossutil.zip
|
||||
unzip -qo /tmp/ossutil.zip -d /tmp/ossutil-extract
|
||||
found="$(find /tmp/ossutil-extract -name ossutil -type f | head -1)"
|
||||
mkdir -p ./.ossutil && cp "$found" ./.ossutil/ossutil && chmod +x ./.ossutil/ossutil
|
||||
OSSUTIL="./.ossutil/ossutil"
|
||||
case "${os}-${arch}" in
|
||||
linux-amd64) expected_ossutil_sha256=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a ;;
|
||||
linux-arm64) expected_ossutil_sha256=f6c95ba0c2d2ef30290af686ce4d706c701f4734ce8090bee4288a77e3f1d764 ;;
|
||||
mac-amd64) expected_ossutil_sha256=8437fdd3ef1a3eb12310f61fcf1c00a5bff5cdab47b4fea815527472e7cf896c ;;
|
||||
mac-arm64) expected_ossutil_sha256=058fd048f321f8c80def8b748030531646eefe3a82837bf16b581ba7d9c84ac7 ;;
|
||||
*) printf 'unsupported ossutil architecture: %s-%s\n' "$os" "$arch" >&2; exit 1 ;;
|
||||
esac
|
||||
ossutil_archive="$(mktemp "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX.zip")"
|
||||
ossutil_extract="$(mktemp -d "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX")"
|
||||
env -u OSS_ACCESS_KEY_ID -u OSS_ACCESS_KEY_SECRET \
|
||||
curl -fsSL \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/2.3.0/ossutil-2.3.0-${os}-${arch}.zip" \
|
||||
-o "$ossutil_archive"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual_ossutil_sha256="$(sha256sum "$ossutil_archive" | awk '{print $1}')"
|
||||
else
|
||||
actual_ossutil_sha256="$(shasum -a 256 "$ossutil_archive" | awk '{print $1}')"
|
||||
fi
|
||||
[ "$actual_ossutil_sha256" = "$expected_ossutil_sha256" ] || {
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
printf 'ossutil archive checksum mismatch for %s-%s\n' "$os" "$arch" >&2
|
||||
exit 1
|
||||
}
|
||||
unzip -qo "$ossutil_archive" -d "$ossutil_extract"
|
||||
found="$(find "$ossutil_extract" -name ossutil -type f | head -1)"
|
||||
[ -n "$found" ] || {
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
printf 'verified ossutil archive does not contain the executable\n' >&2
|
||||
exit 1
|
||||
}
|
||||
OSSUTIL_INSTALL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-ossutil.XXXXXX")"
|
||||
cp "$found" "$OSSUTIL_INSTALL_DIR/ossutil" && chmod +x "$OSSUTIL_INSTALL_DIR/ossutil"
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
OSSUTIL="$OSSUTIL_INSTALL_DIR/ossutil"
|
||||
fi
|
||||
|
||||
oss_cp() {
|
||||
@@ -70,8 +114,59 @@ oss_cp() {
|
||||
"$1" "oss://${OSS_BUCKET}/$2"
|
||||
}
|
||||
|
||||
oss_get() {
|
||||
# oss_get <oss-key> <local-file>
|
||||
"$OSSUTIL" cp -f \
|
||||
--access-key-id "$OSS_ACCESS_KEY_ID" \
|
||||
--access-key-secret "$OSS_ACCESS_KEY_SECRET" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
"oss://${OSS_BUCKET}/$1" "$2"
|
||||
}
|
||||
|
||||
base="${OSS_PREFIX}/download/${VERSION}"
|
||||
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
pointer_name="latest.txt"
|
||||
else
|
||||
pointer_name="beta.txt"
|
||||
fi
|
||||
current_pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-current-pointer.XXXXXX")"
|
||||
pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-release-pointer.XXXXXX")"
|
||||
pointer_status_file="$(mktemp "${TMPDIR:-/tmp}/dws-pointer-status.XXXXXX")"
|
||||
trap 'rm -f "$current_pointer_file" "$pointer_file" "$pointer_status_file"; [ -z "$OSSUTIL_INSTALL_DIR" ] || rm -rf "$OSSUTIL_INSTALL_DIR"' EXIT HUP INT TERM
|
||||
rm -f "$current_pointer_file"
|
||||
current_version=""
|
||||
update_pointer=1
|
||||
if oss_get "${OSS_PREFIX}/${pointer_name}" "$current_pointer_file" >"$pointer_status_file" 2>&1; then
|
||||
[ -s "$current_pointer_file" ] || {
|
||||
cat "$pointer_status_file" >&2
|
||||
echo "❌ OSS ${pointer_name} was read successfully but is empty; refusing to publish." >&2
|
||||
exit 1
|
||||
}
|
||||
current_version="$(tr -d '[:space:]' < "$current_pointer_file")"
|
||||
release_validate_version_channel "$CHANNEL" "$current_version" || {
|
||||
printf '❌ OSS %s contains an invalid %s channel version: %s\n' "$pointer_name" "$CHANNEL" "$current_version" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$current_version" != "$VERSION" ]; then
|
||||
if release_version_is_greater "$VERSION" "$current_version"; then
|
||||
:
|
||||
elif release_version_is_greater "$current_version" "$VERSION"; then
|
||||
update_pointer=0
|
||||
echo "ℹ️ OSS ${pointer_name} already points to newer ${current_version}; assets will be repaired without moving it."
|
||||
else
|
||||
echo "❌ Cannot order OSS ${pointer_name}=${current_version} against ${VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
elif grep -Eq '(^|[^[:alnum:]])NoSuchKey([^[:alnum:]]|$)' "$pointer_status_file"; then
|
||||
printf 'ℹ️ OSS %s does not exist yet; creating it.\n' "$pointer_name"
|
||||
else
|
||||
cat "$pointer_status_file" >&2
|
||||
printf '❌ Could not read OSS %s; refusing to move the channel pointer.\n' "$pointer_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Upload binaries + checksums + skills ──────────────────────────────────────
|
||||
uploaded=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
@@ -85,16 +180,27 @@ if [ "$uploaded" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Publish the latest pointer (for DWS_VERSION=latest resolution on mirror) ──
|
||||
echo "$VERSION" > /tmp/dws-latest.txt
|
||||
oss_cp /tmp/dws-latest.txt "${OSS_PREFIX}/latest.txt"
|
||||
# ── Publish a channel-specific pointer ───────────────────────────────────────
|
||||
# A beta must never move latest.txt: that pointer is consumed by ordinary
|
||||
# installs and is the stable channel contract.
|
||||
pointer_summary="${pointer_name}"
|
||||
if [ "$update_pointer" -eq 1 ]; then
|
||||
echo "$VERSION" > "$pointer_file"
|
||||
oss_cp "$pointer_file" "${OSS_PREFIX}/${pointer_name}"
|
||||
else
|
||||
pointer_summary="${pointer_name} unchanged at ${current_version}"
|
||||
fi
|
||||
|
||||
# ── Publish the install scripts themselves (entry layer) ──────────────────────
|
||||
for s in install.sh install.ps1 install-skills.sh; do
|
||||
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
|
||||
done
|
||||
# Shared installer entrypoints are stable-only. A prerelease must not replace
|
||||
# the stable entry layer, even though those scripts currently fetch from
|
||||
# GitHub/Gitee rather than this asset mirror.
|
||||
installer_summary=""
|
||||
if [ "$CHANNEL" = "stable" ] && [ "$update_pointer" -eq 1 ]; then
|
||||
for s in install.sh install.ps1 install-skills.sh; do
|
||||
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
|
||||
done
|
||||
installer_summary=" + stable install scripts"
|
||||
fi
|
||||
|
||||
echo "✅ Synced ${uploaded} artifact(s) + install scripts + latest.txt to the OSS mirror."
|
||||
echo " China install:"
|
||||
echo " DWS_RELEASE_BASE=https://<your-domain>/${OSS_PREFIX}/download DWS_VERSION=${VERSION} \\"
|
||||
echo " curl -fsSL https://<your-domain>/${OSS_PREFIX}/install.sh | sh"
|
||||
echo "✅ Synced ${uploaded} artifact(s) + ${pointer_summary}${installer_summary} to the OSS mirror."
|
||||
echo " Mirror path: ${OSS_PREFIX}/download/${VERSION}/"
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
|
||||
printf 'usage: verify-delivered-stable.sh <stable-tag> <commit>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
release_is_stable_version "$TAG" || {
|
||||
printf 'invalid stable delivery baseline: %s\n' "$TAG" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v curl >/dev/null 2>&1 || { printf 'curl is required to verify stable delivery\n' >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { printf 'python3 is required to verify stable delivery\n' >&2; exit 1; }
|
||||
|
||||
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
|
||||
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
|
||||
API_TOKEN="$(gh auth token 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$API_TOKEN" ]; then
|
||||
API_TOKEN="${GITHUB_TOKEN:-}"
|
||||
fi
|
||||
github_get() {
|
||||
if [ -n "$API_TOKEN" ]; then
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
"https://api.github.com/$1" 2>/dev/null && return 0
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"https://api.github.com/$1"
|
||||
}
|
||||
|
||||
if git rev-parse --verify --quiet "refs/tags/$TAG^{commit}" >/dev/null; then
|
||||
local_commit="$(git rev-parse "refs/tags/$TAG^{commit}")"
|
||||
[ "$local_commit" = "$EXPECTED_COMMIT" ] || {
|
||||
printf 'stable tag %s resolves to %s, expected %s\n' "$TAG" "$local_commit" "$EXPECTED_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
stable_state="$(
|
||||
github_get "repos/$REPOSITORY/releases/tags/$TAG" \
|
||||
| python3 -c 'import json,sys
|
||||
r=json.load(sys.stdin)
|
||||
print("%s\t%s\t%s" % (r.get("tag_name", ""), str(r.get("draft", "")).lower(), str(r.get("prerelease", "")).lower()))'
|
||||
)" || {
|
||||
printf 'could not query previous stable release %s in %s\n' "$TAG" "$REPOSITORY" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$stable_state" = "$(printf '%s\tfalse\tfalse' "$TAG")" ] || {
|
||||
printf '%s is not a public stable GitHub Release in %s (got: %s)\n' \
|
||||
"$TAG" "$REPOSITORY" "$stable_state" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
stable_runs="$(
|
||||
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100" \
|
||||
| python3 -c 'import json,sys
|
||||
for run in json.load(sys.stdin).get("workflow_runs", []):
|
||||
print("%s\t%s\t%s" % (run.get("head_sha", ""), run.get("head_branch", ""), run.get("conclusion", "")))'
|
||||
)" || {
|
||||
printf 'could not query Release workflow delivery for %s in %s\n' "$TAG" "$REPOSITORY" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TAG" '
|
||||
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' || {
|
||||
printf 'Release workflow did not complete successfully for stable baseline %s at %s\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'Delivered stable baseline verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp/expected" <<'EOF'
|
||||
checksums.txt
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-skills.zip
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
EOF
|
||||
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/releases/tags/$TAG" \
|
||||
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
|
||||
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'GitHub Release asset set verified: %s\n' "$TAG"
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
local_object="$(git rev-parse "refs/tags/$TAG")"
|
||||
remote_ref="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/git/ref/tags/$TAG" \
|
||||
--jq '[.object.type, .object.sha] | @tsv'
|
||||
)"
|
||||
[ "$remote_ref" = "$(printf 'tag\t%s' "$local_object")" ] || {
|
||||
printf 'remote tag object for %s differs from the sealed annotated tag (local=%s remote=%s)\n' \
|
||||
"$TAG" "$local_object" "$remote_ref" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
remote_tag="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/git/tags/$local_object" \
|
||||
--jq '[.tag, .object.type, .object.sha] | @tsv'
|
||||
)"
|
||||
[ "$remote_tag" = "$(printf '%s\tcommit\t%s' "$TAG" "$EXPECTED_COMMIT")" ] || {
|
||||
printf 'remote annotated tag %s does not peel to expected commit %s (got: %s)\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT" "$remote_tag" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'GitHub tag authority verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
|
||||
@@ -5,6 +5,10 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
FORMULA_PATH="$DIST_DIR/homebrew/dingtalk-workspace-cli-local.rb"
|
||||
NPM_STAGE_DIR="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
RUN_NPM=1
|
||||
RUN_BREW=1
|
||||
EXPECTED_VERSION=""
|
||||
VERIFY_SKILL_TARGETS=1
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$*"
|
||||
@@ -23,6 +27,34 @@ need_file() {
|
||||
[ -f "$1" ] || err "required file not found: $1"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 [--npm-only|--brew-only] [--expected-version <vX.Y.Z>] [--skip-skill-targets]" >&2
|
||||
}
|
||||
|
||||
mode_seen=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--npm-only)
|
||||
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
||||
RUN_NPM=1; RUN_BREW=0; mode_seen=1; shift
|
||||
;;
|
||||
--brew-only)
|
||||
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
||||
RUN_NPM=0; RUN_BREW=1; mode_seen=1; shift
|
||||
;;
|
||||
--expected-version)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
EXPECTED_VERSION="${2#v}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-skill-targets) VERIFY_SKILL_TARGETS=0; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -z "$EXPECTED_VERSION" ] || need_cmd strings
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
|
||||
HOME_AGENT_PARENTS="
|
||||
.claude
|
||||
@@ -60,13 +92,11 @@ HOME_SKILL_TARGETS="
|
||||
.hermes/skills/dws
|
||||
"
|
||||
cleanup() {
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
||||
if [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null 2>&1 || true
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
@@ -111,8 +141,18 @@ verify_npm() {
|
||||
npm install -g "$tarball_path" >/dev/null
|
||||
|
||||
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
|
||||
"$npm_prefix/bin/dws" --help >/dev/null
|
||||
verify_skill_targets "$npm_home"
|
||||
HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
|
||||
need_file "$vendor_bin"
|
||||
strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \
|
||||
err "npm-installed binary does not embed expected version v$EXPECTED_VERSION"
|
||||
EXPECTED_VERSION="$EXPECTED_VERSION" node -e '
|
||||
const pkg = require(process.argv[1]);
|
||||
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
|
||||
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
|
||||
fi
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
|
||||
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm uninstall -g dingtalk-workspace-cli >/dev/null
|
||||
@@ -123,6 +163,10 @@ verify_npm() {
|
||||
verify_brew() {
|
||||
need_cmd brew
|
||||
need_file "$FORMULA_PATH"
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
grep -Fq " version \"$EXPECTED_VERSION\"" "$FORMULA_PATH" || \
|
||||
err "Homebrew formula does not declare version $EXPECTED_VERSION"
|
||||
fi
|
||||
|
||||
brew_home="$TMP_ROOT/brew-home"
|
||||
mkdir -p "$brew_home"
|
||||
@@ -130,36 +174,39 @@ verify_brew() {
|
||||
BREW_TAP_NAME="local/dws-package-verify-$$"
|
||||
|
||||
say "==> verifying Homebrew formula install"
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null
|
||||
|
||||
tap_repo="$(
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --repository "$BREW_TAP_NAME"
|
||||
)"
|
||||
mkdir -p "$tap_repo/Formula"
|
||||
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
|
||||
sed "s|skill_home_override = \"\"|skill_home_override = \"$brew_home\"|" \
|
||||
"$FORMULA_PATH" > "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
|
||||
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
|
||||
|
||||
prefix="$(
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --prefix dingtalk-workspace-cli-local
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --prefix "$BREW_TAP_NAME/dingtalk-workspace-cli-local"
|
||||
)"
|
||||
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
|
||||
"$prefix/bin/dws" --help >/dev/null
|
||||
verify_skill_targets "$brew_home"
|
||||
HOME="$brew_home" "$prefix/bin/dws" --help >/dev/null
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \
|
||||
err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION"
|
||||
installed_version="$(HOMEBREW_NO_AUTO_UPDATE=1 brew list --versions "$BREW_TAP_NAME/dingtalk-workspace-cli-local" | awk '{ print $2 }')"
|
||||
[ "$installed_version" = "$EXPECTED_VERSION" ] || \
|
||||
err "Homebrew installed version $installed_version, expected $EXPECTED_VERSION"
|
||||
fi
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$brew_home"
|
||||
}
|
||||
|
||||
need_file "$DIST_DIR/dws-skills.zip"
|
||||
|
||||
verify_npm
|
||||
verify_brew
|
||||
[ "$RUN_NPM" -eq 0 ] || verify_npm
|
||||
[ "$RUN_BREW" -eq 0 ] || verify_brew
|
||||
|
||||
say "Package-manager verification complete."
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
|
||||
|
||||
[ -n "$VERSION" ] || { printf 'expected release version is required\n' >&2; exit 2; }
|
||||
SEMVER="${VERSION#v}"
|
||||
CHECKSUMS="$DIST_DIR/checksums.txt"
|
||||
EXPECTED_PLATFORM_ASSETS="
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
"
|
||||
EXPECTED_ASSETS="$EXPECTED_PLATFORM_ASSETS
|
||||
dws-skills.zip
|
||||
"
|
||||
|
||||
[ -f "$CHECKSUMS" ] || { printf 'missing checksums.txt in %s\n' "$DIST_DIR" >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-binary.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
|
||||
# The public asset namespace is an exact set. GoReleaser may also leave local
|
||||
# metadata files (artifacts.json/config.yaml/metadata.json), but the upload and
|
||||
# npm staging globs never publish them.
|
||||
printf '%s\nchecksums.txt\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-root"
|
||||
for path in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$CHECKSUMS"; do
|
||||
[ -f "$path" ] || continue
|
||||
basename "$path"
|
||||
done | LC_ALL=C sort > "$tmp/actual-root"
|
||||
if ! diff -u "$tmp/expected-root" "$tmp/actual-root"; then
|
||||
printf 'public release assets must contain exactly the supported files\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checksum_format_ok=1
|
||||
awk '
|
||||
NF != 2 { bad = 1; next }
|
||||
$1 !~ /^[0-9a-fA-F]{64}$/ { bad = 1; next }
|
||||
{ print $2 }
|
||||
END { if (bad) exit 1 }
|
||||
' "$CHECKSUMS" > "$tmp/checksum-assets" || checksum_format_ok=0
|
||||
[ "$checksum_format_ok" -eq 1 ] || {
|
||||
printf 'checksums.txt must contain only SHA-256 and filename pairs\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
skills_checksum_count="$(awk '$2 == "dws-skills.zip" { count++ } END { print count + 0 }' "$CHECKSUMS")"
|
||||
[ "$skills_checksum_count" -eq 1 ] || {
|
||||
printf 'checksums.txt must contain dws-skills.zip exactly once (found %s)\n' "$skills_checksum_count" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-checksums"
|
||||
LC_ALL=C sort "$tmp/checksum-assets" > "$tmp/actual-checksums"
|
||||
if ! diff -u "$tmp/expected-checksums" "$tmp/actual-checksums"; then
|
||||
printf 'checksums.txt must describe exactly the supported release assets\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$DIST_DIR" && sha256sum --check checksums.txt)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$DIST_DIR" && shasum -a 256 --check checksums.txt)
|
||||
else
|
||||
printf 'sha256sum or shasum is required\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_binary_version() {
|
||||
asset="$1"
|
||||
extract_dir="$tmp/extract-${asset}"
|
||||
mkdir -p "$extract_dir"
|
||||
case "$asset" in
|
||||
*.tar.gz)
|
||||
tar -xzf "$DIST_DIR/$asset" -C "$extract_dir"
|
||||
binary="$extract_dir/dws"
|
||||
;;
|
||||
*.zip)
|
||||
unzip -q "$DIST_DIR/$asset" -d "$extract_dir"
|
||||
binary="$extract_dir/dws.exe"
|
||||
;;
|
||||
*)
|
||||
printf 'unsupported release archive: %s\n' "$asset" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
[ -f "$binary" ] || {
|
||||
printf '%s does not contain the expected dws binary\n' "$asset" >&2
|
||||
return 1
|
||||
}
|
||||
strings "$binary" | grep -Fqx "v$SEMVER" || {
|
||||
printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
for asset in $EXPECTED_PLATFORM_ASSETS; do
|
||||
verify_binary_version "$asset"
|
||||
done
|
||||
|
||||
printf 'Release artifacts verified for v%s.\n' "$SEMVER"
|
||||
@@ -0,0 +1,219 @@
|
||||
package mock_mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
)
|
||||
|
||||
const mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
|
||||
|
||||
type recordedToolCall struct {
|
||||
path string
|
||||
method string
|
||||
authorization string
|
||||
jsonrpc string
|
||||
tool string
|
||||
arguments map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
// TestCLIHelperProcess runs the production CLI entrypoint with real os.Args.
|
||||
// The parent test supplies only isolated temp directories, a loopback endpoint,
|
||||
// and a synthetic token accepted by the local fake server.
|
||||
func TestCLIHelperProcess(t *testing.T) {
|
||||
if os.Getenv(mockMCPSmokeHelperEnv) != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
marker := -1
|
||||
for i, arg := range os.Args {
|
||||
if arg == "--" {
|
||||
marker = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if marker < 0 {
|
||||
fmt.Fprintln(os.Stderr, "Mock MCP smoke helper: missing -- argument marker")
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Args = append([]string{"dws"}, os.Args[marker+1:]...)
|
||||
os.Exit(app.Execute())
|
||||
}
|
||||
|
||||
func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) {
|
||||
var requestsMu sync.Mutex
|
||||
var requests []recordedToolCall
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := recordedToolCall{
|
||||
path: r.URL.Path,
|
||||
method: r.Method,
|
||||
authorization: r.Header.Get("Authorization"),
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil {
|
||||
call.err = err
|
||||
} else {
|
||||
call.jsonrpc = envelope.JSONRPC
|
||||
call.tool = envelope.Params.Name
|
||||
call.arguments = envelope.Params.Arguments
|
||||
if envelope.Method != "tools/call" {
|
||||
call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method)
|
||||
}
|
||||
}
|
||||
requestsMu.Lock()
|
||||
requests = append(requests, call)
|
||||
requestsMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": envelope.ID,
|
||||
"result": map[string]any{
|
||||
"content": []map[string]any{{
|
||||
"type": "text",
|
||||
"text": `{"success":true,"result":[{"userId":"mock-user-1","name":"Local Mock"}]}`,
|
||||
}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
env := isolatedCLIEnv(t, map[string]string{
|
||||
"DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact",
|
||||
})
|
||||
args := []string{
|
||||
"--token", "ci-smoke-token",
|
||||
"--format", "json",
|
||||
"contact", "user", "get",
|
||||
"--ids", "user-001,user-002",
|
||||
}
|
||||
stdout, stderr, err := runCLI(t, env, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout, stderr)
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
recorded := append([]recordedToolCall(nil), requests...)
|
||||
requestsMu.Unlock()
|
||||
if len(recorded) != 1 {
|
||||
t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded)
|
||||
}
|
||||
call := recorded[0]
|
||||
if call.err != nil {
|
||||
t.Fatal(call.err)
|
||||
}
|
||||
if call.path != "/mcp/contact" {
|
||||
t.Fatalf("request path = %q, want /mcp/contact", call.path)
|
||||
}
|
||||
if call.method != http.MethodPost {
|
||||
t.Fatalf("HTTP method = %q, want POST", call.method)
|
||||
}
|
||||
if call.authorization != "Bearer ci-smoke-token" {
|
||||
t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization)
|
||||
}
|
||||
if call.jsonrpc != "2.0" {
|
||||
t.Fatalf("jsonrpc = %q, want 2.0", call.jsonrpc)
|
||||
}
|
||||
if call.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("tool = %q, want get_user_info_by_user_ids", call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{"user_id_list": []any{"user-001", "user-002"}}
|
||||
if !reflect.DeepEqual(call.arguments, wantArgs) {
|
||||
t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
|
||||
}
|
||||
if payload["success"] != true {
|
||||
t.Fatalf("CLI success = %#v, want true; payload=%#v", payload["success"], payload)
|
||||
}
|
||||
result, ok := payload["result"].([]any)
|
||||
if !ok || len(result) != 1 {
|
||||
t.Fatalf("CLI result = %#v, want one mock user", payload["result"])
|
||||
}
|
||||
user, _ := result[0].(map[string]any)
|
||||
if user["userId"] != "mock-user-1" {
|
||||
t.Fatalf("CLI userId = %#v, want mock-user-1; payload=%#v", user["userId"], payload)
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedCLIEnv(t *testing.T, extra map[string]string) []string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
controlled := map[string]string{
|
||||
"HOME": root,
|
||||
"USERPROFILE": root,
|
||||
"DWS_CONFIG_DIR": filepath.Join(root, "config"),
|
||||
"DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"),
|
||||
"DWS_DISABLE_KEYCHAIN": "1",
|
||||
"HTTP_PROXY": "http://127.0.0.1:1",
|
||||
"HTTPS_PROXY": "http://127.0.0.1:1",
|
||||
"http_proxy": "http://127.0.0.1:1",
|
||||
"https_proxy": "http://127.0.0.1:1",
|
||||
"NO_PROXY": "127.0.0.1,localhost,::1",
|
||||
"no_proxy": "127.0.0.1,localhost,::1",
|
||||
mockMCPSmokeHelperEnv: "1",
|
||||
"DWS_ALLOW_HTTP_ENDPOINTS": "1",
|
||||
"DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1",
|
||||
}
|
||||
for key, value := range extra {
|
||||
controlled[key] = value
|
||||
}
|
||||
|
||||
env := make([]string, 0, len(controlled)+8)
|
||||
for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
}
|
||||
for key, value := range controlled {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
sort.Strings(env)
|
||||
return env
|
||||
}
|
||||
|
||||
func runCLI(t *testing.T, env []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...)
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], processArgs...)
|
||||
cmd.Env = env
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String())
|
||||
}
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string {
|
||||
func postGoreleaserEnv(t *testing.T, distDir, version, releaseBaseURL string) []string {
|
||||
t.Helper()
|
||||
|
||||
binDir := t.TempDir()
|
||||
@@ -106,7 +106,7 @@ func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string {
|
||||
|
||||
return append(os.Environ(),
|
||||
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"DWS_PACKAGE_VERSION=v0.0.0-test",
|
||||
"DWS_PACKAGE_VERSION="+version,
|
||||
"DWS_PACKAGE_DIST_DIR="+distDir,
|
||||
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
|
||||
)
|
||||
@@ -134,7 +134,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
seedDistArtifacts(t, distDir, []string{archiveName})
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3")
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
||||
@@ -161,6 +161,8 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
formulaText := string(formulaData)
|
||||
for _, want := range []string{
|
||||
"class DingtalkWorkspaceCliLocal < Formula",
|
||||
"version \"1.2.3\"",
|
||||
"skill_home_override = \"\"",
|
||||
"resource \"skills\" do",
|
||||
"DingTalk Workspace CLI",
|
||||
} {
|
||||
@@ -177,6 +179,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
releaseFormulaText := string(releaseFormulaData)
|
||||
for _, want := range []string{
|
||||
"class DingtalkWorkspaceCli < Formula",
|
||||
"version \"1.2.3\"",
|
||||
"https://downloads.example.com/dws/releases/v1.2.3/" + archiveName,
|
||||
"https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip",
|
||||
} {
|
||||
@@ -219,13 +222,20 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify checksums.txt was updated to include skills zip
|
||||
// Re-running post packaging must replace, not duplicate, the skills checksum.
|
||||
cmd = exec.Command("sh", scriptPath)
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("second post-goreleaser.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
// Verify checksums.txt includes exactly one skills zip entry.
|
||||
checksumsData, err := os.ReadFile(filepath.Join(distDir, "checksums.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(checksums.txt) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(checksumsData), "dws-skills.zip") {
|
||||
t.Fatalf("checksums.txt missing dws-skills.zip entry:\n%s", string(checksumsData))
|
||||
if count := strings.Count(string(checksumsData), "dws-skills.zip"); count != 1 {
|
||||
t.Fatalf("checksums.txt dws-skills.zip count = %d, want 1:\n%s", count, checksumsData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +263,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
|
||||
seedDistArtifacts(t, distDir, allArchives)
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v9.9.9")
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "v9.9.9", "https://downloads.example.com/dws/releases/v9.9.9")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
||||
@@ -267,7 +277,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
|
||||
}
|
||||
|
||||
packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets")
|
||||
for _, rel := range append(allArchives, "dws-skills.zip") {
|
||||
for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") {
|
||||
if _, err := os.Stat(filepath.Join(packageAssetsDir, rel)); err != nil {
|
||||
t.Fatalf("npm asset missing %q: %v", rel, err)
|
||||
}
|
||||
@@ -330,7 +340,7 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
|
||||
seedDistArtifacts(t, distDir, []string{archiveName})
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v0.0.0")
|
||||
cmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
|
||||
@@ -392,38 +402,40 @@ func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
build := strings.Index(workflow, "Build release artifacts without publishing")
|
||||
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
|
||||
upload := strings.Index(workflow, "Upload finalized signed assets to release")
|
||||
if postProcess == -1 || upload == -1 || upload < postProcess {
|
||||
t.Fatalf("finalized asset upload must run after post-goreleaser.sh")
|
||||
preserve := strings.Index(workflow, "Preserve finalized distribution files")
|
||||
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
|
||||
publishJob := strings.Index(workflow, "publish-release:")
|
||||
if build == -1 || postProcess == -1 || preserve == -1 || verifyJob == -1 || publishJob == -1 ||
|
||||
!(build < postProcess && postProcess < preserve && preserve < verifyJob && verifyJob < publishJob) {
|
||||
t.Fatalf("post-processed assets must be preserved, Apple-verified, and only then published")
|
||||
}
|
||||
|
||||
if !strings.Contains(workflow[upload:], "./scripts/release/finalize-github-release.sh") {
|
||||
t.Fatal("release workflow must delegate atomic finalization to finalize-github-release.sh")
|
||||
}
|
||||
|
||||
finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
|
||||
}
|
||||
finalizeData, err := os.ReadFile(finalizePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", finalizePath, err)
|
||||
}
|
||||
finalize := string(finalizeData)
|
||||
|
||||
buildSection := workflow[build:verifyJob]
|
||||
for _, required := range []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"--skip=publish",
|
||||
"actions/upload-artifact@v4",
|
||||
"finalized-release-dist",
|
||||
} {
|
||||
if !strings.Contains(buildSection, required) {
|
||||
t.Errorf("signed build stage is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
publishSection := workflow[publishJob:]
|
||||
for _, required := range []string{
|
||||
"actions/download-artifact@v4",
|
||||
"dist/dws-*.tar.gz",
|
||||
"dist/dws-windows-*.zip",
|
||||
"checksums.txt",
|
||||
"dws-skills.zip",
|
||||
"gh release upload",
|
||||
"gh release view",
|
||||
"--clobber",
|
||||
"release asset digest mismatch",
|
||||
"verify-release-artifacts.sh",
|
||||
} {
|
||||
if !strings.Contains(finalize, required) {
|
||||
t.Errorf("finalized asset upload is missing %q", required)
|
||||
if !strings.Contains(publishSection, required) {
|
||||
t.Errorf("immutable publication stage is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,7 +454,7 @@ func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) {
|
||||
workflow := string(data)
|
||||
|
||||
prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate")
|
||||
goReleaser := strings.Index(workflow, "Run GoReleaser")
|
||||
goReleaser := strings.Index(workflow, "Build release artifacts without publishing")
|
||||
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
|
||||
cleanup := strings.Index(workflow, "Remove Apple Developer ID certificate")
|
||||
if prepare == -1 || goReleaser == -1 || postProcess == -1 || cleanup == -1 ||
|
||||
@@ -533,36 +545,35 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
upload := strings.Index(workflow, "Upload finalized signed assets to release")
|
||||
preserve := strings.Index(workflow, "Preserve finalized distribution files")
|
||||
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
|
||||
publishJob := strings.Index(workflow, "publish-release:")
|
||||
if upload == -1 || verifyJob == -1 || publishJob == -1 || !(upload < verifyJob && verifyJob < publishJob) {
|
||||
t.Fatal("finalized Draft assets must be uploaded, Apple-verified, and only then published")
|
||||
if preserve == -1 || verifyJob == -1 || publishJob == -1 || !(preserve < verifyJob && verifyJob < publishJob) {
|
||||
t.Fatal("finalized artifacts must be preserved, Apple-verified, and only then published")
|
||||
}
|
||||
|
||||
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false`)
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`)
|
||||
if codesign == -1 || publish == -1 {
|
||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||
}
|
||||
|
||||
buildSection := workflow[upload:verifyJob]
|
||||
buildSection := workflow[preserve:verifyJob]
|
||||
for _, required := range []string{
|
||||
`DWS_PUBLISH_RELEASE: "false"`,
|
||||
"actions/upload-artifact@v4",
|
||||
"finalized-release-dist",
|
||||
} {
|
||||
if !strings.Contains(buildSection, required) {
|
||||
t.Errorf("Draft build stage is missing %q", required)
|
||||
t.Errorf("signed build stage is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
verifySection := workflow[verifyJob:publishJob]
|
||||
for _, required := range []string{
|
||||
"runs-on: macos-latest",
|
||||
"gh release download",
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"actions/download-artifact@v4",
|
||||
"finalized-release-dist",
|
||||
`dws-darwin-${arch}.tar.gz`,
|
||||
"codesign --verify --strict --verbose=4",
|
||||
} {
|
||||
if !strings.Contains(verifySection, required) {
|
||||
@@ -574,9 +585,9 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
for _, required := range []string{
|
||||
"verify-darwin-signatures",
|
||||
"actions/download-artifact@v4",
|
||||
"Publish verified Draft release",
|
||||
"Publish stable to npm",
|
||||
"Publish prerelease to npm beta",
|
||||
"Publish or reuse immutable GitHub Release",
|
||||
"gh release upload",
|
||||
"Publish missing version to npm channel",
|
||||
} {
|
||||
if !strings.Contains(publishSection, required) {
|
||||
t.Errorf("post-verification publication stage is missing %q", required)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type releaseEntryFixture struct {
|
||||
repo *releaseTestRepo
|
||||
entry string
|
||||
log string
|
||||
}
|
||||
|
||||
func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
|
||||
t.Helper()
|
||||
r := newReleaseTestRepo(t)
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
entry := filepath.Join(r.root, "scripts", "release", "dws-release.sh")
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, filepath.Join(sourceRoot, "scripts", "release", "dws-release.sh"), entry, 0o755)
|
||||
fakeDelegate := func(name string) []byte {
|
||||
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + "' >> \"$DWS_RELEASE_CALL_LOG\"\nfor arg in \"$@\"; do printf '\\t%s' \"$arg\" >> \"$DWS_RELEASE_CALL_LOG\"; done\nprintf '\\n' >> \"$DWS_RELEASE_CALL_LOG\"\nexit \"${DWS_RELEASE_FAKE_EXIT:-0}\"\n")
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755)
|
||||
r.commitAndPush(t, "install unified release entry fixture")
|
||||
return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{f.entry}, args...)...)
|
||||
cmd.Dir = f.repo.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_CALL_LOG="+f.log)
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) configureRemote(t *testing.T, remote string) {
|
||||
t.Helper()
|
||||
output, err := f.run(t, nil, "config", "--remote", remote)
|
||||
if err != nil {
|
||||
t.Fatalf("config remote error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *releaseEntryFixture) callLog(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(f.log)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(call log) error = %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryPreparesMissingChangelogAndStops(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prerelease",
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
want: "prepare\tprerelease\tv1.0.1-beta.1\n",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
|
||||
want: "prepare\tstable\tv1.0.1\t--from-beta\tv1.0.1-beta.1\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release prepare error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != test.want {
|
||||
t.Fatalf("delegate calls = %q, want %q", got, test.want)
|
||||
}
|
||||
if !strings.Contains(output, "publishing intentionally stopped") {
|
||||
t.Fatalf("prepare output did not make the stop boundary clear:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRoutesOneGuardedCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
section string
|
||||
args []string
|
||||
wantCall string
|
||||
}{
|
||||
{
|
||||
name: "check prerelease",
|
||||
section: betaSection(),
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin"},
|
||||
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n",
|
||||
},
|
||||
{
|
||||
name: "publish prerelease keeps confirmation",
|
||||
section: betaSection(),
|
||||
args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\t--publish\n",
|
||||
},
|
||||
{
|
||||
name: "check stable",
|
||||
section: stableSection(),
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"},
|
||||
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\n",
|
||||
},
|
||||
{
|
||||
name: "publish stable keeps confirmation",
|
||||
section: stableSection(),
|
||||
args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin", "--publish"},
|
||||
wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\t--publish\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(test.section)), 0o644)
|
||||
f.repo.commitAndPush(t, "add candidate changelog")
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws-release route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != test.wantCall {
|
||||
t.Fatalf("delegate calls = %q, want %q\noutput:\n%s", got, test.wantCall, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsInvalidInvocationBeforeDelegation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "invalid version", args: []string{"1.0.1"}, want: "invalid release version"},
|
||||
{name: "stable missing beta", args: []string{"v1.0.1"}, want: "requires --from-beta"},
|
||||
{name: "prerelease with beta", args: []string{"v1.0.1-beta.1", "--from-beta", "v1.0.1-beta.1"}, want: "only valid for stable"},
|
||||
{name: "conflicting mode", args: []string{"v1.0.1-beta.1", "--check", "--publish"}, want: "cannot be used together"},
|
||||
{name: "yes bypass", args: []string{"v1.0.1-beta.1", "--publish", "--yes"}, want: "unknown argument"},
|
||||
{name: "unknown flag", args: []string{"v1.0.1-beta.1", "--force"}, want: "unknown argument"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
output, err := f.run(t, nil, test.args...)
|
||||
if err == nil || !strings.Contains(output, test.want) {
|
||||
t.Fatalf("invalid invocation: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("invalid invocation delegated work: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryUsesConfiguredRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err != nil {
|
||||
t.Fatalf("configured remote route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n"
|
||||
if got := f.callLog(t); got != want {
|
||||
t.Fatalf("delegate calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryFailsClosedWithoutRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, "release remote is not configured") {
|
||||
t.Fatalf("missing remote did not fail closed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("missing remote delegated work: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryPropagatesDelegateFailure(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
output, err := f.run(t, []string{"DWS_RELEASE_FAKE_EXIT=23"}, "v1.0.1-beta.1", "--remote", "origin")
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
|
||||
t.Fatalf("delegate error was not propagated: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryFastForwardsCleanMain(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
mustRun(t, filepath.Dir(other), "git", "clone", "-b", "main", f.repo.remote, other)
|
||||
mustRun(t, other, "git", "config", "user.name", "Release Test")
|
||||
mustRun(t, other, "git", "config", "user.email", "release-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(other, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustRun(t, other, "git", "add", "CHANGELOG.md")
|
||||
mustRun(t, other, "git", "commit", "-m", "add remote beta changelog")
|
||||
mustRun(t, other, "git", "push", "origin", "main")
|
||||
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1", "--remote", "origin")
|
||||
if err != nil {
|
||||
t.Fatalf("fast-forward route error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
localHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "HEAD"))
|
||||
remoteHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "refs/remotes/origin/main"))
|
||||
if localHead != remoteHead {
|
||||
t.Fatalf("local main was not fast-forwarded: local=%s remote=%s", localHead, remoteHead)
|
||||
}
|
||||
if got := f.callLog(t); !strings.HasPrefix(got, "release\tprerelease\tv1.0.1-beta.1") {
|
||||
t.Fatalf("fast-forward did not reach guarded delegate: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsRetargetedRemote(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
f.configureRemote(t, "origin")
|
||||
mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
f.repo.commitAndPush(t, "add beta changelog")
|
||||
otherRemote := filepath.Join(t.TempDir(), "other.git")
|
||||
mustRun(t, f.repo.root, "git", "init", "--bare", otherRemote)
|
||||
mustRun(t, f.repo.root, "git", "remote", "set-url", "origin", otherRemote)
|
||||
|
||||
output, err := f.run(t, nil, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, "changed repository identity") {
|
||||
t.Fatalf("retargeted remote was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("retargeted remote delegated work: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSReleaseEntryRejectsAuthorityOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "non github remote", env: "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1", want: "test-only"},
|
||||
{name: "official tags URL", env: "DWS_RELEASE_OFFICIAL_TAGS_URL=https://example.com/tags.git", want: "cannot override"},
|
||||
{name: "official repository", env: "DWS_RELEASE_OFFICIAL_REPOSITORY=other/repo", want: "cannot override"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newReleaseEntryFixture(t)
|
||||
output, err := f.run(t, []string{test.env}, "v1.0.1-beta.1")
|
||||
if err == nil || !strings.Contains(output, test.want) {
|
||||
t.Fatalf("authority override was not rejected: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if got := f.callLog(t); got != "" {
|
||||
t.Fatalf("authority override delegated work: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRejectsNonInteractivePublishWithoutYes(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "interactive confirmation is unavailable") {
|
||||
t.Fatalf("non-interactive publish did not require confirmation: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1"); got != "" {
|
||||
t.Fatalf("rejected non-interactive publish created a tag: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var releasePlatformAssets = []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-windows-arm64.zip",
|
||||
}
|
||||
|
||||
func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) {
|
||||
t.Helper()
|
||||
stage := t.TempDir()
|
||||
binary := "dws"
|
||||
if strings.HasSuffix(asset, ".zip") {
|
||||
binary = "dws.exe"
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(stage, binary), []byte("fake release binary\n"+version+"\n"), 0o755)
|
||||
if strings.HasSuffix(asset, ".zip") {
|
||||
mustRun(t, stage, "zip", "-q", filepath.Join(dist, asset), binary)
|
||||
return
|
||||
}
|
||||
mustRun(t, stage, "tar", "-czf", filepath.Join(dist, asset), binary)
|
||||
}
|
||||
|
||||
func writeReleaseChecksums(t *testing.T, dist string, includeSkills bool) {
|
||||
t.Helper()
|
||||
assets := append([]string{}, releasePlatformAssets...)
|
||||
if includeSkills {
|
||||
assets = append(assets, "dws-skills.zip")
|
||||
}
|
||||
sort.Strings(assets)
|
||||
var lines []string
|
||||
for _, asset := range assets {
|
||||
data, err := os.ReadFile(filepath.Join(dist, asset))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", asset, err)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%x %s", sha256.Sum256(data), asset))
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dist, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func seedVersionedReleaseArtifacts(t *testing.T, dist, version string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dist, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", dist, err)
|
||||
}
|
||||
for _, asset := range releasePlatformAssets {
|
||||
writeVersionedReleaseArchive(t, dist, asset, version)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(dist, "dws-skills.zip"), []byte("fake skills\n"), 0o644)
|
||||
writeReleaseChecksums(t, dist, true)
|
||||
}
|
||||
|
||||
type releaseTestRepo struct {
|
||||
root string
|
||||
remote string
|
||||
contract string
|
||||
prepare string
|
||||
releaseCmd string
|
||||
lib string
|
||||
verify string
|
||||
}
|
||||
|
||||
func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
t.Helper()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "work")
|
||||
remote := filepath.Join(base, "remote.git")
|
||||
mustRun(t, base, "git", "init", "--bare", remote)
|
||||
mustRun(t, base, "git", "init", "-b", "main", root)
|
||||
mustRun(t, root, "git", "config", "user.name", "Release Test")
|
||||
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "CHANGELOG.md"), []byte(releaseChangelog()), 0o644)
|
||||
mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("initial\n"), 0o644)
|
||||
mustRun(t, root, "git", "add", ".")
|
||||
mustRun(t, root, "git", "commit", "-m", "initial stable")
|
||||
mustRun(t, root, "git", "tag", "-a", "v1.0.0", "-m", "Release v1.0.0")
|
||||
mustRun(t, root, "git", "remote", "add", "origin", remote)
|
||||
mustRun(t, root, "git", "push", "-u", "origin", "main")
|
||||
mustRun(t, root, "git", "push", "origin", "v1.0.0")
|
||||
|
||||
return &releaseTestRepo{
|
||||
root: root,
|
||||
remote: remote,
|
||||
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionOrdering(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
lib := filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh")
|
||||
tests := []struct {
|
||||
candidate string
|
||||
baseline string
|
||||
greater bool
|
||||
}{
|
||||
{candidate: "v1.0.2", baseline: "v1.0.1", greater: true},
|
||||
{candidate: "v1.1.0-beta.1", baseline: "v1.0.9-beta.9", greater: true},
|
||||
{candidate: "v1.0.1-beta.2", baseline: "v1.0.1-beta.1", greater: true},
|
||||
{candidate: "v1.0.1", baseline: "v1.0.1-beta.9", greater: true},
|
||||
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1-beta.2", greater: false},
|
||||
{candidate: "v1.0.1-beta.1", baseline: "v1.0.1", greater: false},
|
||||
{candidate: "v1.0.1", baseline: "v1.0.1", greater: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
cmd := exec.Command("sh", "-c", `. "$1"; release_version_is_greater "$2" "$3"`, "sh", lib, test.candidate, test.baseline)
|
||||
err := cmd.Run()
|
||||
if (err == nil) != test.greater {
|
||||
t.Fatalf("release_version_is_greater(%s, %s) error = %v, want greater=%v", test.candidate, test.baseline, err, test.greater)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNpmPackingIgnoresLifecycleScripts(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
packageDir := filepath.Join(t.TempDir(), "package")
|
||||
marker := filepath.Join(t.TempDir(), "prepack-ran")
|
||||
manifest := fmt.Sprintf(`{
|
||||
"name": "dingtalk-workspace-cli",
|
||||
"version": "1.2.3",
|
||||
"scripts": {"prepack": "touch %s"},
|
||||
"files": ["README.md"]
|
||||
}
|
||||
`, marker)
|
||||
mustWriteFile(t, filepath.Join(packageDir, "package.json"), []byte(manifest), 0o644)
|
||||
mustWriteFile(t, filepath.Join(packageDir, "README.md"), []byte("test package\n"), 0o644)
|
||||
outputTarball := filepath.Join(t.TempDir(), "package.tgz")
|
||||
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "pack-npm-package.sh"), packageDir, outputTarball)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("pack-npm-package error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(output)), "sha512-") {
|
||||
t.Fatalf("pack output is not an integrity value: %s", output)
|
||||
}
|
||||
if _, err := os.Stat(outputTarball); err != nil {
|
||||
t.Fatalf("packed tarball missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("npm prepack lifecycle unexpectedly ran: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNpmStagingRejectsUnexpectedLifecycleScripts(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(root, "source")
|
||||
dist := filepath.Join(root, "dist")
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "install.js"), []byte("\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "bin", "dws.js"), []byte("\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "README.md"), []byte("test\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(source, "build", "npm", "package.json.tmpl"), []byte(`{
|
||||
"name": "dingtalk-workspace-cli",
|
||||
"version": "__VERSION__",
|
||||
"bin": {"dws": "./bin/dws.js"},
|
||||
"scripts": {"postinstall": "node install.js", "prepublishOnly": "node steal.js"}
|
||||
}
|
||||
`), 0o644)
|
||||
cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "stage-npm-package.sh"), "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_SOURCE_ROOT="+source, "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), "unexpected npm lifecycle scripts") {
|
||||
t.Fatalf("unexpected lifecycle script was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
|
||||
exact := strings.Join([]string{
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"checksums.txt",
|
||||
"dws-skills.zip",
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
}, "\n")
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
||||
run := func(assets string) (string, error) {
|
||||
cmd := exec.Command("sh", script, "v1.2.3")
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GITHUB_REPOSITORY=owner/repo",
|
||||
"GH_ASSETS=" + assets,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
if output, err := run(exact); err != nil {
|
||||
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
||||
}
|
||||
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
r := newReleaseTestRepo(t)
|
||||
commit := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := filepath.Join(binDir, "curl")
|
||||
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
for arg in "$@"; do last="$arg"; done
|
||||
case "$last" in
|
||||
*/releases/tags/*)
|
||||
printf '{"tag_name":"v1.0.0","draft":false,"prerelease":false}\n'
|
||||
;;
|
||||
*/actions/workflows/release.yml/runs*)
|
||||
printf '{"workflow_runs":[{"head_sha":"%s","head_branch":"v1.0.0","conclusion":"%s"}]}\n' "$EXPECTED_COMMIT" "$RUN_CONCLUSION"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-delivered-stable.sh")
|
||||
run := func(conclusion string) (string, error) {
|
||||
cmd := exec.Command("sh", script, "v1.0.0", commit)
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
|
||||
"EXPECTED_COMMIT=" + commit,
|
||||
"RUN_CONCLUSION=" + conclusion,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
if output, err := run("success"); err != nil {
|
||||
t.Fatalf("delivered stable was rejected: %v\n%s", err, output)
|
||||
}
|
||||
if output, err := run("failure"); err == nil || !strings.Contains(output, "did not complete successfully") {
|
||||
t.Fatalf("orphan stable tag was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseChangelog(sections ...string) string {
|
||||
text := "# Changelog\n\n## [Unreleased]\n\n"
|
||||
for _, section := range sections {
|
||||
text += section
|
||||
if !strings.HasSuffix(text, "\n\n") {
|
||||
text += "\n"
|
||||
}
|
||||
}
|
||||
text += "## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"
|
||||
return text
|
||||
}
|
||||
|
||||
func betaSection() string {
|
||||
return "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate the sealed beta candidate.\n\n"
|
||||
}
|
||||
|
||||
func stableSection() string {
|
||||
return "## [1.0.1] - 2026-07-11\n\nThis release promotes the sealed `v1.0.1-beta.1` contents to stable.\n\n### Changed\n\n- Publish the validated candidate to the stable channel.\n\n"
|
||||
}
|
||||
|
||||
func (r *releaseTestRepo) commitAndPush(t *testing.T, message string) {
|
||||
t.Helper()
|
||||
mustRun(t, r.root, "git", "add", ".")
|
||||
mustRun(t, r.root, "git", "commit", "-m", message)
|
||||
mustRun(t, r.root, "git", "push", "origin", "main")
|
||||
}
|
||||
|
||||
func (r *releaseTestRepo) seedBeta(t *testing.T) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "feature.txt"), []byte("sealed candidate\n"), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1", "-m", "Channel: prerelease")
|
||||
mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1")
|
||||
}
|
||||
|
||||
func runReleaseScript(t *testing.T, workdir, script string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", append([]string{script}, args...)...)
|
||||
cmd.Dir = workdir
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1")
|
||||
if remote, err := exec.Command("git", "-C", workdir, "remote", "get-url", "origin").Output(); err == nil {
|
||||
cmd.Env = append(cmd.Env, "DWS_RELEASE_OFFICIAL_TAGS_URL="+strings.TrimSpace(string(remote)))
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func TestReleaseContractAcceptsPrereleaseAndWritesNotes(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta changelog")
|
||||
|
||||
notes := filepath.Join(t.TempDir(), "notes.md")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "local",
|
||||
"--remote", "origin",
|
||||
"--notes-output", notes,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release contract error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Release contract passed") {
|
||||
t.Fatalf("contract output missing success:\n%s", output)
|
||||
}
|
||||
notesData, err := os.ReadFile(notes)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(notes) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(notesData), "Validate the sealed beta candidate") {
|
||||
t.Fatalf("notes did not come from exact changelog section:\n%s", notesData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
tests := []struct {
|
||||
channel string
|
||||
version string
|
||||
}{
|
||||
{channel: "prerelease", version: "v1.0.1"},
|
||||
{channel: "stable", version: "v1.0.1-beta.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-rc.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-preview"},
|
||||
{channel: "stable", version: "1.0.1"},
|
||||
{channel: "stable", version: "v01.0.1"},
|
||||
{channel: "prerelease", version: "v1.0.1-beta.0"},
|
||||
{channel: "prerelease", version: "v1.0.1-beta.2"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.channel+"_"+strings.ReplaceAll(test.version, ".", "_"), func(t *testing.T) {
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", test.channel,
|
||||
"--version", test.version,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid pair unexpectedly passed:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsBadChangelogSections(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sections string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", sections: "", want: "exactly one section"},
|
||||
{name: "empty", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n", want: "must contain release notes"},
|
||||
{name: "placeholder", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- TODO: write this.\n\n", want: "TODO/TBD"},
|
||||
{name: "duplicate", sections: betaSection() + betaSection(), want: "exactly one section"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(test.sections)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "candidate.txt"), []byte(test.name+"\n"), 0o644)
|
||||
r.commitAndPush(t, "write candidate changelog")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("bad changelog unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, test.want) {
|
||||
t.Fatalf("output missing %q:\n%s", test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
|
||||
mustWriteFile(t, filepath.Join(r.root, "dirty.txt"), []byte("dirty\n"), 0o644)
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "worktree must be clean") {
|
||||
t.Fatalf("dirty worktree was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
mustRun(t, r.root, "git", "add", "dirty.txt")
|
||||
mustRun(t, r.root, "git", "commit", "-m", "local commit not pushed")
|
||||
output, err = runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "must exactly match origin/main") {
|
||||
t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) {
|
||||
t.Run("sealed", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare stable changelog")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "stable",
|
||||
"--version", "v1.0.1",
|
||||
"--from-beta", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sealed stable promotion error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("source drift", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "drift.txt"), []byte("untested change\n"), 0o644)
|
||||
r.commitAndPush(t, "drift after beta")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "stable",
|
||||
"--version", "v1.0.1",
|
||||
"--from-beta", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("drifted stable promotion unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "only CHANGELOG.md may differ") || !strings.Contains(output, "drift.txt") {
|
||||
t.Fatalf("drift output is not actionable:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseContractCIReadsStableBaselineFromAnnotatedTag(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare stable changelog")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1", "-m", "Channel: stable", "-m", "From-Beta: v1.0.1-beta.1")
|
||||
mustRun(t, r.root, "git", "push", "origin", "v1.0.1")
|
||||
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1")
|
||||
|
||||
metadata := filepath.Join(t.TempDir(), "metadata")
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "stable",
|
||||
"--version", "v1.0.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
"--metadata-output", metadata,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CI stable contract error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
metadataData, err := os.ReadFile(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(metadata) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(metadataData), "from_beta=v1.0.1-beta.1") {
|
||||
t.Fatalf("metadata missing annotated tag baseline:\n%s", metadataData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractCIRejectsLightweightTag(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare beta")
|
||||
mustRun(t, r.root, "git", "tag", "v1.0.1-beta.1")
|
||||
mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("lightweight release tag unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "must be annotated") {
|
||||
t.Fatalf("output missing annotated-tag guidance:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustWriteFile(t, filepath.Join(r.root, "after-seal.txt"), []byte("main advanced\n"), 0o644)
|
||||
r.commitAndPush(t, "advance main after beta seal")
|
||||
mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1-beta.1")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--context", "ci",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sealed tag should remain valid after main advances: %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
r.commitAndPush(t, "install changelog preparation")
|
||||
|
||||
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
|
||||
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
|
||||
}
|
||||
|
||||
r.commitAndPush(t, "commit unfinished release notes")
|
||||
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
|
||||
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
|
||||
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644)
|
||||
r.commitAndPush(t, "add unreleased changelog note")
|
||||
|
||||
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
|
||||
cmd.Dir = r.root
|
||||
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
positions := []int{
|
||||
strings.Index(content, "## [Unreleased]"),
|
||||
strings.Index(content, "- Keep this unreleased note."),
|
||||
strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"),
|
||||
strings.Index(content, "## [1.0.0] - 2026-07-01"),
|
||||
}
|
||||
for index, position := range positions {
|
||||
if position < 0 {
|
||||
t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content)
|
||||
}
|
||||
}
|
||||
for index := 1; index < len(positions); index++ {
|
||||
if positions[index-1] >= positions[index] {
|
||||
t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
version string
|
||||
channel string
|
||||
want string
|
||||
doNotWant string
|
||||
installer bool
|
||||
}{
|
||||
{name: "prerelease", version: "v1.2.3-beta.1", channel: "prerelease", want: "/beta.txt", doNotWant: "/latest.txt", installer: false},
|
||||
{name: "stable", version: "v1.2.3", channel: "stable", want: "/latest.txt", doNotWant: "/beta.txt", installer: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, test.version)
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) echo 'ErrorCode=NoSuchKey' >&2; exit 1 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
|
||||
|
||||
cmd := exec.Command("bash", script)
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION="+test.version,
|
||||
"DWS_RELEASE_CHANNEL="+test.channel,
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("sync-to-oss error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ossutil log) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), test.want) {
|
||||
t.Fatalf("mirror log missing %q:\n%s", test.want, logData)
|
||||
}
|
||||
if strings.Contains(string(logData), test.doNotWant) {
|
||||
t.Fatalf("mirror log unexpectedly contains %q:\n%s", test.doNotWant, logData)
|
||||
}
|
||||
hasInstaller := strings.Contains(string(logData), "/dws/install.sh")
|
||||
if hasInstaller != test.installer {
|
||||
t.Fatalf("installer upload = %v, want %v:\n%s", hasInstaller, test.installer, logData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
readAction string
|
||||
want string
|
||||
}{
|
||||
{name: "transport error", readAction: "echo 'connection timed out' >&2; exit 7", want: "Could not read OSS beta.txt"},
|
||||
{name: "empty pointer", readAction: ": > \"$last\"; exit 0", want: "read successfully but is empty"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
scriptText := "#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) " + test.readAction + " ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"
|
||||
mustWriteFile(t, fakeOSSUtil, []byte(scriptText), 0o755)
|
||||
|
||||
cmd := exec.Command("bash", script)
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION=v1.2.3-beta.1",
|
||||
"DWS_RELEASE_CHANNEL=prerelease",
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("pointer failure was not closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
if logData, readErr := os.ReadFile(logPath); readErr == nil && strings.Contains(string(logData), "/beta.txt") {
|
||||
t.Fatalf("failed pointer read still published beta pointer:\n%s", logData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
dist := filepath.Join(root, "dist")
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1")
|
||||
logPath := filepath.Join(root, "ossutil.log")
|
||||
fakeOSSUtil := filepath.Join(root, "ossutil")
|
||||
mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) printf 'v1.2.4-beta.1\\n' > \"$last\"; exit 0 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755)
|
||||
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh"))
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DIST_DIR="+dist,
|
||||
"VERSION=v1.2.3-beta.1",
|
||||
"DWS_RELEASE_CHANNEL=prerelease",
|
||||
"OSS_ACCESS_KEY_ID=test-key",
|
||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||
"OSS_ENDPOINT=https://oss.example.com",
|
||||
"OSS_BUCKET=test-bucket",
|
||||
"OSS_PREFIX=dws",
|
||||
"OSSUTIL="+fakeOSSUtil,
|
||||
"OSSUTIL_LOG="+logPath,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(output), "assets will be repaired without moving it") {
|
||||
t.Fatalf("historical OSS repair failed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
logData, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ossutil log) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(logData), "/beta.txt") {
|
||||
t.Fatalf("historical repair moved beta pointer:\n%s", logData)
|
||||
}
|
||||
if !strings.Contains(string(logData), "/download/v1.2.3-beta.1/") {
|
||||
t.Fatalf("historical repair did not upload target assets:\n%s", logData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseRequiredMirrorsFailWithoutCredentials(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
script string
|
||||
require string
|
||||
want string
|
||||
}{
|
||||
{name: "oss", script: "sync-to-oss.sh", require: "DWS_REQUIRE_OSS=1", want: "OSS mirror sync is required"},
|
||||
{name: "gitee", script: "sync-to-gitee.sh", require: "DWS_REQUIRE_GITEE=1", want: "Gitee mirror sync is enabled"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", test.script))
|
||||
cmd.Dir = sourceRoot
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), test.require}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("required mirror did not fail closed: err=%v, want=%q\noutput:\n%s", err, test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
dist := t.TempDir()
|
||||
seedVersionedReleaseArtifacts(t, dist, "v1.2.3")
|
||||
cmd := exec.Command("sh", r.verify, "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("artifact verification error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
writeReleaseChecksums(t, dist, false)
|
||||
cmd = exec.Command("sh", r.verify, "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil || !strings.Contains(string(output), "dws-skills.zip exactly once") {
|
||||
t.Fatalf("missing checksum was not blocked: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
|
||||
writeReleaseChecksums(t, dist, true)
|
||||
mustWriteFile(t, filepath.Join(dist, "dws-linux-riscv64.tar.gz"), []byte("unexpected\n"), 0o644)
|
||||
cmd = exec.Command("sh", r.verify, "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "public release assets") {
|
||||
t.Fatalf("extra public archive was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dist, "dws-linux-riscv64.tar.gz")); err != nil {
|
||||
t.Fatalf("Remove(extra archive) error = %v", err)
|
||||
}
|
||||
|
||||
writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.2")
|
||||
writeReleaseChecksums(t, dist, true)
|
||||
cmd = exec.Command("sh", r.verify, "v1.2.3")
|
||||
cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist)
|
||||
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary") {
|
||||
t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "No tag was created") {
|
||||
t.Fatalf("validation output missing dry-run result:\n%s", output)
|
||||
}
|
||||
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
|
||||
t.Fatal("validation-only release created a tag")
|
||||
}
|
||||
|
||||
output, err = runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release publish error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(mustOutput(t, r.root, "git", "cat-file", "-t", "v1.0.1-beta.1")); got != "tag" {
|
||||
t.Fatalf("release tag type = %q, want annotated tag", got)
|
||||
}
|
||||
if got := mustOutput(t, r.root, "git", "ls-remote", "--tags", "origin", "refs/tags/v1.0.1-beta.1"); !strings.Contains(got, "refs/tags/v1.0.1-beta.1") {
|
||||
t.Fatalf("remote release tag missing:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
|
||||
hook := filepath.Join(r.remote, "hooks", "pre-receive")
|
||||
mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755)
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("rejected tag push unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "new local tag was removed") {
|
||||
t.Fatalf("push failure output missing cleanup result:\n%s", output)
|
||||
}
|
||||
if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" {
|
||||
t.Fatal("failed push left the local release tag behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release automation")
|
||||
otherRemote := filepath.Join(t.TempDir(), "other.git")
|
||||
mustRun(t, r.root, "git", "init", "--bare", otherRemote)
|
||||
mustRun(t, r.root, "git", "remote", "set-url", "--push", "origin", otherRemote)
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "fetch and push URLs target different repositories") {
|
||||
t.Fatalf("split release authority was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.2-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
|
||||
!strings.Contains(output, "--stable-ref v1.0.1") {
|
||||
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
t.Helper()
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
||||
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", source, err)
|
||||
}
|
||||
mustWriteFile(t, target, data, mode)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package smoke_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cliSmokeHelperEnv = "DWS_CLI_SMOKE_HELPER"
|
||||
|
||||
// TestCLIHelperProcess executes the real app entrypoint in a subprocess. Product
|
||||
// routing inspects os.Args, so using Cobra.SetArgs would not exercise the same
|
||||
// path as the dws binary.
|
||||
func TestCLIHelperProcess(t *testing.T) {
|
||||
if os.Getenv(cliSmokeHelperEnv) != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
marker := -1
|
||||
for i, arg := range os.Args {
|
||||
if arg == "--" {
|
||||
marker = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if marker < 0 {
|
||||
fmt.Fprintln(os.Stderr, "CLI smoke helper: missing -- argument marker")
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Args = append([]string{"dws"}, os.Args[marker+1:]...)
|
||||
os.Exit(app.Execute())
|
||||
}
|
||||
|
||||
func TestCLISmoke_AllPublicCommandsSupportHelp(t *testing.T) {
|
||||
_ = isolatedCLIEnv(t)
|
||||
|
||||
root := app.NewRootCommand()
|
||||
root.InitDefaultHelpCmd()
|
||||
paths := publicCommandPaths(root)
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("public command traversal returned no commands")
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
path := path
|
||||
name := "root"
|
||||
if len(path) > 0 {
|
||||
name = strings.Join(path, "/")
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
args := append(append([]string(nil), path...), "--help")
|
||||
// Help never executes a product handler, so SetArgs is sufficient here.
|
||||
// Real product dispatch is covered below through app.Execute in a
|
||||
// subprocess because that path intentionally inspects os.Args.
|
||||
cmd := app.NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("dws %s failed: %v\noutput:\n%s", strings.Join(args, " "), err, output.String())
|
||||
}
|
||||
if strings.TrimSpace(output.String()) == "" {
|
||||
t.Fatalf("dws %s returned empty help", strings.Join(args, " "))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Logf("validated --help for %d public Cobra command paths", len(paths))
|
||||
}
|
||||
|
||||
func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) {
|
||||
env := isolatedCLIEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
}{
|
||||
{
|
||||
name: "contact search",
|
||||
args: []string{"--mock", "--format", "json", "contact", "user", "search", "--query", "Ada"},
|
||||
wantTool: "search_contact_by_key_word",
|
||||
},
|
||||
{
|
||||
name: "calendar list",
|
||||
args: []string{
|
||||
"--mock", "--format", "json", "calendar", "event", "list",
|
||||
"--start", "2026-07-10T09:00:00+08:00",
|
||||
"--end", "2026-07-10T10:00:00+08:00",
|
||||
},
|
||||
wantTool: "list_calendar_events",
|
||||
},
|
||||
{
|
||||
name: "ding list",
|
||||
args: []string{"--mock", "--format", "json", "ding", "message", "list", "--type", "UNREAD"},
|
||||
wantTool: "list_ding_messages",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout, stderr, err := runCLI(t, env, tc.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("dws %s returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr)
|
||||
}
|
||||
if payload["_mock"] != true {
|
||||
t.Fatalf("dws %s _mock = %#v, want true; payload=%#v", strings.Join(tc.args, " "), payload["_mock"], payload)
|
||||
}
|
||||
if payload["_tool"] != tc.wantTool {
|
||||
t.Fatalf("dws %s _tool = %#v, want %q; payload=%#v", strings.Join(tc.args, " "), payload["_tool"], tc.wantTool, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// publicCommandPaths follows only canonical, user-visible Cobra commands.
|
||||
// Hidden commands and commands marked Deprecated by Cobra are intentionally
|
||||
// excluded; aliases are not separate command nodes. Parents and leaves are both
|
||||
// checked, while positional placeholders in Use are never synthesized.
|
||||
func publicCommandPaths(root *cobra.Command) [][]string {
|
||||
var paths [][]string
|
||||
var walk func(*cobra.Command, []string)
|
||||
walk = func(cmd *cobra.Command, path []string) {
|
||||
if cmd != root && (cmd.Hidden || cmd.Deprecated != "") {
|
||||
return
|
||||
}
|
||||
|
||||
paths = append(paths, append([]string(nil), path...))
|
||||
for _, child := range cmd.Commands() {
|
||||
walk(child, append(path, child.Name()))
|
||||
}
|
||||
}
|
||||
walk(root, nil)
|
||||
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
return strings.Join(paths[i], " ") < strings.Join(paths[j], " ")
|
||||
})
|
||||
return paths
|
||||
}
|
||||
|
||||
func isolatedCLIEnv(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
controlled := map[string]string{
|
||||
"HOME": root,
|
||||
"USERPROFILE": root,
|
||||
"DWS_CONFIG_DIR": filepath.Join(root, "config"),
|
||||
"DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"),
|
||||
"DWS_DISABLE_KEYCHAIN": "1",
|
||||
"HTTP_PROXY": "http://127.0.0.1:1",
|
||||
"HTTPS_PROXY": "http://127.0.0.1:1",
|
||||
"http_proxy": "http://127.0.0.1:1",
|
||||
"https_proxy": "http://127.0.0.1:1",
|
||||
"NO_PROXY": "127.0.0.1,localhost,::1",
|
||||
"no_proxy": "127.0.0.1,localhost,::1",
|
||||
cliSmokeHelperEnv: "1",
|
||||
"DWS_ALLOW_HTTP_ENDPOINTS": "1",
|
||||
"DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1",
|
||||
}
|
||||
for key, value := range controlled {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
|
||||
env := make([]string, 0, len(controlled)+8)
|
||||
for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
}
|
||||
for key, value := range controlled {
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
sort.Strings(env)
|
||||
return env
|
||||
}
|
||||
|
||||
func runCLI(t *testing.T, env []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...)
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], processArgs...)
|
||||
cmd.Env = env
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String())
|
||||
}
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
Reference in New Issue
Block a user