Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72cb8f188e | ||
|
|
2f8614aa1f | ||
|
|
0e60d09980 | ||
|
|
4d182dea45 | ||
|
|
f56d3263e8 | ||
|
|
22c94900d7 | ||
|
|
0871c5d88c | ||
|
|
15a15a1c12 | ||
|
|
0cc049eaa1 | ||
|
|
dd2d91ae7e | ||
|
|
98309fa239 | ||
|
|
b4e92f4e65 | ||
|
|
b34bbc9aa0 | ||
|
|
40444a6f78 |
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 凭证。
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")" \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user