From 76d301268dd74f2138f16ddf2e2591d2493a3815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 10:03:25 +0800 Subject: [PATCH] ci: add cloud release and withdrawal workflows --- .github/workflows/release.yml | 750 +++++++++-- .github/workflows/withdraw-release.yml | 148 ++ docs/releasing.md | 78 +- scripts/release/next-release-version.sh | 203 +++ scripts/release/recover-release.sh | 53 +- scripts/release/release-contract.sh | 64 +- scripts/release/release.sh | 8 +- scripts/release/verify-delivered-stable.sh | 2 +- .../verify-release-workflow-delivery.sh | 357 ++++- scripts/release/withdraw-release.sh | 1186 +++++++++++++++++ test/scripts/package_script_test.go | 119 +- test/scripts/release_script_test.go | 401 ++++++ test/scripts/withdraw_release_test.go | 746 +++++++++++ 13 files changed, 3945 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/withdraw-release.yml create mode 100755 scripts/release/next-release-version.sh create mode 100755 scripts/release/withdraw-release.sh create mode 100644 test/scripts/withdraw_release_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0398057..c45d4675 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.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 }} +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: @@ -7,6 +7,36 @@ on: - "v*" workflow_dispatch: inputs: + release_operation: + description: "Plan the next version or publish it from the current default-branch commit" + required: true + default: none + type: choice + options: + - none + - plan + - publish + release_channel: + description: "Release channel (used only when release_operation is plan/publish)" + required: true + default: beta + type: choice + options: + - beta + - stable + release_bump: + description: "New beta core bump; ignored while continuing an open beta line" + required: true + default: patch + type: choice + options: + - 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 @@ -89,13 +119,21 @@ jobs: RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} RECOVER_NONCE: ${{ inputs.recover_release_nonce }} RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }} + 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 npm_repair=0 gitee_repair=0 oss_repair=0 governance=0 recovery=0 + if test -n "$RELEASE_OPERATION" && test "$RELEASE_OPERATION" != none; then + release_flow=1 + fi test -z "$REPAIR_NPM_VERSION" || npm_repair=1 test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1 test -z "$REPAIR_OSS_VERSION" || oss_repair=1 @@ -105,11 +143,36 @@ jobs: test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \ test -n "$RECOVER_NONCE" || \ test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi - test $((npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { + test $((release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { echo "workflow_dispatch must select exactly one release mode" >&2 exit 1 } - if test "$npm_repair" -eq 1; then + if test "$release_flow" -eq 1; then + case "$RELEASE_CHANNEL" in beta|stable) ;; *) + echo "release_channel must be beta or stable" >&2 + exit 1 + esac + case "$RELEASE_BUMP" in patch|minor|major) ;; *) + echo "release_bump must be patch, minor, or major" >&2 + 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 + exit 1 + fi + elif 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" @@ -197,7 +260,7 @@ jobs: governance-preflight: name: Release governance preflight needs: dispatch-contract - if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }} + if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' || needs.dispatch-contract.outputs.mode == 'create_release' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -208,7 +271,8 @@ jobs: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }} REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} run: | @@ -225,10 +289,24 @@ jobs: echo "governance preflight cannot be combined with npm repair" >&2 exit 1 } - if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then - echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 - exit 1 - fi + case "$DISPATCH_MODE" in + governance_preflight) + if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then + echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 + exit 1 + fi + ;; + create_release) + test -z "$PREFLIGHT_NONCE" || { + echo "cloud release cannot be combined with a governance nonce" >&2 + exit 1 + } + ;; + *) + echo "unsupported governance preflight mode: $DISPATCH_MODE" >&2 + exit 1 + ;; + esac case "$PREFLIGHT_COMMIT" in *[!0-9a-f]*) echo "governance_preflight_commit must be a lowercase commit SHA" >&2 @@ -247,7 +325,7 @@ jobs: - name: Require successful Code Admission contexts on the preflight commit uses: actions/github-script@v7 env: - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} with: script: | const { owner, repo } = context.repo; @@ -299,7 +377,7 @@ jobs: - name: Check out trusted preflight tooling uses: actions/checkout@v4 with: - ref: ${{ inputs.governance_preflight_commit }} + ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} persist-credentials: false fetch-depth: 0 @@ -348,8 +426,8 @@ jobs: ./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary release-contract: - needs: [dispatch-contract, authorize-recovery] - if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }} + needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release] + if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -377,16 +455,28 @@ jobs: RECOVER_COMMIT: ${{ inputs.recover_release_commit }} RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }} RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + SEALED_VERSION: ${{ needs.seal-release.outputs.release_version }} + SEALED_COMMIT: ${{ needs.seal-release.outputs.release_commit }} + SEALED_TAG_OBJECT: ${{ needs.seal-release.outputs.release_tag_object }} with: script: | const { owner, repo } = context.repo; - const isRecovery = context.eventName === "workflow_dispatch"; + const dispatchMode = process.env.DISPATCH_MODE; + const isRecovery = + context.eventName === "workflow_dispatch" && dispatchMode === "recover_release"; + const isCreate = + context.eventName === "workflow_dispatch" && dispatchMode === "create_release"; let version = context.ref.replace("refs/tags/", ""); let commit = context.sha; let expectedTagObject = ""; let failedRunId = ""; let failedRunAttempt = ""; - if (isRecovery) { + if (context.eventName === "workflow_dispatch" && !isRecovery && !isCreate) { + core.setFailed(`unsupported publication dispatch mode: ${dispatchMode}`); + return; + } + if (isRecovery || isCreate) { if ( context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" || context.ref !== `refs/heads/${context.payload.repository.default_branch}` @@ -394,24 +484,28 @@ jobs: core.setFailed("release recovery must be dispatched from the official default branch"); return; } - version = process.env.RECOVER_VERSION; - commit = process.env.RECOVER_COMMIT; - expectedTagObject = process.env.RECOVER_TAG_OBJECT; - failedRunId = process.env.RECOVER_FAILED_RUN_ID; - failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + version = isCreate ? process.env.SEALED_VERSION : process.env.RECOVER_VERSION; + commit = isCreate ? process.env.SEALED_COMMIT : process.env.RECOVER_COMMIT; + expectedTagObject = isCreate + ? process.env.SEALED_TAG_OBJECT + : process.env.RECOVER_TAG_OBJECT; + if (isRecovery) { + failedRunId = process.env.RECOVER_FAILED_RUN_ID; + failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + } if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) { - core.setFailed(`invalid recovery release version: ${version}`); + core.setFailed(`invalid dispatched release version: ${version}`); return; } if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) { - core.setFailed("recovery tag object and commit must be full lowercase SHA-1 values"); + core.setFailed("dispatched tag object and commit must be full lowercase SHA-1 values"); return; } - if (!/^[1-9]\d*$/.test(failedRunId)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunId)) { core.setFailed("recover_failed_run_id must be a positive workflow run ID"); return; } - if (!/^[1-9]\d*$/.test(failedRunAttempt)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunAttempt)) { core.setFailed("recover_failed_run_attempt must be a positive run attempt"); return; } @@ -432,12 +526,67 @@ jobs: core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`); return; } - if (isRecovery && tagObject !== expectedTagObject) { + if ((isRecovery || isCreate) && tagObject !== expectedTagObject) { core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`); return; } + const tagFields = new Map(); + for (const line of tag.data.message.split(/\r?\n/)) { + const separator = line.indexOf(": "); + if (separator < 1) continue; + const key = line.slice(0, separator); + if (tagFields.has(key)) { + core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`); + return; + } + tagFields.set(key, line.slice(separator + 2)); + } + const cloudKeys = [ + "Channel", + "Release-Run", + "Release-Run-Attempt", + "Requested-By", + "Requested-By-ID", + "Sealed-Commit", + "Workflow-Commit", + "Allocation-Fingerprint", + ]; + const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key)); + const isCloudSeal = cloudKeys.every((key) => tagFields.has(key)); + if (hasAnyCloudMetadata && !isCloudSeal) { + core.setFailed(`${version} contains incomplete cloud seal metadata`); + return; + } + if (isCloudSeal) { + const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable"; + const actorId = String(context.payload.sender?.id || ""); + if ( + tagFields.get("Channel") !== expectedChannel || + tagFields.get("Sealed-Commit") !== commit || + tagFields.get("Workflow-Commit") !== commit || + !/^[0-9a-f]{64}$/.test(tagFields.get("Allocation-Fingerprint")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run-Attempt")) || + !/^[1-9]\d*$/.test(tagFields.get("Requested-By-ID")) + ) { + core.setFailed(`${version} cloud seal metadata does not match its tag authority`); + return; + } + if (isCreate && ( + tagFields.get("Release-Run") !== String(context.runId) || + tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT || + tagFields.get("Requested-By") !== context.actor || + tagFields.get("Requested-By-ID") !== actorId + )) { + core.setFailed(`${version} cloud seal is not bound to this release request`); + return; + } + } else if (isCreate) { + core.setFailed(`${version} is missing cloud seal metadata`); + return; + } - if (isRecovery) { + if (isRecovery || isCreate) { 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({ @@ -449,6 +598,8 @@ jobs: core.setFailed(`${version} commit is not contained in ${defaultBranch}`); return; } + } + if (isRecovery) { const failedRun = await github.request( "GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}", { @@ -459,19 +610,34 @@ jobs: }, ); const run = failedRun.data; + const failedByCloud = isCloudSeal; + const expectedBranch = failedByCloud + ? context.payload.repository.default_branch + : version; + const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"; if ( run.id !== Number(failedRunId) || run.run_attempt !== Number(failedRunAttempt) || run.repository.full_name !== `${owner}/${repo}` || run.path !== ".github/workflows/release.yml" || - run.event !== "push" || + run.event !== expectedEvent || run.status !== "completed" || !["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) || - run.head_branch !== version || + run.head_branch !== expectedBranch || run.head_sha !== commit ) { core.setFailed( - `run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`, + `run ${failedRunId} attempt ${failedRunAttempt} is not the failed sealed Release attempt for ${version}`, + ); + return; + } + if (failedByCloud && ( + tagFields.get("Release-Run") !== failedRunId || + tagFields.get("Release-Run-Attempt") !== failedRunAttempt || + tagFields.get("Requested-By-ID") !== String(run.actor?.id || "") + )) { + core.setFailed( + `run ${failedRunId} attempt ${failedRunAttempt} is not bound by the cloud seal for ${version}`, ); return; } @@ -512,7 +678,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Validate release contract id: contract @@ -757,7 +924,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing release source env: @@ -952,7 +1120,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing publication source env: @@ -1337,7 +1506,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing channel source env: @@ -1568,7 +1738,8 @@ jobs: run: | git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -1628,6 +1799,17 @@ jobs: script: | const { owner, repo } = context.repo; const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } const ref = await github.rest.git.getRef({ owner, repo, @@ -1694,100 +1876,18 @@ jobs: core.setFailed(`${version} does not contain the exact release asset set`); return; } - const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: version, - event: "push", - status: "completed", - per_page: 100, - }); - let passed = false; - for (const run of runs) { - if (run.head_sha !== commitSha || run.head_branch !== version) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === commitSha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - if (!passed) { - const recoveryTitle = `Release recovery ${version} at ${commitSha}`; - const recoveries = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: defaultBranch, - event: "workflow_dispatch", - status: "completed", - per_page: 100, - }); - for (const run of recoveries) { - const nonce = run.display_title.startsWith(`${recoveryTitle} `) - ? run.display_title.slice(recoveryTitle.length + 1) - : ""; - if ( - !new RegExp(`^${commitSha}-[0-9]+-[0-9]+$`).test(nonce) || - run.head_branch !== defaultBranch || - run.path !== ".github/workflows/release.yml" || - run.repository.full_name !== `${owner}/${repo}` - ) { - continue; - } - const workflowComparison = await github.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${run.head_sha}...${branch.data.commit.sha}`, - }); - if (!["ahead", "identical"].includes(workflowComparison.data.status)) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === run.head_sha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - } - if (!passed) { - core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`); - return; - } core.setOutput("commit_sha", commitSha); + - name: Require successful Release workflow delivery + working-directory: tooling + env: + VERSION: ${{ inputs.repair_npm_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./scripts/release/verify-release-workflow-delivery.sh --npm-repair \ + "$VERSION" "$RELEASE_COMMIT" + - name: Check out target release source uses: actions/checkout@v4 with: @@ -1888,6 +1988,8 @@ jobs: - mirror-gitee-release - repair-npm - repair-channel + - release-plan + - seal-release runs-on: ubuntu-latest timeout-minutes: 2 permissions: {} @@ -1908,6 +2010,8 @@ jobs: MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} + RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }} + SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }} run: | set -eu require_result() { @@ -1931,6 +2035,10 @@ jobs: require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped fi } + require_cloud_jobs_skipped() { + require_result release-plan "$RELEASE_PLAN_RESULT" skipped + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + } case "$EVENT_NAME:$DISPATCH_MODE" in push:) require_result dispatch-contract "$DISPATCH_RESULT" skipped @@ -1938,14 +2046,41 @@ jobs: require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; + workflow_dispatch:create_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_publication + ;; + workflow_dispatch:plan_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + 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 + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + ;; workflow_dispatch:recover_release) require_result dispatch-contract "$DISPATCH_RESULT" success 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-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; workflow_dispatch:governance_preflight) @@ -1960,6 +2095,7 @@ jobs: require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_npm) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1973,6 +2109,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_gitee) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1986,6 +2123,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_cloud_jobs_skipped ;; workflow_dispatch:repair_oss) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1999,6 +2137,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_cloud_jobs_skipped ;; *) echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2 @@ -2050,6 +2189,17 @@ jobs: script: | const { owner, repo } = context.repo; const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } const ref = await github.rest.git.getRef({ owner, repo, @@ -2142,7 +2292,8 @@ jobs: set -eu git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' ./scripts/release/verify-github-tag-authority.sh \ "$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -2207,3 +2358,348 @@ jobs: OSS_BUCKET: ${{ secrets.OSS_BUCKET }} OSS_PREFIX: ${{ secrets.OSS_PREFIX }} DWS_REQUIRE_OSS: "1" + + release-plan: + name: Plan next cloud release + needs: [dispatch-contract, governance-preflight] + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'plan_release' || (needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success')) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + outputs: + release_version: ${{ steps.allocate.outputs.release_version }} + release_commit: ${{ steps.allocate.outputs.release_commit }} + channel: ${{ steps.allocate.outputs.channel }} + from_beta: ${{ steps.allocate.outputs.from_beta }} + base: ${{ steps.allocate.outputs.base }} + refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }} + steps: + - name: Validate official default-branch release request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli + run: | + set -eu + test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || { + echo "cloud releases are restricted to $EXPECTED_REPOSITORY" >&2 + exit 1 + } + test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" || { + echo "cloud releases must be dispatched from $DEFAULT_BRANCH" >&2 + exit 1 + } + printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || { + echo "cloud release commit must be a full lowercase SHA" >&2 + exit 1 + } + + - name: Check out exact default-branch commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Fetch current main and all allocated versions + run: | + set -eu + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" || { + echo "checked-out release commit differs from the dispatched commit" >&2 + exit 1 + } + test "$(git rev-parse refs/remotes/origin/main)" = "$GITHUB_SHA" || { + echo "main moved after this release was dispatched; run it again from current main" >&2 + exit 1 + } + + - name: Allocate the next version + id: allocate + env: + REQUESTED_CHANNEL: ${{ inputs.release_channel }} + REQUESTED_BUMP: ${{ inputs.release_bump }} + run: | + set -eu + allocation="$RUNNER_TEMP/next-release-version" + ./scripts/release/next-release-version.sh \ + --channel "$REQUESTED_CHANNEL" \ + --bump "$REQUESTED_BUMP" > "$allocation" + for key in release_version from_beta channel base; do + count="$(grep -c "^$key=" "$allocation")" + test "$count" -eq 1 || { + echo "allocator returned $count values for $key" >&2 + exit 1 + } + value="$(sed -n "s/^$key=//p" "$allocation")" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" + done + refs_fingerprint="$( + git for-each-ref --format='%(refname)=%(objectname)' \ + refs/tags/v refs/tags/withdrawn/v \ + | LC_ALL=C sort \ + | sha256sum \ + | awk '{print $1}' + )" + echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT" + + release_version="$(sed -n 's/^release_version=//p' "$allocation")" + from_beta="$(sed -n 's/^from_beta=//p' "$allocation")" + channel="$(sed -n 's/^channel=//p' "$allocation")" + { + echo "## Cloud release plan" + echo + echo "- Version: \`$release_version\`" + echo "- Channel: \`$channel\`" + echo "- Commit: \`$GITHUB_SHA\`" + if test -n "$from_beta"; then + echo "- Promotes beta: \`$from_beta\`" + fi + if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then + echo + echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Validate the candidate release contract before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }} + RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + run: | + set -eu + git config user.name "dws release cloud seal" + git config user.email "dws-release@users.noreply.github.com" + { + printf 'Release %s\n\n' "$RELEASE_VERSION" + printf 'Channel: %s\n' "$RELEASE_CHANNEL" + test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA" + } > "$RUNNER_TEMP/candidate-tag-message" + git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message" + set -- \ + --channel "$RELEASE_CHANNEL" \ + --version "$RELEASE_VERSION" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" \ + --metadata-output "$RUNNER_TEMP/release-contract-output" + if test -n "$FROM_BETA"; then + set -- "$@" --from-beta "$FROM_BETA" + fi + ./scripts/release/release-contract.sh "$@" + + - name: Require delivered previous stable baseline before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + previous_stable="$(sed -n 's/^previous_stable=//p' "$RUNNER_TEMP/release-contract-output")" + previous_commit="$(sed -n 's/^previous_stable_commit=//p' "$RUNNER_TEMP/release-contract-output")" + if test -n "$previous_stable"; then + ./scripts/release/verify-delivered-stable.sh "$previous_stable" "$previous_commit" + fi + + - name: Require delivered beta before sealing stable + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && steps.allocate.outputs.channel == 'stable' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + run: | + set -eu + from_beta_commit="$(git rev-parse "$FROM_BETA^{commit}")" + release_state="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$FROM_BETA" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" + test "$release_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" || { + echo "$FROM_BETA is not a published immutable prerelease" >&2 + exit 1 + } + ./scripts/release/verify-github-release-assets.sh "$FROM_BETA" + ./scripts/release/verify-release-workflow-delivery.sh \ + "$FROM_BETA" "$from_beta_commit" + + seal-release: + 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' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + 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: Create one immutable annotated release tag + id: seal + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ needs.release-plan.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-plan.outputs.release_commit }} + RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }} + FROM_BETA: ${{ needs.release-plan.outputs.from_beta }} + REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }} + with: + script: | + const crypto = require("crypto"); + const { owner, repo } = context.repo; + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + const version = process.env.RELEASE_VERSION; + const commit = process.env.RELEASE_COMMIT; + const channel = process.env.RELEASE_CHANNEL; + const fromBeta = process.env.FROM_BETA; + const expectedFingerprint = process.env.REFS_FINGERPRINT; + + if ( + context.payload.repository.full_name !== expectedRepository || + context.ref !== `refs/heads/${defaultBranch}` || + context.sha !== commit + ) { + core.setFailed("cloud release seal is not bound to the official dispatched default-branch commit"); + return; + } + if ( + !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) || + !/^[0-9a-f]{40}$/.test(commit) || + !/^[0-9a-f]{64}$/.test(expectedFingerprint) + ) { + core.setFailed("cloud release plan contains an invalid sealed identity"); + return; + } + if ( + (channel === "stable" && !fromBeta) || + (channel === "prerelease" && fromBeta) + ) { + core.setFailed("cloud release channel and beta promotion baseline disagree"); + return; + } + + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + if (branch.data.commit.sha !== commit) { + core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`); + return; + } + + const refs = []; + for (const prefix of ["tags/v", "tags/withdrawn/v"]) { + const matching = await github.paginate(github.rest.git.listMatchingRefs, { + owner, + repo, + ref: prefix, + per_page: 100, + }); + for (const ref of matching) { + refs.push(`${ref.ref}=${ref.object.sha}`); + } + } + refs.sort(); + const actualFingerprint = crypto + .createHash("sha256") + .update(refs.length > 0 ? `${refs.join("\n")}\n` : "") + .digest("hex"); + if (actualFingerprint !== expectedFingerprint) { + core.setFailed("allocated release refs changed after planning; run the release again"); + return; + } + + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }); + core.setFailed(`release tag already exists: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`release version was withdrawn and cannot be reused: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + const message = [ + `Release ${version}`, + "", + `Channel: ${channel}`, + ...(fromBeta ? [`From-Beta: ${fromBeta}`] : []), + `Release-Run: ${context.runId}`, + `Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`, + `Requested-By: ${context.actor}`, + `Requested-By-ID: ${context.payload.sender?.id || ""}`, + `Sealed-Commit: ${commit}`, + `Workflow-Commit: ${context.sha}`, + `Allocation-Fingerprint: ${expectedFingerprint}`, + ].join("\n"); + const tag = await github.rest.git.createTag({ + owner, + repo, + tag: version, + message, + object: commit, + type: "commit", + }); + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${version}`, + sha: tag.data.sha, + }); + + const sealedRef = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if ( + sealedRef.data.object.type !== "tag" || + sealedRef.data.object.sha !== tag.data.sha + ) { + core.setFailed(`${version} did not seal to the new annotated tag object`); + return; + } + const sealedTag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: tag.data.sha, + }); + if ( + sealedTag.data.tag !== version || + sealedTag.data.object.type !== "commit" || + sealedTag.data.object.sha !== commit || + sealedTag.data.message !== message + ) { + core.setFailed(`${version} annotated tag authority differs after creation`); + return; + } + + core.setOutput("release_version", version); + core.setOutput("release_commit", commit); + core.setOutput("release_tag_object", tag.data.sha); + await core.summary + .addHeading("Cloud release sealed") + .addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`) + .write(); diff --git a/.github/workflows/withdraw-release.yml b/.github/workflows/withdraw-release.yml new file mode 100644 index 00000000..b84bafa9 --- /dev/null +++ b/.github/workflows/withdraw-release.yml @@ -0,0 +1,148 @@ +name: Withdraw release +run-name: Withdraw ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)" + required: true + type: string + reason: + description: "Public, single-line withdrawal reason (8-300 characters)" + required: true + type: string + confirmation: + description: "Type WITHDRAW followed by a space and the exact version" + required: true + type: string + +permissions: + contents: read + +# Share the publication lock with release.yml. A withdrawal and a publication +# must never mutate channel pointers concurrently. +concurrency: + group: dws-release-publication + cancel-in-progress: false + +jobs: + withdraw: + name: Withdraw release from every distribution channel + environment: release-withdrawal + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + actions: read + contents: write + steps: + - name: Verify withdrawal environment protection + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const response = await github.request( + "GET /repos/{owner}/{repo}/environments/{environment_name}", + { owner, repo, environment_name: "release-withdrawal" }, + ); + const reviewerRule = response.data.protection_rules.find( + (rule) => rule.type === "required_reviewers", + ); + if ( + !reviewerRule || + reviewerRule.prevent_self_review !== true || + !Array.isArray(reviewerRule.reviewers) || + reviewerRule.reviewers.length === 0 + ) { + core.setFailed("release-withdrawal must require a reviewer and prevent self-review"); + return; + } + if (response.data.deployment_branch_policy?.protected_branches !== true) { + core.setFailed("release-withdrawal must allow only protected branches"); + } + if (response.data.can_admins_bypass !== false) { + core.setFailed("release-withdrawal must not allow administrator bypass"); + } + + - name: Require the exact current official default-branch commit + uses: actions/github-script@v7 + with: + script: | + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + if (context.eventName !== "workflow_dispatch") { + core.setFailed("release withdrawal accepts workflow_dispatch only"); + return; + } + if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) { + core.setFailed(`release withdrawal is restricted to ${expectedRepository}`); + return; + } + if (context.ref !== `refs/heads/${defaultBranch}`) { + core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`); + return; + } + const branch = await github.rest.git.getRef({ + ...context.repo, + ref: `heads/${defaultBranch}`, + }); + if (branch.data.object.sha !== context.sha) { + core.setFailed( + `default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`, + ); + } + + - name: Check out trusted withdrawal tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js for npm channel withdrawal + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Withdraw immutable release and roll back channels + id: withdrawal + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WITHDRAW_VERSION: ${{ inputs.version }} + WITHDRAW_REASON: ${{ inputs.reason }} + WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }} + OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }} + HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} + run: | + ./scripts/release/withdraw-release.sh \ + "$WITHDRAW_VERSION" \ + "$WITHDRAW_REASON" \ + "$WITHDRAW_CONFIRMATION" + + - name: Report withdrawal boundary + if: ${{ always() }} + env: + VERSION: ${{ inputs.version }} + RESULT: ${{ steps.withdrawal.outcome }} + run: | + { + echo "### Release withdrawal: ${VERSION}" + echo + echo "- Workflow result: ${RESULT}" + echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier." + echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause." + echo "- The problem GitHub Release and original tag are removed after npm/OSS/Gitee rollback so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed." + echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded." + echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/releasing.md b/docs/releasing.md index f3076519..7b2e8a00 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,10 +1,52 @@ # 发布手册(预发 / 正式) -发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。 +发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula 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`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 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 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。 -## 日常只用一个入口 +## 推荐入口:GitHub 云端发布 + +任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布: + +1. 在 GitHub Actions 打开 `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`。 + +`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm/OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。 + +## 自动版本规则 + +- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。 +- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。 +- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。 +- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。 +- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。 + +## 全平台撤回与回滚 + +已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写: + +- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。 +- `reason`:8–300 字符的单行公开原因。 +- `confirmation`:精确输入 `WITHDRAW `,例如 `WITHDRAW v1.0.53`。 + +该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行: + +1. 先创建永久 annotated tag `withdrawn/`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。 +2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。 +3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;OSS 先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。 +4. npm、OSS 和 Gitee 均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。 + +GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。 + +撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。 + +撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。 + +撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。 + +## 兼容入口:本地发布 安装发布 Skill 后直接运行: @@ -29,7 +71,7 @@ main 上的候选代码 + beta CHANGELOG → vX.Y.Z(正式发布) ``` -正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 +云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 ## 预发发布 @@ -68,7 +110,7 @@ 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 会再次读取和验证。 +本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 ## CHANGELOG 契约 @@ -86,14 +128,14 @@ 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、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。 +- 只接受 `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;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `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`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。 -- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。 -- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。 +- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。 +- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 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。 @@ -110,25 +152,27 @@ workflow 和本地直发脚本已停用,避免绕开 publication queue 或用 ## 既有 tag 的紧急恢复 -tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: +云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: ```bash dws-release recover v1.2.3-beta.1 ``` -命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: +命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: -- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。 -- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。 +- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。 +- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。 - `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。 - 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。 - 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。 成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。 -OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。 +云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。 -Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。 +OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。发布和撤回仍把 OSS 作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本。 + +Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。 ## 平台治理前置 @@ -136,8 +180,14 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac - `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag 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 验证。 +- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。 +- 配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。 +- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。 - 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。 - 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。 +- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。 +- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。 immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/release/next-release-version.sh b/scripts/release/next-release-version.sh new file mode 100755 index 00000000..e0d4a6d7 --- /dev/null +++ b/scripts/release/next-release-version.sh @@ -0,0 +1,203 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +CHANNEL="" +REQUESTED_CHANNEL="" +BUMP="patch" + +usage() { + cat >&2 <<'EOF' +usage: next-release-version.sh --channel [options] + +Options: + --bump Core bump when starting a new beta line (default: patch) + --repo-root Override repository root (primarily for tests) + +Output: + release_version= + from_beta= + channel= + base= + +Both ordinary release tags and refs/tags/withdrawn/v... tombstones reserve a +version permanently. The caller must create the returned release tag +atomically; this script only calculates the next candidate. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --channel) + [ "$#" -ge 2 ] || { usage; exit 2; } + REQUESTED_CHANNEL="$2" + shift 2 + ;; + --bump) + [ "$#" -ge 2 ] || { usage; exit 2; } + BUMP="$2" + shift 2 + ;; + --repo-root) + [ "$#" -ge 2 ] || { usage; exit 2; } + ROOT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +case "$REQUESTED_CHANNEL" in + beta|prerelease) CHANNEL="prerelease" ;; + stable) CHANNEL="stable" ;; + *) + printf 'invalid release channel: %s (expected beta, prerelease, or stable)\n' "${REQUESTED_CHANNEL:-}" >&2 + exit 2 + ;; +esac +case "$BUMP" in + patch|minor|major) ;; + *) + printf 'invalid version bump: %s (expected patch, minor, or major)\n' "$BUMP" >&2 + exit 2 + ;; +esac + +cd "$ROOT" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'not a Git worktree: %s\n' "$ROOT" >&2 + exit 1 +} + +allocated_version_for_ref() { + _avfr_ref="$1" + case "$_avfr_ref" in + refs/tags/withdrawn/v*) printf '%s\n' "${_avfr_ref#refs/tags/withdrawn/}" ;; + refs/tags/v*) printf '%s\n' "${_avfr_ref#refs/tags/}" ;; + *) return 1 ;; + esac +} + +stable_core_is_allocated() { + _scia_core="$1" + git show-ref --verify --quiet "refs/tags/$_scia_core" || + git show-ref --verify --quiet "refs/tags/withdrawn/$_scia_core" +} + +version_is_withdrawn() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +bump_core() { + _bc_version="${1#v}" + _bc_major="${_bc_version%%.*}" + _bc_remainder="${_bc_version#*.}" + _bc_minor="${_bc_remainder%%.*}" + _bc_patch="${_bc_remainder#*.}" + case "$2" in + patch) _bc_patch=$((_bc_patch + 1)) ;; + minor) + _bc_minor=$((_bc_minor + 1)) + _bc_patch=0 + ;; + major) + _bc_major=$((_bc_major + 1)) + _bc_minor=0 + _bc_patch=0 + ;; + esac + printf 'v%s.%s.%s\n' "$_bc_major" "$_bc_minor" "$_bc_patch" +} + +tag_refs="$(git for-each-ref --format='%(refname)' refs/tags)" +latest_stable="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + if release_is_stable_version "$version"; then + if [ -z "$latest_stable" ] || release_core_is_greater "$version" "$latest_stable"; then + latest_stable="$version" + fi + fi +done + +highest_open_beta_core="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + core="$(release_core_tag "$version")" + stable_core_is_allocated "$core" && continue + if [ -n "$latest_stable" ] && ! release_core_is_greater "$core" "$latest_stable"; then + continue + fi + if [ -z "$highest_open_beta_core" ] || release_core_is_greater "$core" "$highest_open_beta_core"; then + highest_open_beta_core="$core" + fi +done + +release_version="" +from_beta="" +base="$latest_stable" + +if [ "$CHANNEL" = "prerelease" ]; then + if [ -n "$highest_open_beta_core" ]; then + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + next_beta_number=$(( $(release_beta_number "$latest_beta") + 1 )) + release_version="$highest_open_beta_core-beta.$next_beta_number" + else + [ -n "$latest_stable" ] || { + printf 'cannot start a beta line without an allocated stable baseline\n' >&2 + exit 1 + } + next_core="$(bump_core "$latest_stable" "$BUMP")" + release_version="$next_core-beta.1" + fi +else + [ -n "$highest_open_beta_core" ] || { + printf 'cannot create a stable release without an open beta line newer than the latest allocated stable\n' >&2 + exit 1 + } + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + if version_is_withdrawn "$latest_beta" || + ! git show-ref --verify --quiet "refs/tags/$latest_beta"; then + printf 'latest beta %s is withdrawn; create the next beta before stable promotion\n' "$latest_beta" >&2 + exit 1 + fi + release_version="$highest_open_beta_core" + from_beta="$latest_beta" +fi + +printf 'release_version=%s\n' "$release_version" +printf 'from_beta=%s\n' "$from_beta" +printf 'channel=%s\n' "$CHANNEL" +printf 'base=%s\n' "$base" diff --git a/scripts/release/recover-release.sh b/scripts/release/recover-release.sh index 0e6c4a8a..5e4df00e 100755 --- a/scripts/release/recover-release.sh +++ b/scripts/release/recover-release.sh @@ -120,12 +120,41 @@ git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref" } tag_object="$(git rev-parse "$recovery_ref")" commit="$(git rev-parse "$recovery_ref^{commit}")" +tag_message="$(git for-each-ref "$recovery_ref" --format='%(contents)')" +cloud_run_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run" { print $2 }')" +cloud_run_attempt="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run-Attempt" { print $2 }')" +cloud_actor="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By" { print $2 }')" +cloud_actor_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By-ID" { print $2 }')" +cloud_sealed_commit="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Sealed-Commit" { print $2 }')" +cloud_marker_count=0 +for cloud_value in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor" "$cloud_actor_id" "$cloud_sealed_commit"; do + [ -z "$cloud_value" ] || cloud_marker_count=$((cloud_marker_count + 1)) +done +if [ "$cloud_marker_count" -ne 0 ] && [ "$cloud_marker_count" -ne 5 ]; then + printf '%s contains incomplete cloud release metadata\n' "$VERSION" >&2 + exit 1 +fi +if [ "$cloud_marker_count" -eq 5 ]; then + for cloud_number in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor_id"; do + printf '%s\n' "$cloud_number" | grep -Eq '^[1-9][0-9]*$' || { + printf '%s contains invalid cloud release identity\n' "$VERSION" >&2 + exit 1 + } + done + [ "$cloud_sealed_commit" = "$commit" ] || { + printf '%s cloud release metadata is not bound to %s\n' "$VERSION" "$commit" >&2 + exit 1 + } +fi git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || { printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2 exit 1 } -if [ -z "$FAILED_RUN_ID" ]; then +if [ -z "$FAILED_RUN_ID" ] && [ "$cloud_marker_count" -eq 5 ]; then + FAILED_RUN_ID="$cloud_run_id" + FAILED_RUN_ATTEMPT="$cloud_run_attempt" +elif [ -z "$FAILED_RUN_ID" ]; then candidate_runs="$( gh api \ -H 'Accept: application/vnd.github+json' \ @@ -160,7 +189,7 @@ attempt_record="$( gh api \ -H 'Accept: application/vnd.github+json' \ "repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \ - --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv' + --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv' )" || { printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2 exit 1 @@ -174,6 +203,7 @@ attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)" attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)" attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)" attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)" +attempt_actor_id="$(printf '%s\n' "$attempt_record" | cut -f11)" case "$attempt_conclusion" in failure|cancelled|timed_out|startup_failure|stale) ;; *) @@ -182,18 +212,33 @@ case "$attempt_conclusion" in exit 1 ;; esac +if [ "$cloud_marker_count" -eq 5 ]; then + expected_attempt_event="workflow_dispatch" + expected_attempt_branch="main" +else + expected_attempt_event="push" + expected_attempt_branch="$VERSION" +fi if [ "$attempt_id" != "$FAILED_RUN_ID" ] || [ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] || [ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] || [ "$attempt_path" != ".github/workflows/release.yml" ] || - [ "$attempt_event" != "push" ] || + [ "$attempt_event" != "$expected_attempt_event" ] || [ "$attempt_status" != "completed" ] || - [ "$attempt_branch" != "$VERSION" ] || + [ "$attempt_branch" != "$expected_attempt_branch" ] || [ "$attempt_commit" != "$commit" ]; then printf 'Release run %s attempt %s does not match %s at %s\n' \ "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" "$commit" >&2 exit 1 fi +if [ "$cloud_marker_count" -eq 5 ] && + { [ "$cloud_run_id" != "$FAILED_RUN_ID" ] || + [ "$cloud_run_attempt" != "$FAILED_RUN_ATTEMPT" ] || + [ "$cloud_actor_id" != "$attempt_actor_id" ]; }; then + printf 'Release run %s attempt %s is not bound by the cloud seal for %s\n' \ + "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" >&2 + exit 1 +fi printf 'Recovery target:\n' printf ' version: %s\n' "$VERSION" diff --git a/scripts/release/release-contract.sh b/scripts/release/release-contract.sh index 71bbb6b1..3e4aadc7 100755 --- a/scripts/release/release-contract.sh +++ b/scripts/release/release-contract.sh @@ -91,7 +91,13 @@ if [ "$CONTEXT" = "local" ]; then printf 'release tag already exists locally: %s\n' "$VERSION" >&2 exit 1 fi - remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || { + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi + remote_tag="$(git ls-remote --tags "$REMOTE" \ + "refs/tags/$VERSION" "refs/tags/$VERSION^{}" \ + "refs/tags/withdrawn/$VERSION" "refs/tags/withdrawn/$VERSION^{}")" || { printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2 exit 1 } @@ -100,6 +106,10 @@ if [ "$CONTEXT" = "local" ]; then exit 1 } else + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || { printf 'CI release tag is not available: %s\n' "$VERSION" >&2 exit 1 @@ -120,32 +130,58 @@ fi previous_stable="" previous_stable_commit="" -for tag in $(git tag --list 'v*' --sort=-version:refname); do +latest_allocated_stable="" +for tag_ref in $(git for-each-ref --format='%(refname)' refs/tags); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_stable_version "$tag"; then - previous_stable="$tag" - previous_stable_commit="$(git rev-parse "$tag^{commit}")" - break + release_is_stable_version "$tag" || continue + if [ -z "$latest_allocated_stable" ] || + release_core_is_greater "$tag" "$latest_allocated_stable"; then + latest_allocated_stable="$tag" fi done -if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then - printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2 +if [ -n "$latest_allocated_stable" ] && + ! release_core_is_greater "$VERSION" "$latest_allocated_stable"; then + printf 'release version %s must be greater than latest allocated stable %s\n' \ + "$VERSION" "$latest_allocated_stable" >&2 exit 1 fi +for tag in $(git tag --list 'v*' --sort=-version:refname); do + [ "$tag" = "$VERSION" ] && continue + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + previous_stable="$tag" + previous_stable_commit="$(git rev-parse "$tag^{commit}")" + break +done + core_tag="$(release_core_tag "$VERSION")" if [ "$CHANNEL" = "prerelease" ]; then [ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; } - if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then + if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null || + git rev-parse --verify --quiet "refs/tags/withdrawn/$core_tag" >/dev/null; then printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2 exit 1 fi previous_beta="" - for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do + for tag_ref in $(git for-each-ref --format='%(refname)' \ + "refs/tags/$core_tag-beta.*" "refs/tags/withdrawn/$core_tag-beta.*"); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_prerelease_version "$tag"; then + release_is_prerelease_version "$tag" || continue + if [ -z "$previous_beta" ] || release_version_is_greater "$tag" "$previous_beta"; then previous_beta="$tag" - break fi done beta_number="$(release_beta_number "$VERSION")" @@ -178,6 +214,10 @@ else printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 exit 1 } + if git rev-parse --verify --quiet "refs/tags/withdrawn/$FROM_BETA" >/dev/null; then + printf 'stable beta baseline was withdrawn: %s\n' "$FROM_BETA" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || { printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2 exit 1 diff --git a/scripts/release/release.sh b/scripts/release/release.sh index db88045a..fa0f87c9 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -288,8 +288,12 @@ build_policy_binary() { } fetch_release_tags() { - git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*' - git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*' + git fetch --force "$REMOTE" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + git fetch --force --no-tags "$OFFICIAL_TAGS_URL" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' } printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH" diff --git a/scripts/release/verify-delivered-stable.sh b/scripts/release/verify-delivered-stable.sh index 2e83fa2b..28b2dd69 100755 --- a/scripts/release/verify-delivered-stable.sh +++ b/scripts/release/verify-delivered-stable.sh @@ -82,7 +82,7 @@ if [ "$delivered_by_push" -ne 1 ]; then if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \ DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \ "$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then - printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \ + printf 'Delivered stable baseline verified through trusted default-branch delivery: %s -> %s\n' \ "$TAG" "$EXPECTED_COMMIT" exit 0 fi diff --git a/scripts/release/verify-release-workflow-delivery.sh b/scripts/release/verify-release-workflow-delivery.sh index 40f19ca0..0a264000 100755 --- a/scripts/release/verify-release-workflow-delivery.sh +++ b/scripts/release/verify-release-workflow-delivery.sh @@ -18,6 +18,9 @@ if [ "${1:-}" = "--channel-repair" ]; then exit 2 ;; esac +elif [ "${1:-}" = "--npm-repair" ]; then + MODE="npm-repair" + shift fi TAG="${1:-}" @@ -25,7 +28,7 @@ EXPECTED_COMMIT="${2:-}" REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" [ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || { - printf 'usage: verify-release-workflow-delivery.sh [--channel-repair ] \n' >&2 + printf 'usage: verify-release-workflow-delivery.sh [--channel-repair | --npm-repair] \n' >&2 exit 2 } if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then @@ -81,6 +84,209 @@ for run in runs: done } +find_cloud_delivery_identity() { + tag_ref="$( + github_get "repos/$REPOSITORY/git/ref/tags/$TAG" \ + | python3 -c 'import json,sys +ref=json.load(sys.stdin) +obj=ref.get("object", {}) +if obj.get("type") == "tag" and obj.get("sha"): + print(obj["sha"])' + )" || return 1 + [ -n "$tag_ref" ] || return 1 + github_get "repos/$REPOSITORY/git/tags/$tag_ref" \ + | python3 -c 'import json,re,sys +tag,commit=sys.argv[1:] +payload=json.load(sys.stdin) +if payload.get("tag") != tag or payload.get("object", {}).get("type") != "commit": + raise SystemExit(1) +if payload.get("object", {}).get("sha") != commit: + raise SystemExit(1) +fields={} +for line in payload.get("message", "").splitlines(): + if ": " not in line: + continue + key,value=line.split(": ", 1) + if key in fields: + raise SystemExit(1) + fields[key]=value +required={ + "Channel", "Release-Run", "Release-Run-Attempt", "Requested-By", + "Requested-By-ID", "Sealed-Commit", "Workflow-Commit", + "Allocation-Fingerprint", +} +if not required.issubset(fields): + raise SystemExit(1) +if fields["Sealed-Commit"] != commit or fields["Workflow-Commit"] != commit: + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run-Attempt"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Requested-By-ID"]): + raise SystemExit(1) +if not fields["Requested-By"] or not re.fullmatch(r"[0-9a-f]{64}", fields["Allocation-Fingerprint"]): + raise SystemExit(1) +is_beta="-beta." in tag +if fields["Channel"] != ("prerelease" if is_beta else "stable"): + raise SystemExit(1) +from_beta=fields.get("From-Beta", "") +if is_beta: + if from_beta: + raise SystemExit(1) +else: + core=re.escape(tag) + if not re.fullmatch(core + r"-beta\.[1-9][0-9]*", from_beta): + raise SystemExit(1) +print("\t".join([ + fields["Release-Run"], + fields["Release-Run-Attempt"], + fields["Requested-By"], + fields["Requested-By-ID"], + fields["Workflow-Commit"], +]))' "$TAG" "$EXPECTED_COMMIT" +} + +verify_cloud_delivery() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ -n "$cloud_run_id" ] && [ -n "$cloud_run_attempt" ] && + [ -n "$cloud_actor" ] && [ -n "$cloud_actor_id" ] && + [ -n "$cloud_workflow_sha" ] || return 1 + + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] || + return 1 + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] || return 1 + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] || + return 1 + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-cloud-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )" || { + rm -rf "$jobs_dir" + return 1 + } + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$cloud_workflow_sha" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +workflow_sha, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +required = ( + "Plan next cloud release", + "Seal cloud release tag", + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", + "Publish npm and mirrors", + "Release delivery gate", +) +for name in required: + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + raise SystemExit(1) + job = matches[0] + if ( + job.get("head_sha") != workflow_sha + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + raise SystemExit(1) + +seal = next(job for job in jobs if job.get("name") == "Seal cloud release tag") +steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + raise SystemExit(1) +PY + rm -rf "$jobs_dir" + return "$result" +} + +verify_failed_cloud_delivery_identity() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tfailure\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] && + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] && + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] +} + find_failed_push_delivery() { matches="" page=1 @@ -275,6 +481,112 @@ PY return "$result" } +verify_npm_repair_delivery() { + run_id="$1" + run_attempt="$2" + require_cloud_seal="$3" + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-npm-repair-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + if ! page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )"; then + rm -rf "$jobs_dir" + return 1 + fi + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$require_cloud_seal" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +commit, run_id, run_attempt, tag, require_cloud_seal, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +def fail(message): + print( + f"Release run {run_id} attempt {run_attempt} is not safe npm-repair " + f"authority for {tag}: {message}", + file=sys.stderr, + ) + raise SystemExit(1) + +def one_job(name): + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one job {name!r}, found {len(matches)}") + job = matches[0] + if ( + job.get("head_sha") != commit + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + fail(f"required job {name!r} did not succeed at {commit}") + return job + +for name in ( + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", +): + one_job(name) + +if require_cloud_seal == "true": + one_job("Plan next cloud release") + seal = one_job("Seal cloud release tag") + seal_steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" + ] + if ( + len(seal_steps) != 1 + or seal_steps[0].get("status") != "completed" + or seal_steps[0].get("conclusion") != "success" + ): + fail("cloud release seal step did not succeed") +elif require_cloud_seal != "false": + fail(f"invalid cloud seal requirement {require_cloud_seal!r}") + +published = one_job("Publish immutable GitHub Release") +steps = [ + step for step in published.get("steps", []) + if step.get("name") == "Require immutable published GitHub Release" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + fail("immutable GitHub Release verification step did not succeed") + +channels = [job for job in jobs if job.get("name") == "Publish npm and mirrors"] +if len(channels) != 1: + fail(f"expected exactly one npm publication job, found {len(channels)}") +channel = channels[0] +if ( + channel.get("head_sha") != commit + or channel.get("status") != "completed" + or channel.get("conclusion") not in {"success", "failure"} +): + fail("npm publication job is not a completed success/failure at the release commit") +PY + rm -rf "$jobs_dir" + return "$result" +} + push_delivery="$(find_push_delivery || true)" if [ -n "$push_delivery" ]; then printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \ @@ -282,6 +594,15 @@ if [ -n "$push_delivery" ]; then exit 0 fi +cloud_delivery_identity="$(find_cloud_delivery_identity || true)" +if [ -n "$cloud_delivery_identity" ] && + verify_cloud_delivery "$cloud_delivery_identity"; then + cloud_delivery_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + printf 'Release workflow delivery verified through cloud release run %s: %s -> %s\n' \ + "$cloud_delivery_run" "$TAG" "$EXPECTED_COMMIT" + exit 0 +fi + if [ "$MODE" = "channel-repair" ]; then failed_push_identity="$(find_failed_push_delivery || true)" failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)" @@ -292,6 +613,38 @@ if [ "$MODE" = "channel-repair" ]; then "$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" exit 0 fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_channel_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt"; then + printf 'Release %s channel-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$CHANNEL_REPAIR_TARGET" "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi +fi + +if [ "$MODE" = "npm-repair" ]; then + failed_push_identity="$(find_failed_push_delivery || true)" + failed_push_run="$(printf '%s\n' "$failed_push_identity" | cut -f1)" + failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)" + if [ -n "$failed_push_run" ] && + verify_npm_repair_delivery "$failed_push_run" "$failed_push_attempt" false; then + printf 'Release npm-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \ + "$failed_push_run" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_npm_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt" true; then + printf 'Release npm-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi fi find_recovery_identity() { @@ -325,7 +678,7 @@ for run in runs: recovery_identity="$(find_recovery_identity || true)" [ -n "$recovery_identity" ] || { - printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \ + printf 'Release workflow did not deliver %s at %s through a tag push, cloud release, or protected recovery\n' \ "$TAG" "$EXPECTED_COMMIT" >&2 exit 1 } diff --git a/scripts/release/withdraw-release.sh b/scripts/release/withdraw-release.sh new file mode 100755 index 00000000..6af3097c --- /dev/null +++ b/scripts/release/withdraw-release.sh @@ -0,0 +1,1186 @@ +#!/usr/bin/env bash +# Withdraw one already-published DWS release from every distribution channel +# without pretending that installed clients can be downgraded. +# +# The withdrawal is deliberately resumable. A permanent annotated tombstone is +# created before any channel mutation. Reruns accept only that exact tombstone; +# the ref is never updated or deleted. + +set -euo pipefail + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/release/release-lib.sh +. "$SCRIPT_DIR/release-lib.sh" + +VERSION="${1:-}" +REASON="${2:-}" +CONFIRMATION="${3:-}" + +OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" +PACKAGE_NAME="${DWS_NPM_PACKAGE:-dingtalk-workspace-cli}" +DEFAULT_BRANCH="${DWS_DEFAULT_BRANCH:-${GITHUB_EVENT_DEFAULT_BRANCH:-}}" +TOMBSTONE="withdrawn/${VERSION}" +GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" +GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" +OSS_PREFIX="${OSS_PREFIX:-dws}" +DELIVERY_VERIFIER="${DWS_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-release-workflow-delivery.sh}" +STABLE_DELIVERY_VERIFIER="${DWS_STABLE_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-delivered-stable.sh}" +GITHUB_DOWNLOAD_HELPER="${DWS_GITHUB_DOWNLOAD_HELPER:-$SCRIPT_DIR/download-github-release-assets.sh}" +ARTIFACT_VERIFY_HELPER="${DWS_ARTIFACT_VERIFY_HELPER:-$SCRIPT_DIR/verify-release-artifacts.sh}" +GITEE_SYNC_HELPER="${DWS_GITEE_SYNC_HELPER:-$SCRIPT_DIR/sync-to-gitee.sh}" + +err() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" +} + +need_env() { + local name="$1" value="$2" + [ -n "$value" ] || err "missing required environment variable: $name" +} + +sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +github_api() { + gh api -H 'X-GitHub-Api-Version: 2026-03-10' "$@" +} + +github_optional() { + local error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-optional.XXXXXX")" + set +e + github_api "$@" 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + return 4 + fi + cat "$error_file" >&2 + rm -f "$error_file" + return "$status" +} + +github_expect_404() { + local endpoint="$1" error_file + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-404.XXXXXX")" + if github_api "$endpoint" >/dev/null 2>"$error_file"; then + rm -f "$error_file" + err "GitHub resource still exists after deletion: $endpoint" + fi + if ! grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + cat "$error_file" >&2 + rm -f "$error_file" + err "could not prove GitHub resource deletion: $endpoint" + fi + rm -f "$error_file" +} + +github_delete_if_present() { + local endpoint="$1" description="$2" error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-delete.XXXXXX")" + set +e + github_api --method DELETE "$endpoint" >/dev/null 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + say "$description was already absent." + return 0 + fi + cat "$error_file" >&2 + rm -f "$error_file" + err "could not delete $description" +} + +release_json() { + github_api "repos/${OFFICIAL_REPOSITORY}/releases/tags/$1" +} + +release_field() { + local version="$1" expression="$2" + release_json "$version" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +path = sys.argv[1].split(".") +value = data +for item in path: + if item: + value = value.get(item) if isinstance(value, dict) else None +if value is True: + print("true") +elif value is False: + print("false") +elif value is not None: + print(value) +' "$expression" +} + +tag_commit() { + git rev-parse --verify "$1^{commit}" 2>/dev/null +} + +tag_object() { + git rev-parse --verify "refs/tags/$1" 2>/dev/null +} + +has_tombstone() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +expected_prerelease() { + if [ "$1" = prerelease ]; then + printf '%s\n' true + else + printf '%s\n' false + fi +} + +npm_exact_version() { + npm view "${PACKAGE_NAME}@${1#v}" version --registry=https://registry.npmjs.org 2>/dev/null +} + +npm_deprecation() { + npm view "${PACKAGE_NAME}@${1#v}" deprecated --registry=https://registry.npmjs.org 2>/dev/null +} + +release_is_eligible_rollback() { + local candidate="$1" channel="$2" state commit deprecation immutable + + release_validate_version_channel "$channel" "$candidate" >/dev/null 2>&1 || return 1 + has_tombstone "$candidate" && return 1 + [ "$(git cat-file -t "$(tag_object "$candidate")" 2>/dev/null || true)" = tag ] || return 1 + commit="$(tag_commit "$candidate" 2>/dev/null || true)" + printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$' || return 1 + + state="$( + release_json "$candidate" 2>/dev/null | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + str(bool(d.get("draft"))).lower(), + str(bool(d.get("prerelease"))).lower(), + "withdrawn" if "" + end_marker="" + original_body="$(release_field "$VERSION" body)" + block="${begin_marker} +## Withdrawn + +This release has been withdrawn from distribution. + +- Reason: ${REASON} +- Tombstone: \`${TOMBSTONE}\` + +Already-installed clients cannot be remotely downgraded. Install the current ${CHANNEL} channel or a later fixed version. +${end_marker}" + notes="$( + ORIGINAL_BODY="$original_body" WITHDRAW_BLOCK="$block" \ + BEGIN_MARKER="$begin_marker" END_MARKER="$end_marker" python3 - <<'PY' +import os + +body = os.environ["ORIGINAL_BODY"] +block = os.environ["WITHDRAW_BLOCK"] +begin = os.environ["BEGIN_MARKER"] +end = os.environ["END_MARKER"] +if begin in body and end in body and body.index(begin) < body.index(end): + prefix, remainder = body.split(begin, 1) + _, suffix = remainder.split(end, 1) + print(prefix + block + suffix, end="") +else: + separator = "\n\n" if body else "" + print(body + separator + block, end="") +PY + )" + + payload="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-release-json.XXXXXX")" + NOTES="$notes" python3 - "$VERSION" >"$payload" <<'PY' +import json +import os +import sys + +print(json.dumps({ + "name": f"[WITHDRAWN] {sys.argv[1]}", + "body": os.environ["NOTES"], +})) +PY + github_api --method PATCH \ + "repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}" \ + --input "$payload" >/dev/null + rm -f "$payload" + + state="$( + release_json "$VERSION" | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + d.get("name", ""), + "marked" if "