Compare commits

...
Author SHA1 Message Date
修雨 72cb8f188e ci: trigger code admission on main after bot merges 2026-07-27 11:16:24 +08:00
github-actions[bot] 2f8614aa1f Merge pull request #798 from DingTalk-Real-AI/changelog-v1.0.55-beta.4
chore(release): prepare v1.0.55-beta.4
2026-07-27 10:58:01 +08:00
修雨 0e60d09980 chore(release): prepare v1.0.55-beta.4 2026-07-27 10:26:32 +08:00
github-actions[bot] 4d182dea45 Merge pull request #795 from DingTalk-Real-AI/codex/fix-chat-bots-projection
fix(shortcut): prevent projection data loss
2026-07-27 10:18:14 +08:00
修雨 f56d3263e8 chore: extend changelog entry and align npm propagation test with workflow
- CHANGELOG [Unreleased] entry now covers all three projection fixes
- npm dist-tag propagation test expects 60 attempts, matching release.yml
2026-07-26 21:49:12 +08:00
Dennis 22c94900d7 docs(changelog): note shortcut projection fix 2026-07-26 16:58:10 +08:00
Dennis 0871c5d88c fix(shortcut): preserve bot search and mail thread fields 2026-07-26 16:19:21 +08:00
Dennis 15a15a1c12 fix(shortcut): preserve chat bots projection 2026-07-26 15:48:21 +08:00
修雨 0cc049eaa1 fix(release): handle Gitee API 200 null response for missing releases
Gitee API returns HTTP 200 with null body when a release tag doesn't
exist, unlike GitHub which returns 404. Treat empty release_id as
"no release exists" instead of erroring out.
2026-07-24 18:58:30 +08:00
修雨 dd2d91ae7e feat(ci): add release asset sync to Gitee mirror workflow
Add `sync_release_version` input to mirror-to-gitee.yml for ad-hoc
release asset synchronization that bypasses the release.yml verify
gate when tag metadata cannot be updated.
2026-07-24 18:54:47 +08:00
修雨 98309fa239 fix(ci): increase npm CDN propagation timeout with incremental backoff
npm registry behind CDN can take 1-5 minutes for dist-tag to propagate
to edge nodes. Extend max attempts from 12 to 60 and use incremental
backoff: 5s for first 12 attempts, then 10s.
2026-07-24 18:48:08 +08:00
修雨 b4e92f4e65 chore(release): prepare v1.0.55-beta.3 2026-07-24 18:48:03 +08:00
修雨 b34bbc9aa0 ci: enforce beta and stable release roles (#791) 2026-07-24 18:15:55 +08:00
github-actions[bot] 40444a6f78 chore: update beta formula for v1.0.55-beta.3 [skip ci] 2026-07-24 09:35:06 +00:00
18 changed files with 516 additions and 89 deletions
+44 -1
View File
@@ -14,6 +14,11 @@ on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
inputs:
sync_release_version:
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
required: false
type: string
concurrency:
group: gitee-code-mirror
@@ -23,7 +28,6 @@ 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 }}
@@ -76,3 +80,42 @@ jobs:
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
- name: Download GitHub Release assets
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
echo "📥 Downloading release assets for ${VERSION}"
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist/
- name: Verify release artifacts
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
run: |
set -eu
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
./scripts/release/verify-release-artifacts.sh "$VERSION"
- name: Sync release assets to Gitee
if: ${{ inputs.sync_release_version != '' }}
env:
VERSION: ${{ inputs.sync_release_version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DIST_DIR: ${{ github.workspace }}/dist
run: |
set -eu
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
./scripts/release/sync-to-gitee.sh
+137 -18
View File
@@ -2,9 +2,6 @@ name: Release
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_operation:
@@ -33,10 +30,6 @@ on:
- patch
- minor
- major
release_confirmation:
description: "For publish, type PUBLISH beta or PUBLISH stable"
required: false
type: string
repair_npm_version:
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
@@ -122,7 +115,6 @@ jobs:
RELEASE_OPERATION: ${{ inputs.release_operation }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_BUMP: ${{ inputs.release_bump }}
RELEASE_CONFIRMATION: ${{ inputs.release_confirmation }}
run: |
set -eu
release_flow=0
@@ -157,16 +149,8 @@ jobs:
exit 1
esac
if test "$RELEASE_OPERATION" = plan; then
test -z "$RELEASE_CONFIRMATION" || {
echo "release_confirmation must be empty for a plan" >&2
exit 1
}
echo "mode=plan_release" >> "$GITHUB_OUTPUT"
elif test "$RELEASE_OPERATION" = publish; then
test "$RELEASE_CONFIRMATION" = "PUBLISH $RELEASE_CHANNEL" || {
echo "release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL" >&2
exit 1
}
echo "mode=create_release" >> "$GITHUB_OUTPUT"
else
echo "release_operation must be none, plan, or publish" >&2
@@ -219,6 +203,53 @@ jobs:
echo "mode=recover_release" >> "$GITHUB_OUTPUT"
fi
- name: Authorize repository release requester
if: ${{ steps.mode.outputs.mode == 'plan_release' || steps.mode.outputs.mode == 'create_release' }}
uses: actions/github-script@v7
env:
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
REQUESTED_BY: ${{ github.actor }}
TRIGGERED_BY: ${{ github.triggering_actor }}
with:
script: |
const expectedRepository = process.env.EXPECTED_REPOSITORY;
if (context.payload.repository.full_name !== expectedRepository) {
core.setFailed(`release requests are restricted to ${expectedRepository}`);
return;
}
const actors = new Set([
process.env.REQUESTED_BY,
process.env.TRIGGERED_BY,
]);
for (const username of actors) {
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username || "")) {
core.setFailed("release request contains an invalid GitHub actor");
return;
}
let permission;
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username,
});
permission = response.data.permission;
} catch (error) {
if (error.status === 404) {
core.setFailed(`${username} is not an authorized repository collaborator`);
return;
}
throw error;
}
// GitHub maps maintain to the legacy write permission here.
if (!["write", "admin"].includes(permission)) {
core.setFailed(
`${username} needs repository write, maintain, or admin access to release`,
);
return;
}
}
governance-preflight:
name: Release governance preflight
needs: dispatch-contract
@@ -1836,7 +1867,7 @@ jobs:
set -eu
. ./scripts/release/release-lib.sh
attempt=1
max_attempts=12
max_attempts=60
while :; do
delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG" \
--registry=https://registry.npmjs.org --prefer-online)"
@@ -1854,7 +1885,12 @@ jobs:
exit 1
fi
echo "npm $NPM_TAG still reports older v$delivered; waiting for registry propagation ($attempt/$max_attempts)."
sleep 5
# incremental backoff: 5s for first 12 attempts (1 min), then 10s
if [ "$attempt" -lt 12 ]; then
sleep 5
else
sleep 10
fi
attempt=$((attempt + 1))
done
@@ -2841,15 +2877,98 @@ jobs:
name: Seal cloud release tag
needs: [dispatch-contract, governance-preflight, release-plan]
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }}
environment:
name: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: write
outputs:
release_version: ${{ steps.seal.outputs.release_version }}
release_commit: ${{ steps.seal.outputs.release_commit }}
release_tag_object: ${{ steps.seal.outputs.release_tag_object }}
steps:
- name: Verify release environment governance before sealing
uses: actions/github-script@v7
env:
RELEASE_ENVIRONMENT: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}
with:
script: |
const { owner, repo } = context.repo;
const environmentName = process.env.RELEASE_ENVIRONMENT;
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
if (
context.payload.repository.full_name !== expectedRepository ||
!["release-beta", "release-stable"].includes(environmentName)
) {
core.setFailed("release environment validation is not bound to an official channel");
return;
}
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: environmentName },
);
const environment = response.data;
const branchPolicy = environment.deployment_branch_policy;
const protectionRules = environment.protection_rules || [];
const reviewerRules = protectionRules.filter(
(rule) => rule.type === "required_reviewers",
);
const waitRules = protectionRules.filter(
(rule) => rule.type === "wait_timer",
);
if (
environment.name !== environmentName ||
environment.can_admins_bypass !== false ||
branchPolicy?.protected_branches !== true ||
branchPolicy?.custom_branch_policies !== false ||
waitRules.length !== 0
) {
core.setFailed(`${environmentName} does not match the required release policy`);
return;
}
if (environmentName === "release-beta") {
if (reviewerRules.length !== 0) {
core.setFailed("release-beta must not require a manual reviewer");
}
return;
}
if (
reviewerRules.length !== 1 ||
reviewerRules[0].prevent_self_review !== true ||
!Array.isArray(reviewerRules[0].reviewers) ||
reviewerRules[0].reviewers.length === 0
) {
core.setFailed(
"release-stable requires a non-self reviewing administrator",
);
return;
}
for (const reviewer of reviewerRules[0].reviewers) {
if (
reviewer.type !== "User" ||
!reviewer.reviewer?.login
) {
core.setFailed("release-stable reviewers must be repository administrators");
return;
}
const permission = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: reviewer.reviewer.login,
});
if (permission.data.permission !== "admin") {
core.setFailed(
`${reviewer.reviewer.login} is no longer a repository administrator`,
);
return;
}
}
- name: Create one immutable annotated release tag
id: seal
uses: actions/github-script@v7
+16 -13
View File
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.0.55-beta.4] - 2026-07-27
This beta validates the shortcut projection fixes for group bots, bot search,
and mail threads, together with hardened release delivery to Gitee and npm on
top of the `v1.0.55-beta.3` baseline.
### Fixed
- **Shortcut projection fixes** (#795) — `chat +chat-bots` no longer projects a non-empty `list_group_bots` response to an empty list, `+bot-find` recognizes the `search_bots` response shape (`result.bots` entries with `botOpenDingTalkId`), and mail thread listings keep `lastUpdated` when the backend returns `lastModifiedDateTime`.
### Changed
- **Hardened release delivery** — the Gitee mirror workflow can synchronize a specific release's assets on demand, release lookup tolerates Gitee's HTTP 200 null-body response for missing releases, npm dist-tag verification waits through slow registry CDN propagation with incremental backoff, and beta/stable release operations are role-enforced (#791).
## [1.0.55-beta.3] - 2026-07-24
This beta validates the HR Brain command surface, smoother guarded release
@@ -22,19 +38,6 @@ automation, and deterministic Markdown test coverage on top of the
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
## [1.0.55-beta.2] - 2026-07-23
This beta validates Wukong capability parity across Chat, Contacts, documents,
Drive, Markdown, and Todos, together with faster guarded releases and legacy
authentication migration.
### Added
- **Chat editing and group management** — adds `chat message edit`, conversation-category lookups through `chat category list-by-conv` and `chat category batch-info`, and the confirmed, irreversible `chat group upgrade-to-external` flow.
- **Contact maintenance commands** — adds `contact user update`, `contact user update-self`, `contact dept create`, `contact dept update`, and `contact account update`, all with guarded write behavior.
- **Markdown file workflows** — adds the `markdown` product with `fetch`, `create`, `overwrite`, and `patch` commands for Drive-native `.md` files, including explicit routing, dry-run previews, and confirmation for destructive writes.
- **Todo labels** — adds `todo tag add`, `delete`, `update`, `list`, and `create`.
### Changed
- **Faster guarded releases** — trusts an independently revalidated, exact `CHANGELOG.md`-only successor of an already admitted `main` commit, runs cloud planning alongside governance, and executes sealed-release automation, compatibility, and multi-profile validation in parallel with artifact compilation. Normal cloud publication no longer requires an unshareable local packaging preflight.
+7
View File
@@ -91,3 +91,10 @@ coverage is additionally selected for platform-sensitive code.
contexts are current and green, auto-merge completes the PR. If `main`
advances first, strict status checks revalidate the branch; no separate
routine merge request is needed.
Contributors without repository write access stop at the PR flow. Explicitly
authorized collaborators with `write`, `maintain`, or `admin` access can use
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
to publish beta releases without manual approval. The same internal roles may
start a stable release, but a different repository administrator must approve
the `release-stable` Environment deployment before publication continues.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55-beta.2"
version "1.0.55-beta.3"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-darwin-arm64.tar.gz"
sha256 "fd23353c473419ce8cfaf316e7afff757cc4606291f4ad41a62c6f5625f45c8f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.3/dws-darwin-arm64.tar.gz"
sha256 "b833c5a1799b07c7c60a1f1c345b59422a4cda45d07fd6812ae0c29103b61777"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-darwin-amd64.tar.gz"
sha256 "0b429950a6449736338b504386d8009a4f2cdb0668f64f72a61cd503a25df193"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.3/dws-darwin-amd64.tar.gz"
sha256 "e5d49b6466c00d75de2589f3aa18e051c3f705621c05dba44f613a906dd8f198"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-linux-arm64.tar.gz"
sha256 "c213a22d6187a8f68596b9f25d6277f5fd58cea5120f5b7caeef011fb41f1104"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.3/dws-linux-arm64.tar.gz"
sha256 "bad99072427d4c1cf02028465db2b84bff61fabce95e3a04c56511575b02f2b7"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-linux-amd64.tar.gz"
sha256 "4e041ba7b1d8038fa34deba3a3f78af8377f4361131d8bfdb1f1913e5f9454dc"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.3/dws-linux-amd64.tar.gz"
sha256 "564f0913d7e753d4de0edf6724d4b0e7541fe007fb573ed21dc819be88604534"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.2/dws-skills.zip"
sha256 "1cec445c73b9ff569ca002b6548f4ee8383712447bc4cebd7701c6065774524a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.3/dws-skills.zip"
sha256 "48283ee7d8c4f9a7f57a5a42f2aabe4c5fda89ee78cb750889e42347a2a671f9"
end
def install
+2 -2
View File
@@ -39,8 +39,8 @@ help:
@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 release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
build:
+24 -19
View File
@@ -2,16 +2,23 @@
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
## 推荐入口:GitHub 云端发布
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
基于当时最新的 `main` 发起发布:
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -64,7 +71,9 @@ dws-release
dws-release config --remote origin
```
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
## 发布模型
@@ -91,13 +100,7 @@ dws-release v1.2.3-beta.1
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
```bash
dws-release v1.2.3-beta.1 --publish
```
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -107,14 +110,13 @@ beta 验证通过后,运行正式版入口:
dws-release v1.2.3 --from-beta v1.2.3-beta.1
```
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
```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 会再次读取和验证。
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
## CHANGELOG 契约
@@ -133,13 +135,13 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `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`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 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。
@@ -182,15 +184,18 @@ Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
+3 -3
View File
@@ -82,7 +82,7 @@ func botSearchProject(data map[string]any) []map[string]any {
// botResolveList locates the list payload inside the response, tolerating a bare
// top-level array or nesting under result/data/list/items containers.
func botResolveList(data map[string]any) []any {
for _, key := range []string{"result", "data", "list", "items", "robots", "records"} {
for _, key := range []string{"result", "data", "list", "items", "robots", "bots", "records"} {
v, ok := data[key]
if !ok {
continue
@@ -91,7 +91,7 @@ func botResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "robots", "records", "result", "data"} {
for _, ik := range []string{"list", "items", "robots", "bots", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
@@ -158,7 +158,7 @@ func botFindProject(data map[string]any) []map[string]any {
continue
}
row := map[string]any{}
if v, ok := botFirst(m, "openDingTalkId", "open_ding_talk_id", "openId"); ok {
if v, ok := botFirst(m, "botOpenDingTalkId", "openDingTalkId", "open_ding_talk_id", "openId"); ok {
row["openDingTalkId"] = v
}
if v, ok := botFirst(m, "name", "robotName", "botName"); ok {
@@ -0,0 +1,56 @@
// 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 chat
import (
"encoding/json"
"testing"
)
// TestBotFindProjectBotsShape guards the exact search_bots response contract:
// entries are nested under result.bots and expose botOpenDingTalkId. Losing
// either key makes +bot-find silently report no usable bots.
func TestBotFindProjectBotsShape(t *testing.T) {
const raw = `{"result":{"bots":[
{"botOpenDingTalkId":"bot-open-id-1","name":"Bot One"},
{"botOpenDingTalkId":"bot-open-id-2","name":"Bot Two"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := botFindProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.bots has 2 entries, projection returned %d (%v)", len(got), got)
}
if got[0]["openDingTalkId"] != "bot-open-id-1" || got[0]["name"] != "Bot One" {
t.Fatalf("bot fields missing: %v", got[0])
}
}
// TestBotSearchProjectRobotsShape ensures the shared resolver still accepts the
// search_my_robots response after adding the search_bots container.
func TestBotSearchProjectRobotsShape(t *testing.T) {
const raw = `{"result":{"robots":[{"robotCode":"robot-1","robotName":"Robot One"}]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := botSearchProject(data)
if len(got) != 1 || got[0]["robotCode"] != "robot-1" || got[0]["robotName"] != "Robot One" {
t.Fatalf("robots shape regressed: %v", got)
}
}
@@ -0,0 +1,44 @@
// 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 chat
import (
"encoding/json"
"testing"
)
// TestChatBotsProjectBotsShape guards against projection-data-loss:
// list_group_bots nests its entries under result.bots; the shared resolver
// must probe "bots" or +chat-bots silently reports zero robots.
func TestChatBotsProjectBotsShape(t *testing.T) {
const raw = `{"result":{"bots":[
{"openBotId":"bot-1","name":"AI小钉"},
{"openBotId":"bot-2","name":"本地opencode"},
{"openBotId":"bot-3","name":"hermes助手"},
{"openBotId":"bot-4","name":"claudecode 助手"},
{"openBotId":"bot-5","name":"ClawdBot"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := chatBotsProject(data)
if len(got) != 5 {
t.Fatalf("lower/upper mismatch: result.bots has 5 entries, projection returned %d (%v)", len(got), got)
}
if got[0]["openBotId"] != "bot-1" || got[0]["name"] != "AI小钉" {
t.Fatalf("bot fields missing: %v", got[0])
}
}
+7 -6
View File
@@ -413,12 +413,13 @@ func chatGroupResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// "roles" is probed too: chatRoleListProject reuses this resolver and
// list_custom_group_roles nests its list under result.roles — without it
// +chat-role-list silently returns empty despite the group having roles.
// "bots" and "roles" are probed too: chatBotsProject and
// chatRoleListProject reuse this resolver, while their backing tools nest
// lists under result.bots and result.roles. Without those keys the
// shortcuts silently return empty despite the group having bots or roles.
// Group listings key on groups/conversations, which are probed first, so
// adding roles is harmless to them.
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} {
// adding these sibling containers is harmless to them.
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "bots", "roles"} {
v, ok := data[key]
if !ok {
continue
@@ -427,7 +428,7 @@ func chatGroupResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} {
for _, ik := range []string{"list", "items", "groups", "conversations", "bots", "roles", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
+1 -1
View File
@@ -106,7 +106,7 @@ func threadListProject(data map[string]any) []map[string]any {
if v, ok := threadListFirst(m, "subject", "title", "topic"); ok {
row["subject"] = v
}
if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "updateTime", "modifiedTime", "sentDateTime"); ok {
if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok {
row["lastUpdated"] = v
}
if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok {
@@ -0,0 +1,50 @@
// 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 mail
import (
"encoding/json"
"testing"
)
// TestThreadListProjectConversationsShape guards the exact
// list_mailbox_threads response contract, including lastModifiedDateTime.
func TestThreadListProjectConversationsShape(t *testing.T) {
const raw = `{"result":{"conversations":[{
"id":"thread-1",
"subject":"Projection contract",
"lastModifiedDateTime":"2026-07-26T10:00:00Z",
"isRead":false
}]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := threadListProject(data)
if len(got) != 1 {
t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got)
}
want := map[string]any{
"conversationId": "thread-1",
"subject": "Projection contract",
"lastUpdated": "2026-07-26T10:00:00Z",
"isRead": false,
}
for key, value := range want {
if got[0][key] != value {
t.Fatalf("%s mismatch: want %v, got %v (row=%v)", key, value, got[0][key], got[0])
}
}
}
+18 -4
View File
@@ -4,6 +4,7 @@ set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CLOUD_RELEASE_URL="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml"
VERSION=""
FROM_BETA=""
@@ -20,21 +21,20 @@ usage: dws-release [version] [options]
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.
full guarded preflight. Official publication runs from GitHub Actions so beta
and stable use the repository's release authorization policy.
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
--publish Retired for official releases; use Actions -> Release
-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
dws-release recover v1.2.3-beta.1
EOF
}
@@ -127,6 +127,18 @@ require_remote() {
}
}
reject_official_publish() {
normalized_repository="$(
printf '%s\n' "$actual_repository" | tr '[:upper:]' '[:lower:]'
)"
if [ "$MODE" = "publish" ] &&
[ "$normalized_repository" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then
printf '%s\n' 'Direct local publication is disabled for official releases.' >&2
printf 'Use Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL" >&2
exit 1
fi
}
sync_main_if_safe() {
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release worktree must be clean before synchronization' >&2
@@ -322,6 +334,7 @@ MAIN_SYNCED=0
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" = "main" ]; then
require_remote
reject_official_publish
sync_main_if_safe
MAIN_SYNCED=1
fi
@@ -351,6 +364,7 @@ release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
if [ "$MAIN_SYNCED" -ne 1 ]; then
require_remote
reject_official_publish
sync_main_if_safe
fi
+19 -4
View File
@@ -4,6 +4,7 @@ set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CLOUD_RELEASE_URL="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml"
CHANNEL="${1:-}"
VERSION="${2:-}"
@@ -19,12 +20,12 @@ usage() {
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.
The default is validation only. Official publication is cloud-only.
Options:
--remote <name> Required tag destination
--from-beta <tag> Required for stable releases
--publish Create and push the annotated release tag
--publish Create a tag only for a non-official test or fork remote
--yes Skip the interactive version confirmation
EOF
}
@@ -73,10 +74,19 @@ repository_identity() {
fetch_identity="$(repository_identity "$fetch_url")"
push_identity="$(repository_identity "$push_url")"
normalized_push_identity="$(
printf '%s\n' "$push_identity" | tr '[:upper:]' '[:lower:]'
)"
[ "$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
}
if [ "$PUBLISH" -eq 1 ] &&
[ "$normalized_push_identity" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then
printf '%s\n' 'Direct tag publication to the official repository is disabled.' >&2
printf 'Use Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL" >&2
exit 1
fi
printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url"
github_repository_from_url() {
@@ -430,13 +440,18 @@ settled_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | t
exit 1
}
if [ "$ran_expensive_gates" -eq 1 ]; then
if [ "$ran_expensive_gates" -eq 1 ] &&
[ "$normalized_push_identity" != "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then
write_preflight_proof "$proof_path"
fi
if [ "$PUBLISH" -ne 1 ]; then
printf '\nPreflight passed. No tag was created.\n'
printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n'
if [ "$normalized_push_identity" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then
printf 'Publish from Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL"
else
printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n'
fi
exit 0
fi
+1 -2
View File
@@ -165,7 +165,6 @@ rel_json="$(<"$release_lookup_body")"
case "$release_status" in
200)
release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)"
[ -n "$release_id" ] || err "Gitee release lookup returned HTTP 200 without a valid release id"
;;
404)
release_id=""
@@ -175,7 +174,7 @@ case "$release_status" in
;;
esac
if [ "$release_status" = "404" ]; then
if [ -z "$release_id" ]; then
deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during release lookup"
echo " No Gitee release for ${VERSION} yet — creating it."
create_max_time="$(bounded_max_time "$GITEE_RELEASE_CREATE_MAX_TIME")" \
+32 -5
View File
@@ -909,11 +909,15 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
"- publish",
"release_channel:",
"release_bump:",
"release_confirmation:",
`release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery`,
`echo "mode=plan_release"`,
`echo "mode=create_release"`,
`release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL`,
"name: Authorize repository release requester",
`if: ${{ steps.mode.outputs.mode == 'plan_release' || steps.mode.outputs.mode == 'create_release' }}`,
`REQUESTED_BY: ${{ github.actor }}`,
`TRIGGERED_BY: ${{ github.triggering_actor }}`,
"github.rest.repos.getCollaboratorPermissionLevel",
`!["write", "admin"].includes(permission)`,
`needs.dispatch-contract.outputs.mode == 'plan_release'`,
`needs.governance-preflight.result == 'success'`,
"actions: read",
@@ -950,10 +954,28 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
if strings.Contains(plan, "refs/tags/v refs/tags/withdrawn/v") {
t.Error("cloud release planning must use wildcard ref patterns that match the seal API prefixes")
}
workflowHeader := workflow[:strings.Index(workflow, "\njobs:\n")]
if strings.Contains(workflowHeader, "\n push:\n") {
t.Error("new releases must not bypass cloud authorization through a tag-push trigger")
}
if regexp.MustCompile(`(?m)^ release_confirmation:$`).MatchString(workflowHeader) ||
strings.Contains(workflow, "release_confirmation must be exactly: PUBLISH") {
t.Error("cloud publication must not retain the redundant typed confirmation gate")
}
for _, required := range []string{
"name: Seal cloud release tag",
`name: ${{ inputs.release_channel == 'stable' && 'release-stable' || 'release-beta' }}`,
"actions: read",
"contents: write",
"name: Verify release environment governance before sealing",
`"GET /repos/{owner}/{repo}/environments/{environment_name}"`,
`environment.can_admins_bypass !== false`,
`branchPolicy?.protected_branches !== true`,
`branchPolicy?.custom_branch_policies !== false`,
`reviewerRules.length !== 0`,
`reviewerRules[0].prevent_self_review !== true`,
`permission.data.permission !== "admin"`,
"name: Create one immutable annotated release tag",
`branch.data.commit.sha !== commit`,
`actualFingerprint !== expectedFingerprint`,
@@ -972,6 +994,11 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
}
}
createRef := strings.Index(seal, "github.rest.git.createRef")
environmentPolicy := strings.Index(seal, "Verify release environment governance before sealing")
createTagStep := strings.Index(seal, "Create one immutable annotated release tag")
if environmentPolicy == -1 || createTagStep == -1 || environmentPolicy > createTagStep {
t.Fatal("release Environment policy must be verified before the first tag write")
}
visibilityRead := strings.LastIndex(seal, "github.rest.git.getRef")
if createRef == -1 || visibilityRead == -1 || visibilityRead < createRef {
t.Fatal("cloud release seal must confirm tag-ref visibility after creating the ref")
@@ -2169,9 +2196,9 @@ func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) {
{
name: "stale beta never converges",
sequence: "1.0.53-beta.6\n",
wantCalls: 12,
wantSleeps: 11,
wantOutput: "still reports older v1.0.53-beta.6 after 12 attempts",
wantCalls: 60,
wantSleeps: 59,
wantOutput: "still reports older v1.0.53-beta.6 after 60 attempts",
},
{
name: "permanent registry error fails immediately",
+44
View File
@@ -313,3 +313,47 @@ func TestReleaseCommandRejectsNonInteractivePublishWithoutYes(t *testing.T) {
t.Fatalf("rejected non-interactive publish created a tag: %s", got)
}
}
func TestReleaseCommandRejectsDirectOfficialPublication(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")
mustRun(t, r.root, "git", "remote", "set-url", "origin",
"https://github.com/dingtalk-real-ai/DingTalk-Workspace-CLI.git")
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 || !strings.Contains(output, "Direct tag publication to the official repository is disabled") {
t.Fatalf("direct official publication was not rejected: err=%v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "actions/workflows/release.yml") {
t.Fatalf("direct official publication did not point to the cloud release page:\n%s", output)
}
if got := mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1"); got != "" {
t.Fatalf("rejected official publication created a tag: %s", got)
}
}
func TestDWSReleaseEntryRejectsDirectOfficialPublication(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")
mustRun(t, f.repo.root, "git", "remote", "set-url", "origin",
"https://github.com/DINGTALK-REAL-AI/dingtalk-workspace-cli.git")
if output, err := f.run(t, nil, "config", "--remote", "origin"); err != nil {
t.Fatalf("configure official remote error = %v\noutput:\n%s", err, output)
}
output, err := f.run(t, nil, "v1.0.1-beta.1", "--publish")
if err == nil || !strings.Contains(output, "Direct local publication is disabled for official releases") {
t.Fatalf("official entry publication was not rejected: err=%v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "actions/workflows/release.yml") {
t.Fatalf("official entry did not point to the cloud release page:\n%s", output)
}
if got := f.callLog(t); got != "" {
t.Fatalf("official entry delegated direct publication: %q", got)
}
}