feat(release): add guarded prerelease and stable pipeline (#54)

* feat(release): add guarded prerelease and stable pipeline

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

(cherry picked from commit f7fa7b78f325f3574f0487862fc3e65bba5cdc96)
This commit is contained in:
修雨
2026-07-13 15:34:21 +08:00
committed by 修雨
parent bfd48b6a71
commit e3cedc8f67
32 changed files with 4198 additions and 560 deletions
+160 -28
View File
@@ -7,8 +7,7 @@ on:
pull_request:
permissions:
contents: write
pull-requests: write
contents: read
jobs:
lint:
@@ -59,7 +58,17 @@ jobs:
run: make build
- name: Test with Race Detection
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
run: |
go test -v -race -count=1 -timeout=5m \
./cmd/... \
./internal/... \
./pkg/... \
./test/unit/...
go test -v -count=1 -timeout=5m \
./test/cli/...
- name: Test engineering scripts
run: go test -v -count=1 -timeout=5m ./test/scripts/...
test-darwin:
name: Test (macOS auth/keychain)
@@ -133,30 +142,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
@@ -195,11 +180,158 @@ jobs:
- name: Run edition contract tests
run: go test -v -count=1 ./pkg/editiontest/...
code-check:
name: Code Check
needs: [lint, test, test-darwin, test-windows, coverage, policy, edition-tests]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify code checks
env:
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
POLICY_RESULT: ${{ needs.policy.result }}
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
run: |
set -eu
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
"Coverage:$COVERAGE_RESULT" \
"Policy Check:$POLICY_RESULT" \
"Edition Contract Tests:$EDITION_TESTS_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
command-compatibility:
name: Command Compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: read
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch official release tags
run: |
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check command compatibility
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$PR_BASE_SHA"
fi
stable_ref="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')"
test -n "$base_ref"
test -n "$stable_ref"
stable_commit="$(git rev-parse "$stable_ref^{commit}")"
./scripts/release/verify-delivered-stable.sh "$stable_ref" "$stable_commit"
./scripts/policy/check-command-compatibility.sh \
--base-ref "$base_ref" \
--stable-ref "$stable_ref"
cli-smoke:
name: CLI Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run CLI smoke tests
run: go test -count=1 -timeout=5m ./test/smoke/...
mock-mcp-smoke:
name: Mock MCP Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run Mock MCP smoke tests
run: go test -count=1 -timeout=5m ./test/mock_mcp/...
ci-gate:
name: CI Gate
needs: [code-check, command-compatibility, cli-smoke, mock-mcp-smoke]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required checks
env:
CODE_CHECK_RESULT: ${{ needs.code-check.result }}
COMMAND_COMPATIBILITY_RESULT: ${{ needs.command-compatibility.result }}
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
run: |
set -eu
failed=0
for check in \
"Code Check:$CODE_CHECK_RESULT" \
"Command Compatibility:$COMMAND_COMPATIBILITY_RESULT" \
"CLI Smoke:$CLI_SMOKE_RESULT" \
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
notify-downstream:
name: Notify Wukong Overlay
needs: [test, policy, edition-tests]
needs: [ci-gate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |
+7 -16
View File
@@ -1,4 +1,5 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
@@ -10,8 +11,6 @@ on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
@@ -23,13 +22,14 @@ concurrency:
jobs:
mirror:
runs-on: ubuntu-latest
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
- name: Checkout main history
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
@@ -41,15 +41,7 @@ jobs:
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
@@ -81,7 +73,6 @@ jobs:
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
-71
View File
@@ -1,71 +0,0 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+737 -80
View File
@@ -7,33 +7,239 @@ on:
workflow_dispatch:
inputs:
repair_npm_version:
description: "Only publish an existing release to npm, e.g. v1.0.48"
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
type: string
permissions:
contents: write
contents: read
# Queue tag releases and npm repairs behind one publication lock. The local
# entrypoint also refuses to tag while a Release run is active.
concurrency:
group: dws-release-publication
queue: max
jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
release-contract:
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
# idempotent (re-runs only upload missing assets).
timeout-minutes: 60
timeout-minutes: 20
permissions:
actions: read
checks: read
contents: read
outputs:
channel: ${{ steps.contract.outputs.channel }}
previous_stable: ${{ steps.contract.outputs.previous_stable }}
previous_stable_commit: ${{ steps.contract.outputs.previous_stable_commit }}
from_beta: ${{ steps.contract.outputs.from_beta }}
from_beta_commit: ${{ steps.contract.outputs.from_beta_commit }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Validate release contract
id: contract
run: |
set -eu
. ./scripts/release/release-lib.sh
channel="$(release_channel_for_version "$GITHUB_REF_NAME")"
./scripts/release/release-contract.sh \
--channel "$channel" \
--version "$GITHUB_REF_NAME" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md" \
--metadata-output "$GITHUB_OUTPUT"
- name: Verify remote annotated tag authority
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
- name: Require successful CI Gate on the sealed commit
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: context.sha,
check_name: "CI Gate",
filter: "latest",
per_page: 100,
});
const passed = data.check_runs.some(
(run) => run.name === "CI Gate" && run.conclusion === "success",
);
if (!passed) {
core.setFailed(`CI Gate has not succeeded for ${context.sha}`);
}
- name: Require delivered previous stable baseline
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ steps.contract.outputs.previous_stable_commit }}
run: ./scripts/release/verify-delivered-stable.sh "$PREVIOUS_STABLE" "$PREVIOUS_STABLE_COMMIT"
- name: Require immutable releases governance
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/immutable-releases",
{
owner,
repo,
headers: { "X-GitHub-Api-Version": "2026-03-10" },
},
);
if (response.data.enabled !== true) {
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Require successful beta delivery before stable promotion
if: ${{ steps.contract.outputs.channel == 'stable' }}
uses: actions/github-script@v7
env:
FROM_BETA: ${{ steps.contract.outputs.from_beta }}
FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }}
with:
script: |
const { owner, repo } = context.repo;
const expectedAssets = new Set([
"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",
]);
const { data: release } = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: process.env.FROM_BETA,
});
if (release.draft || !release.prerelease || !release.immutable) {
core.setFailed(`${process.env.FROM_BETA} is not a published immutable prerelease`);
return;
}
const assetNames = release.assets.map((asset) => asset.name);
const names = new Set(assetNames);
const missing = [...expectedAssets].filter((name) => !names.has(name));
const extra = assetNames.filter((name) => !expectedAssets.has(name));
if (
missing.length > 0 ||
extra.length > 0 ||
assetNames.length !== expectedAssets.size
) {
core.setFailed(
`beta release asset set differs: missing=${missing.join(",")} extra=${extra.join(",")}`,
);
return;
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: process.env.FROM_BETA,
event: "push",
status: "completed",
per_page: 100,
});
const passed = runs.some(
(run) =>
run.head_sha === process.env.FROM_BETA_COMMIT &&
run.head_branch === process.env.FROM_BETA &&
run.conclusion === "success",
);
if (!passed) {
core.setFailed(`Release workflow did not succeed for ${process.env.FROM_BETA}`);
}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test release automation
run: go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'
- name: Verify module files are tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- name: Verify released command tree
env:
PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }}
run: |
set -eu
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
release:
name: Build immutable GitHub Release
if: ${{ github.event_name == 'push' }}
needs: [release-contract]
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Revalidate contract and render release notes
run: |
./scripts/release/release-contract.sh \
--channel "${{ needs.release-contract.outputs.channel }}" \
--version "$GITHUB_REF_NAME" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
@@ -46,16 +252,17 @@ jobs:
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
echo "a0e0c548b313026abf4f40a6e06554d352c6f4e1add6a87d626c94134a4ee564 /tmp/rcodesign.tar.gz" | sha256sum --check -
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Run GoReleaser
- name: Build release artifacts without publishing
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
version: "v2.16.0"
args: release --clean --skip=publish --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -63,24 +270,161 @@ jobs:
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}
- name: Upload dws-skills.zip to release
- name: Verify final release artifacts
run: ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
- name: Verify npm package before sealing GitHub Release
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME"
- name: Reverify remote tag before sealing GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
- name: Publish or reuse immutable GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
expected_prerelease=false
prerelease_flag=""
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then
expected_prerelease=true
prerelease_flag="--prerelease"
fi
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
is_draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)"
is_prerelease="$(gh release view "$GITHUB_REF_NAME" --json isPrerelease --jq .isPrerelease)"
if [ "$is_prerelease" != "$expected_prerelease" ]; then
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
exit 1
fi
if [ "$is_draft" = "false" ]; then
is_immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \
--jq .immutable
)"
if [ "$is_immutable" != "true" ]; then
echo "Published release is mutable; refusing to reuse its assets." >&2
exit 1
fi
remote_body="$(gh release view "$GITHUB_REF_NAME" --json body --jq .body)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
if [ "$remote_body" != "$local_body" ]; then
echo "Published release notes differ from the sealed CHANGELOG." >&2
exit 1
fi
published_dir="$RUNNER_TEMP/published-release"
mkdir -p "$published_dir"
gh release download "$GITHUB_REF_NAME" \
--dir "$published_dir" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$published_dir" \
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
cp "$asset" dist/
done
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME"
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
exit 0
fi
gh release edit "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--notes-file "$RUNNER_TEMP/release-notes.md"
else
# shellcheck disable=SC2086
gh release create "$GITHUB_REF_NAME" \
--verify-tag \
--draft \
--title "$GITHUB_REF_NAME" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
$prerelease_flag
fi
gh release upload "$GITHUB_REF_NAME" \
dist/dws-*.tar.gz \
dist/dws-windows-*.zip \
dist/dws-skills.zip \
dist/checksums.txt \
--clobber
./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME"
sealed_upload="$RUNNER_TEMP/sealed-upload"
rm -rf "$sealed_upload"
mkdir -p "$sealed_upload"
gh release download "$GITHUB_REF_NAME" \
--dir "$sealed_upload" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
local_asset="dist/$(basename "$remote_asset")"
cmp -s "$local_asset" "$remote_asset" || {
echo "Uploaded GitHub asset differs from local sealed bytes: $(basename "$remote_asset")" >&2
exit 1
}
done
gh release edit "$GITHUB_REF_NAME" --draft=false
- name: Require immutable published GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
set -eu
immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \
--jq .immutable
)"
test "$immutable" = true || {
echo "GitHub Release is not immutable; refusing downstream publication." >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME"
./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
- name: Sync release to China OSS mirror
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-oss.sh
env:
VERSION: ${{ github.ref_name }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Reverify exact immutable npm package
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME"
publish-channels:
name: Publish npm and mirrors
if: ${{ github.event_name == 'push' }}
needs: [release-contract, release]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Check out sealed release source
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch release branch and official tags
run: |
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
- name: Revalidate sealed contract
run: |
./scripts/release/release-contract.sh \
--channel "${{ needs.release-contract.outputs.channel }}" \
--version "$GITHUB_REF_NAME" \
--context ci \
--remote origin \
--branch main
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -88,28 +432,176 @@ jobs:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Publish prerelease to npm beta
# 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
- name: Download and verify immutable GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
release_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \
--jq '[.draft, .immutable] | @tsv'
)"
test "$release_state" = "$(printf 'false\ttrue')" || {
echo "GitHub Release must be public and immutable before channel publication." >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME"
./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA"
mkdir -p dist
gh release download "$GITHUB_REF_NAME" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME"
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME"
- name: Verify immutable npm package without publication credentials
run: |
./scripts/release/verify-package-managers.sh \
--npm-only \
--expected-version "$GITHUB_REF_NAME"
- name: Inspect npm channel state
id: npm-state
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
. ./scripts/release/release-lib.sh
semver="${GITHUB_REF_NAME#v}"
npm_tag=latest
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then npm_tag=beta; fi
current="$(npm view dingtalk-workspace-cli "dist-tags.$npm_tag")"
local_integrity="$(
./scripts/release/pack-npm-package.sh \
"$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \
"$RUNNER_TEMP/dingtalk-workspace-cli.tgz"
)"
version_error="$RUNNER_TEMP/npm-version-error.log"
if existing="$(npm view "dingtalk-workspace-cli@$semver" version 2>"$version_error")"; then
if [ "$existing" != "$semver" ]; then
echo "npm returned unexpected version $existing for $semver" >&2
exit 1
fi
elif grep -Eq 'E404|404 Not Found' "$version_error"; then
existing=""
else
cat "$version_error" >&2
exit 1
fi
if [ -n "$existing" ]; then
remote_integrity="$(npm view "dingtalk-workspace-cli@$semver" dist.integrity)"
if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then
echo "npm $semver integrity differs from the sealed package." >&2
echo "local: $local_integrity" >&2
echo "remote: $remote_integrity" >&2
exit 1
fi
advance_channel=false
if [ "$current" = "$semver" ]; then
:
elif [ -n "$current" ] && release_version_is_greater "v$current" "$GITHUB_REF_NAME"; then
echo "npm $npm_tag already points to newer v$current; leaving it unchanged."
elif [ -z "$current" ] || release_version_is_greater "$GITHUB_REF_NAME" "v$current"; then
advance_channel=true
else
echo "Cannot reconcile npm $npm_tag=$current with $GITHUB_REF_NAME" >&2
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"
echo "advance_channel=$advance_channel" >> "$GITHUB_OUTPUT"
echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT"
echo "semver=$semver" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$current" ] && ! release_version_is_greater "$GITHUB_REF_NAME" "v$current"; then
echo "$GITHUB_REF_NAME would move npm $npm_tag backwards from v$current" >&2
exit 1
fi
echo "published=false" >> "$GITHUB_OUTPUT"
echo "advance_channel=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT"
echo "semver=$semver" >> "$GITHUB_OUTPUT"
- name: Publish missing version to npm channel
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.published != 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ -n "$current" ] && [ "$current" != "$SEMVER" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then
echo "Refusing to move npm $NPM_TAG backwards from v$current to v$SEMVER" >&2
exit 1
fi
npm publish "$RUNNER_TEMP/dingtalk-workspace-cli.tgz" \
--access public \
--tag "$NPM_TAG" \
--ignore-scripts \
--registry=https://registry.npmjs.org
- name: Advance npm channel for an existing exact version
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.advance_channel == 'true' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ "$current" = "$SEMVER" ]; then exit 0; fi
if [ -n "$current" ] && release_version_is_greater "v$current" "v$SEMVER"; then
echo "npm $NPM_TAG advanced to newer v$current; leaving it unchanged."
exit 0
fi
if [ -n "$current" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then
echo "Refusing unsafe npm $NPM_TAG transition from v$current to v$SEMVER" >&2
exit 1
fi
npm dist-tag add "dingtalk-workspace-cli@$SEMVER" "$NPM_TAG" \
--registry=https://registry.npmjs.org
- name: Verify npm channel delivery
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
env:
NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }}
SEMVER: ${{ steps.npm-state.outputs.semver }}
run: |
set -eu
. ./scripts/release/release-lib.sh
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")"
if [ "$delivered" = "$SEMVER" ]; then exit 0; fi
release_version_is_greater "v$delivered" "v$SEMVER" || {
echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2
exit 1
}
echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered."
- name: Sync release artifacts to China OSS mirror
run: ./scripts/release/sync-to-oss.sh
env:
VERSION: ${{ github.ref_name }}
DWS_RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
DWS_REQUIRE_OSS: ${{ github.repository_owner == 'DingTalk-Real-AI' && '1' || '0' }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
timeout-minutes: 20
run: ./scripts/release/sync-to-gitee.sh
@@ -118,45 +610,152 @@ jobs:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_REQUIRE_GITEE: "1"
repair-npm:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
permissions:
actions: read
contents: read
steps:
- name: Check out repository
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
path: tooling
- name: Download GitHub release assets
- name: Validate repair version
id: version
working-directory: tooling
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.repair_npm_version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
. ./scripts/release/release-lib.sh
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
echo "Invalid release version: $VERSION" >&2
exit 2
fi
echo "semver=${VERSION#v}" >> "$GITHUB_OUTPUT"
- name: Stage npm package
run: |
set -eu
version="${{ inputs.repair_npm_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: Verify immutable release authority
id: authority
uses: actions/github-script@v7
env:
VERSION: ${{ inputs.repair_npm_version }}
with:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
const commitSha = tag.data.object.sha;
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commitSha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} is not contained in ${defaultBranch}`);
return;
}
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: version,
});
if (
release.data.tag_name !== version ||
release.data.draft ||
!release.data.immutable
) {
core.setFailed(`${version} is not a public immutable GitHub Release`);
return;
}
const expectedAssets = new Set([
"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",
]);
const assetNames = release.data.assets.map((asset) => asset.name);
if (
assetNames.length !== expectedAssets.size ||
assetNames.some((name) => !expectedAssets.has(name)) ||
new Set(assetNames).size !== expectedAssets.size
) {
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: version,
event: "push",
status: "completed",
per_page: 100,
});
let passed = false;
for (const run of runs) {
if (run.head_sha !== commitSha || run.head_branch !== version) continue;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "all",
per_page: 100,
});
if (
jobs.some(
(job) =>
job.name === "Build immutable GitHub Release" &&
job.conclusion === "success",
)
) {
passed = true;
break;
}
}
if (!passed) {
core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`);
return;
}
core.setOutput("commit_sha", commitSha);
- name: Check out target release source
uses: actions/checkout@v4
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -164,16 +763,74 @@ jobs:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
- name: Download and verify GitHub release assets
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
- name: Stage and verify target npm package
env:
VERSION: ${{ inputs.repair_npm_version }}
run: |
set -eu
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE/release-source" \
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/stage-npm-package.sh" "$VERSION"
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-package-managers.sh" \
--npm-only \
--expected-version "$VERSION" \
--skip-skill-targets
- name: Inspect npm version
id: npm
env:
SEMVER: ${{ steps.version.outputs.semver }}
run: |
set -eu
local_integrity="$(
"$GITHUB_WORKSPACE/tooling/scripts/release/pack-npm-package.sh" \
"$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \
"$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz"
)"
version_error="$RUNNER_TEMP/npm-version-error.log"
if existing="$(npm view "dingtalk-workspace-cli@$SEMVER" version 2>"$version_error")"; then
if [ "$existing" != "$SEMVER" ]; then
echo "npm returned unexpected version $existing for $SEMVER" >&2
exit 1
fi
elif grep -Eq 'E404|404 Not Found' "$version_error"; then
existing=""
else
cat "$version_error" >&2
exit 1
fi
if [ "$existing" = "$SEMVER" ]; then
remote_integrity="$(npm view "dingtalk-workspace-cli@$SEMVER" dist.integrity)"
if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then
echo "npm $SEMVER integrity differs from the immutable release package." >&2
echo "local: $local_integrity" >&2
echo "remote: $remote_integrity" >&2
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
- name: Backfill exact version to npm
if: ${{ steps.npm.outputs.published != 'true' }}
run: npm publish "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" --access public --tag backfill --ignore-scripts --registry=https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -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 }}