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:
+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/...
|
||||
|
||||
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: |
|
||||
|
||||
@@ -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 }}
|
||||
+737
-80
@@ -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 }}
|
||||
Reference in New Issue
Block a user