Compare commits

..
Author SHA1 Message Date
修雨 6b50cc41c5 ci(release): add dispatch repair-gitee job to mirror an existing release
The push-triggered mirror-gitee-release job consumes the same run's
finalized-release-dist artifact, so it cannot mirror a tag that was
already published — including one delivered by a recovery dispatch such
as v1.0.53-beta.3. Add a workflow_dispatch repair-gitee job (input
mirror_gitee_version) that re-derives the asset set from the immutable
GitHub Release, verifies it byte-for-byte via checksums, and runs
sync-to-gitee.sh. Guarded to the official repo + default branch and
gated by the existing Gitee secrets.
2026-07-19 21:51:35 +08:00
修雨 11e50662f9 Merge pull request #683 from DingTalk-Real-AI/codex/fix-event-bus-shutdown-race
fix(event): serialize bus shutdown with accept loop
2026-07-19 21:39:53 +08:00
修雨 29abdb6e79 fix(event): serialize bus shutdown with accept loop
Wait for the accept loop to stop before waiting for connection handlers, and track accepted connections before publishing handlers. This removes the WaitGroup Add/Wait race caught by PR #667 CI and follows up the event bus introduced in #589.
2026-07-19 21:21:16 +08:00
修雨 6196e2565e Merge pull request #678 from DingTalk-Real-AI/fix/release-draft-asset-verify
fix(release): bind draft publication to release ID
2026-07-19 19:52:09 +08:00
4 changed files with 310 additions and 22 deletions
+207 -8
View File
@@ -1,5 +1,5 @@
name: Release
run-name: ${{ 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.workflow }}
run-name: ${{ 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.workflow }}
on:
push:
@@ -11,6 +11,10 @@ on:
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
type: string
repair_gitee_version:
description: "Mirror an existing public immutable release's assets to Gitee"
required: false
type: string
governance_preflight_commit:
description: "Verify release governance for this exact default-branch commit without publishing"
required: false
@@ -51,9 +55,9 @@ on:
permissions:
contents: read
# Queue governance checks, tag releases, protected recoveries, and npm repairs
# behind one publication lock. The local entrypoint also refuses to tag while
# a Release run is active.
# Queue governance checks, tag releases, protected recoveries, and channel
# repairs behind one publication lock. The local entrypoint also refuses to tag
# while a Release run is active.
concurrency:
group: dws-release-publication
queue: max
@@ -70,6 +74,7 @@ jobs:
id: mode
env:
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}
GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }}
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
RECOVER_VERSION: ${{ inputs.recover_release_version }}
@@ -81,22 +86,26 @@ jobs:
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
run: |
set -eu
repair=0
npm_repair=0
gitee_repair=0
governance=0
recovery=0
test -z "$REPAIR_NPM_VERSION" || repair=1
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -n "$RECOVER_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((repair + governance + recovery)) -eq 1 || {
test $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
if test "$repair" -eq 1; then
if test "$npm_repair" -eq 1; then
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
elif test "$gitee_repair" -eq 1; then
echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"
elif test "$governance" -eq 1; then
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
echo "governance preflight requires both commit and nonce" >&2
@@ -1809,6 +1818,7 @@ jobs:
- publish-channels
- mirror-gitee-release
- repair-npm
- repair-gitee
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
@@ -1828,6 +1838,7 @@ jobs:
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}
run: |
set -eu
require_result() {
@@ -1857,6 +1868,7 @@ jobs:
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
require_publication
;;
workflow_dispatch:recover_release)
@@ -1864,6 +1876,7 @@ jobs:
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
require_publication
;;
workflow_dispatch:governance_preflight)
@@ -1877,6 +1890,7 @@ jobs:
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
@@ -1889,9 +1903,194 @@ jobs:
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
;;
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-gitee "$REPAIR_GITEE_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
;;
*)
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
exit 1
;;
esac
# Mirror an existing public immutable release's assets to Gitee on demand. The
# push-triggered mirror-gitee-release job depends on the same run's finalized
# dist artifact, so a tag that was already published (including one delivered
# by a recovery dispatch) cannot reuse it. This dispatch path re-derives the
# asset set from the immutable GitHub Release itself and mirrors it. The
# release tag and asset checksums remain the sealed authority boundary.
repair-gitee:
name: Mirror an existing immutable release to Gitee
needs: dispatch-contract
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tooling
persist-credentials: false
fetch-depth: 0
- name: Validate repair version
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
echo "Invalid release version: $VERSION" >&2
exit 2
fi
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
env:
VERSION: ${{ inputs.repair_gitee_version }}
with:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
const commitSha = tag.data.object.sha;
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commitSha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} is not contained in ${defaultBranch}`);
return;
}
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: version,
});
if (
release.data.tag_name !== version ||
release.data.draft ||
!release.data.immutable
) {
core.setFailed(`${version} is not a public immutable GitHub Release`);
return;
}
const expectedPrerelease = version.includes("-beta.");
if (release.data.prerelease !== expectedPrerelease) {
core.setFailed(`${version} prerelease metadata does not match its version`);
return;
}
const expectedAssets = new Set([
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
]);
const assetNames = release.data.assets.map((asset) => asset.name);
if (
assetNames.length !== expectedAssets.size ||
assetNames.some((name) => !expectedAssets.has(name)) ||
new Set(assetNames).size !== expectedAssets.size
) {
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
core.setOutput("commit_sha", commitSha);
core.setOutput("tag_object", ref.data.object.sha);
- name: Fetch and verify sealed release tag
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
run: |
set -eu
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
./scripts/release/verify-github-tag-authority.sh \
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Release workflow delivery
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/release/verify-release-workflow-delivery.sh \
"$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version }}
run: |
set -eu
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Mirror release to Gitee (China)
timeout-minutes: 100
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
env:
VERSION: ${{ inputs.repair_gitee_version }}
DIST_DIR: ${{ github.workspace }}/dist
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_REQUIRE_GITEE: "1"
+4 -2
View File
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
d.conns.Store("not-a-conn", struct{}{})
d.conns.Store(&queryConn{}, struct{}{})
d.consumerWG.Add(1)
d.shutdown()
acceptDone := make(chan struct{})
close(acceptDone)
d.shutdown(acceptDone)
d.consumerWG.Done()
d.shutdown()
d.shutdown(acceptDone)
pr, pw, err := os.Pipe()
if err != nil {
+13 -7
View File
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then close listener / drain consumers.
// goroutines wake up, then stop accepting connections before draining
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown()
<-acceptDone
d.shutdown(acceptDone)
<-idleDone
<-dropWarnDone
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
d.log.Warn("bus: accept error", "err", err)
continue
}
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
d.conns.Store(conn, struct{}{})
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
d.conns.Store(conn, struct{}{})
defer func() {
d.conns.Delete(conn)
conn.Close()
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for all per-connection goroutines to drain
// 5. lock + meta cleanup via Run's defers
func (d *daemon) shutdown() {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
+86 -5
View File
@@ -724,6 +724,86 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
start := strings.Index(workflow, " repair-gitee:\n")
if start == -1 {
t.Fatal("release workflow is missing the Gitee repair job")
}
repair := workflow[start:]
for _, required := range []string{
"repair_gitee_version:",
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
"gitee_repair=0",
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
"npm_repair + gitee_repair + governance + recovery",
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
t.Errorf("Gitee repair dispatch contract is missing %q", required)
}
}
for _, required := range []string{
"needs: dispatch-contract",
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`,
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
"actions: read",
`ref: ${{ github.sha }}`,
"path: tooling",
"persist-credentials: false",
"release_is_stable_version",
"release_is_prerelease_version",
`ref: ` + "`tags/${version}`",
`["ahead", "identical"].includes(comparison.data.status)`,
"!release.data.immutable",
`release.data.prerelease !== expectedPrerelease`,
"assetNames.length !== expectedAssets.size",
"new Set(assetNames).size !== expectedAssets.size",
`core.setOutput("tag_object", ref.data.object.sha)`,
"verify-github-tag-authority.sh",
"verify-release-workflow-delivery.sh",
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
"verify-release-artifacts.sh",
`--repo "$GITHUB_REPOSITORY"`,
`DWS_REQUIRE_GITEE: "1"`,
"working-directory: tooling\n run: |\n " +
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
} {
if !strings.Contains(repair, required) {
t.Errorf("Gitee repair authority is missing %q", required)
}
}
for _, asset := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
} {
if strings.Count(repair, `"`+asset+`"`) != 1 {
t.Errorf("Gitee repair must require exactly one %s asset declaration", asset)
}
}
if strings.Contains(repair, "contents: write") {
t.Error("Gitee repair must not grant contents write permission")
}
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
t.Error("Gitee repair must not check out a floating default branch")
}
if got := strings.Count(repair, "working-directory: tooling"); got < 4 {
t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got)
}
}
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
@@ -978,11 +1058,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
start := strings.Index(workflow, " release-delivery-gate:\n")
if start == -1 {
t.Fatal("release workflow is missing the terminal delivery gate")
}
gate := workflow[start:]
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
for _, required := range []string{
"name: Release delivery gate",
@@ -997,6 +1073,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"- publish-channels",
"- mirror-gitee-release",
"- repair-npm",
"- repair-gitee",
`REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`,
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
@@ -1006,6 +1084,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"workflow_dispatch:recover_release",
"workflow_dispatch:governance_preflight",
"workflow_dispatch:repair_npm",
"workflow_dispatch:repair_gitee",
`require_result repair-gitee "$REPAIR_GITEE_RESULT" success`,
`require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`,
"unsupported release mode",
} {
if !strings.Contains(gate, required) {