diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45baf027..c2912a36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 7885ea93..676313d6 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -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) {