From fa558372d2a8bffff86d9577bd02fc17abf485ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 17:49:08 +0800 Subject: [PATCH] fix(release): add fast guarded recovery path --- .github/workflows/release.yml | 735 +++++++++++++++--- CHANGELOG.md | 6 + Makefile | 2 +- docs/automation.md | 19 + docs/releasing.md | 26 +- scripts/release/dws-release.sh | 39 + scripts/release/recover-release.sh | 153 ++++ scripts/release/release.sh | 226 ++++-- scripts/release/verify-delivered-stable.sh | 8 + .../release/verify-github-tag-authority.sh | 8 +- .../release/verify-release-governance-ci.sh | 99 +++ .../verify-release-workflow-delivery.sh | 156 ++++ test/scripts/install_script_test.go | 148 ++-- test/scripts/package_script_test.go | 196 ++++- test/scripts/release_entry_test.go | 14 + test/scripts/release_script_test.go | 242 +++++- 16 files changed, 1826 insertions(+), 251 deletions(-) create mode 100755 scripts/release/recover-release.sh create mode 100755 scripts/release/verify-release-governance-ci.sh create mode 100755 scripts/release/verify-release-workflow-delivery.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d059e927..94f0b10f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ name: Release +run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }} on: push: @@ -10,19 +11,264 @@ on: description: "Recover npm for a public immutable release without moving latest/beta" required: false type: string + governance_preflight_commit: + description: "Verify release governance for this exact default-branch commit without publishing" + required: false + type: string + governance_preflight_nonce: + description: "Unique identifier for one release governance preflight" + required: false + type: string + recover_release_version: + description: "Recover one existing failed release tag (vX.Y.Z or vX.Y.Z-beta.N)" + required: false + type: string + recover_release_tag_object: + description: "Expected 40-character annotated tag object SHA" + required: false + type: string + recover_release_commit: + description: "Expected 40-character peeled release commit SHA" + required: false + type: string + recover_failed_run_id: + description: "Failed exact-tag Release workflow run ID" + required: false + type: string + recover_release_nonce: + description: "Unique identifier for this protected recovery request" + required: false + type: string + recover_release_confirmation: + description: "Type the exact release version again to authorize recovery" + required: false + type: string permissions: contents: read -# Queue tag releases and npm repairs behind one publication lock. The local -# entrypoint also refuses to tag while a Release run is active. +# Queue governance checks, tag releases, protected recoveries, and npm repairs +# behind one publication lock. The local entrypoint also refuses to tag while +# a Release run is active. concurrency: group: dws-release-publication queue: max jobs: + dispatch-contract: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + mode: ${{ steps.mode.outputs.mode }} + steps: + - name: Select exactly one dispatch mode + id: mode + env: + REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} + GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }} + GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }} + RECOVER_VERSION: ${{ inputs.recover_release_version }} + RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }} + RECOVER_COMMIT: ${{ inputs.recover_release_commit }} + RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }} + RECOVER_NONCE: ${{ inputs.recover_release_nonce }} + RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }} + run: | + set -eu + repair=0 + governance=0 + recovery=0 + test -z "$REPAIR_NPM_VERSION" || repair=1 + if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi + if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \ + test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \ + test -n "$RECOVER_NONCE" || \ + test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi + test $((repair + governance + recovery)) -eq 1 || { + echo "workflow_dispatch must select exactly one release mode" >&2 + exit 1 + } + if test "$repair" -eq 1; then + echo "mode=repair_npm" >> "$GITHUB_OUTPUT" + elif test "$governance" -eq 1; then + test -n "$GOVERNANCE_COMMIT" && test -n "$GOVERNANCE_NONCE" || { + echo "governance preflight requires both commit and nonce" >&2 + exit 1 + } + echo "mode=governance_preflight" >> "$GITHUB_OUTPUT" + else + test -n "$RECOVER_VERSION" && test -n "$RECOVER_TAG_OBJECT" && \ + test -n "$RECOVER_COMMIT" && test -n "$RECOVER_FAILED_RUN_ID" && \ + test -n "$RECOVER_NONCE" && \ + test -n "$RECOVER_CONFIRMATION" || { + echo "release recovery requires every recovery input" >&2 + exit 1 + } + test "$RECOVER_CONFIRMATION" = "$RECOVER_VERSION" || { + echo "release recovery confirmation must equal the exact version" >&2 + exit 1 + } + printf '%s\n' "$RECOVER_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || { + echo "recover_release_commit must be a full lowercase commit SHA" >&2 + exit 1 + } + printf '%s\n' "$RECOVER_TAG_OBJECT" | grep -Eq '^[0-9a-f]{40}$' || { + echo "recover_release_tag_object must be a full lowercase tag object SHA" >&2 + exit 1 + } + printf '%s\n' "$RECOVER_FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || { + echo "recover_failed_run_id must be a positive run ID" >&2 + exit 1 + } + printf '%s\n' "$RECOVER_NONCE" | grep -Eq "^${RECOVER_COMMIT}-[0-9]+-[0-9]+$" || { + echo "recover_release_nonce must be bound to the release commit" >&2 + exit 1 + } + echo "mode=recover_release" >> "$GITHUB_OUTPUT" + fi + + authorize-recovery: + name: Approve existing tag recovery + needs: dispatch-contract + if: ${{ needs.dispatch-contract.outputs.mode == 'recover_release' }} + environment: release-recovery + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + actions: read + steps: + - name: Verify break-glass 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-recovery" }, + ); + 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-recovery must require a reviewer and prevent self-review"); + return; + } + if (response.data.deployment_branch_policy?.protected_branches !== true) { + core.setFailed("release-recovery must allow only protected branches"); + } + if (response.data.can_admins_bypass !== false) { + core.setFailed("release-recovery must not allow administrator bypass"); + } + + governance-preflight: + name: Release governance preflight + needs: dispatch-contract + if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + checks: read + steps: + - name: Validate trusted governance preflight context + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli + PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }} + REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} + run: | + set -eu + test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || { + echo "governance preflight is restricted to $EXPECTED_REPOSITORY" >&2 + exit 1 + } + test "$GITHUB_REF_NAME" = "$DEFAULT_BRANCH" || { + echo "governance preflight must run from the default branch $DEFAULT_BRANCH" >&2 + exit 1 + } + test -z "$REPAIR_NPM_VERSION" || { + echo "governance preflight cannot be combined with npm repair" >&2 + exit 1 + } + test -n "$PREFLIGHT_COMMIT" && test -n "$PREFLIGHT_NONCE" || { + echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 + exit 1 + } + case "$PREFLIGHT_COMMIT" in + *[!0-9a-f]*) + echo "governance_preflight_commit must be a lowercase commit SHA" >&2 + exit 1 + ;; + esac + test "${#PREFLIGHT_COMMIT}" -eq 40 || { + echo "governance_preflight_commit must be a full 40-character commit SHA" >&2 + exit 1 + } + test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA" || { + echo "governance preflight commit must equal the dispatched default-branch commit" >&2 + exit 1 + } + + - name: Require release governance token + env: + RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }} + run: | + test -n "$RELEASE_GOVERNANCE_TOKEN" || { + echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2 + exit 1 + } + + - name: Require successful CI Gate on the preflight commit + uses: actions/github-script@v7 + env: + PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + with: + script: | + const { owner, repo } = context.repo; + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: process.env.PREFLIGHT_COMMIT, + check_name: "CI Gate", + filter: "latest", + per_page: 100, + }); + const passed = data.check_runs.some( + (run) => run.name === "CI Gate" && run.conclusion === "success", + ); + if (!passed) { + core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`); + } + + - name: Require immutable releases governance + uses: actions/github-script@v7 + with: + # GITHUB_TOKEN cannot read repository administration settings. This + # dedicated credential needs only repository Administration: read. + github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }} + script: | + const { owner, repo } = context.repo; + const response = await github.request( + "GET /repos/{owner}/{repo}/immutable-releases", + { + owner, + repo, + headers: { "X-GitHub-Api-Version": "2026-03-10" }, + }, + ); + if (response.data.enabled !== true) { + core.setFailed("Immutable releases must be enabled before publishing"); + } + release-contract: - if: ${{ github.event_name == 'push' }} + needs: [dispatch-contract, authorize-recovery] + if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -30,17 +276,140 @@ jobs: checks: read contents: read outputs: + release_version: ${{ steps.target.outputs.release_version }} + release_commit: ${{ steps.target.outputs.release_commit }} + release_tag_object: ${{ steps.target.outputs.release_tag_object }} + is_recovery: ${{ steps.target.outputs.is_recovery }} + failed_run_id: ${{ steps.target.outputs.failed_run_id }} channel: ${{ steps.contract.outputs.channel }} previous_stable: ${{ steps.contract.outputs.previous_stable }} previous_stable_commit: ${{ steps.contract.outputs.previous_stable_commit }} from_beta: ${{ steps.contract.outputs.from_beta }} from_beta_commit: ${{ steps.contract.outputs.from_beta_commit }} steps: + - name: Resolve and verify exact release target + id: target + uses: actions/github-script@v7 + env: + RECOVER_VERSION: ${{ inputs.recover_release_version }} + RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }} + RECOVER_COMMIT: ${{ inputs.recover_release_commit }} + RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }} + with: + script: | + const { owner, repo } = context.repo; + const isRecovery = context.eventName === "workflow_dispatch"; + let version = context.ref.replace("refs/tags/", ""); + let commit = context.sha; + let expectedTagObject = ""; + let failedRunId = ""; + if (isRecovery) { + if ( + context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" || + context.ref !== `refs/heads/${context.payload.repository.default_branch}` + ) { + 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; + 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}`); + 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"); + return; + } + if (!/^[1-9]\d*$/.test(failedRunId)) { + core.setFailed("recover_failed_run_id must be a positive workflow run ID"); + return; + } + } + + const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }); + if (ref.data.object.type !== "tag") { + core.setFailed(`${version} is not an annotated tag`); + return; + } + const tagObject = ref.data.object.sha; + const tag = await github.rest.git.getTag({ owner, repo, tag_sha: tagObject }); + if (tag.data.tag !== version || tag.data.object.type !== "commit") { + core.setFailed(`${version} does not resolve to one annotated commit tag`); + return; + } + if (tag.data.object.sha !== commit) { + core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`); + return; + } + if (isRecovery && tagObject !== expectedTagObject) { + core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`); + return; + } + + if (isRecovery) { + const defaultBranch = context.payload.repository.default_branch; + const branch = await github.rest.repos.getBranch({ owner, repo, branch: defaultBranch }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${commit}...${branch.data.commit.sha}`, + }); + if (!["ahead", "identical"].includes(comparison.data.status)) { + core.setFailed(`${version} commit is not contained in ${defaultBranch}`); + return; + } + const failedRun = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: Number(failedRunId), + }); + const run = failedRun.data; + if ( + run.repository.full_name !== `${owner}/${repo}` || + run.path !== ".github/workflows/release.yml" || + run.event !== "push" || + run.status !== "completed" || + !["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) || + run.head_branch !== version || + run.head_sha !== commit + ) { + core.setFailed(`run ${failedRunId} is not the failed exact-tag Release run for ${version}`); + return; + } + try { + const release = await github.rest.repos.getReleaseByTag({ owner, repo, tag: version }); + if (!release.data.draft) { + core.setFailed(`${version} already has a public release; use a channel repair instead`); + return; + } + } catch (error) { + if (error.status !== 404) throw error; + } + } + + core.setOutput("release_version", version); + core.setOutput("release_commit", commit); + core.setOutput("release_tag_object", tagObject); + core.setOutput("is_recovery", isRecovery ? "true" : "false"); + core.setOutput("failed_run_id", failedRunId); + - name: Check out repository uses: actions/checkout@v4 with: + ref: ${{ steps.target.outputs.release_commit }} + persist-credentials: false fetch-depth: 0 + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tmp/trusted-release-tooling + persist-credentials: false + - name: Fetch release branch and official tags run: | git fetch --force origin \ @@ -61,13 +430,15 @@ jobs: - name: Validate release contract id: contract + env: + RELEASE_VERSION: ${{ steps.target.outputs.release_version }} run: | set -eu . ./scripts/release/release-lib.sh - channel="$(release_channel_for_version "$GITHUB_REF_NAME")" + channel="$(release_channel_for_version "$RELEASE_VERSION")" ./scripts/release/release-contract.sh \ --channel "$channel" \ - --version "$GITHUB_REF_NAME" \ + --version "$RELEASE_VERSION" \ --context ci \ --remote origin \ --branch main \ @@ -77,17 +448,22 @@ jobs: - name: Verify remote annotated tag authority env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + RELEASE_VERSION: ${{ steps.target.outputs.release_version }} + RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ steps.target.outputs.release_tag_object }} + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Require successful CI Gate on the sealed commit uses: actions/github-script@v7 + env: + RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }} with: script: | const { owner, repo } = context.repo; const { data } = await github.rest.checks.listForRef({ owner, repo, - ref: context.sha, + ref: process.env.RELEASE_COMMIT, check_name: "CI Gate", filter: "latest", per_page: 100, @@ -96,7 +472,7 @@ jobs: (run) => run.name === "CI Gate" && run.conclusion === "success", ); if (!passed) { - core.setFailed(`CI Gate has not succeeded for ${context.sha}`); + core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`); } - name: Require delivered previous stable baseline @@ -104,11 +480,23 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }} PREVIOUS_STABLE_COMMIT: ${{ steps.contract.outputs.previous_stable_commit }} - run: ./scripts/release/verify-delivered-stable.sh "$PREVIOUS_STABLE" "$PREVIOUS_STABLE_COMMIT" + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-delivered-stable.sh "$PREVIOUS_STABLE" "$PREVIOUS_STABLE_COMMIT" + + - name: Require release governance token + env: + RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }} + run: | + test -n "$RELEASE_GOVERNANCE_TOKEN" || { + echo "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required" >&2 + exit 1 + } - name: Require immutable releases governance uses: actions/github-script@v7 with: + # GITHUB_TOKEN cannot read repository administration settings. This + # dedicated credential needs only repository Administration: read. + github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }} script: | const { owner, repo } = context.repo; const response = await github.request( @@ -165,24 +553,16 @@ jobs: ); return; } - const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: process.env.FROM_BETA, - event: "push", - status: "completed", - per_page: 100, - }); - const passed = runs.some( - (run) => - run.head_sha === process.env.FROM_BETA_COMMIT && - run.head_branch === process.env.FROM_BETA && - run.conclusion === "success", - ); - if (!passed) { - core.setFailed(`Release workflow did not succeed for ${process.env.FROM_BETA}`); - } + + - name: Verify beta Release workflow delivery + if: ${{ steps.contract.outputs.channel == 'stable' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FROM_BETA: ${{ steps.contract.outputs.from_beta }} + FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }} + run: | + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-release-workflow-delivery.sh" \ + "$FROM_BETA" "$FROM_BETA_COMMIT" - name: Set up Go uses: actions/setup-go@v5 @@ -209,18 +589,31 @@ jobs: release: name: Build signed release artifacts - if: ${{ github.event_name == 'push' }} + if: ${{ needs.release-contract.result == 'success' }} needs: [release-contract] runs-on: ubuntu-latest timeout-minutes: 60 permissions: contents: read + env: + RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }} steps: - name: Check out repository uses: actions/checkout@v4 with: + ref: ${{ needs.release-contract.outputs.release_commit }} + persist-credentials: false fetch-depth: 0 + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tmp/trusted-release-tooling + persist-credentials: false + - name: Fetch release branch and official tags run: | git fetch --force origin \ @@ -229,11 +622,16 @@ jobs: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ '+refs/tags/v*:refs/tags/v*' + - name: Verify sealed tag before executing release source + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + - name: Revalidate contract and render release notes run: | ./scripts/release/release-contract.sh \ --channel "${{ needs.release-contract.outputs.channel }}" \ - --version "$GITHUB_REF_NAME" \ + --version "$RELEASE_VERSION" \ --context ci \ --remote origin \ --branch main \ @@ -300,6 +698,15 @@ jobs: echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV" echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV" + - name: Require a clean sealed source before GoReleaser + run: | + dirty="$(git status --porcelain --untracked-files=all)" + test -z "$dirty" || { + echo "sealed release source became dirty before GoReleaser:" >&2 + printf '%s\n' "$dirty" >&2 + exit 1 + } + - name: Build release artifacts without publishing uses: goreleaser/goreleaser-action@v6 with: @@ -307,13 +714,14 @@ jobs: args: release --clean --skip=publish --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_CURRENT_TAG: ${{ github.ref_name }} + GORELEASER_CURRENT_TAG: ${{ needs.release-contract.outputs.release_version }} + GORELEASER_PREVIOUS_TAG: ${{ needs.release-contract.outputs.previous_stable }} - name: Post-release packaging run: ./scripts/release/post-goreleaser.sh env: - DWS_PACKAGE_VERSION: ${{ github.ref_name }} - DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }} + DWS_PACKAGE_VERSION: ${{ needs.release-contract.outputs.release_version }} + DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.release-contract.outputs.release_version }} DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }} - name: Remove Apple Developer ID certificate @@ -323,15 +731,15 @@ jobs: rm -f "$RUNNER_TEMP/dws-developer-id-password" - name: Verify final release artifacts - run: ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release - run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" - name: Reverify remote tag before sealing GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Preserve finalized distribution files uses: actions/upload-artifact@v4 @@ -343,8 +751,8 @@ jobs: verify-darwin-signatures: name: Verify Apple Developer ID signatures - if: ${{ github.event_name == 'push' }} - needs: release + if: ${{ needs.release.result == 'success' }} + needs: [release-contract, release] runs-on: macos-latest timeout-minutes: 10 permissions: @@ -371,18 +779,32 @@ jobs: publish-release: name: Publish immutable GitHub Release - if: ${{ github.event_name == 'push' }} + if: ${{ needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }} needs: [release-contract, release, verify-darwin-signatures] runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write + env: + RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }} + IS_RECOVERY: ${{ needs.release-contract.outputs.is_recovery }} steps: - name: Check out sealed release source uses: actions/checkout@v4 with: + ref: ${{ needs.release-contract.outputs.release_commit }} + persist-credentials: false fetch-depth: 0 + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tmp/trusted-release-tooling + persist-credentials: false + - name: Fetch release branch and official tags run: | git fetch --force origin \ @@ -391,16 +813,28 @@ jobs: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ '+refs/tags/v*:refs/tags/v*' + - name: Verify sealed tag before executing publication source + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + - name: Revalidate contract and render release notes run: | ./scripts/release/release-contract.sh \ --channel "${{ needs.release-contract.outputs.channel }}" \ - --version "$GITHUB_REF_NAME" \ + --version "$RELEASE_VERSION" \ --context ci \ --remote origin \ --branch main \ --notes-output "$RUNNER_TEMP/release-notes.md" + - name: Bind recovery publication to this workflow run + if: ${{ needs.release-contract.outputs.is_recovery == 'true' }} + run: | + printf '\n\n' \ + "$GITHUB_RUN_ID" "$RELEASE_TAG_OBJECT" "$RELEASE_COMMIT" \ + >> "$RUNNER_TEMP/release-notes.md" + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -417,15 +851,15 @@ jobs: path: dist - name: Verify finalized release artifacts before publication - run: ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + run: ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" - name: Verify npm package before sealing GitHub Release - run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" - name: Reverify remote tag before sealing GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Publish or reuse immutable GitHub Release env: @@ -440,9 +874,9 @@ jobs: prerelease_flag="--prerelease" fi - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - is_draft="$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" - is_prerelease="$(gh release view "$GITHUB_REF_NAME" --json isPrerelease --jq .isPrerelease)" + if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then + is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)" + is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)" if [ "$is_prerelease" != "$expected_prerelease" ]; then echo "Existing release channel does not match $RELEASE_CHANNEL." >&2 exit 1 @@ -450,66 +884,80 @@ jobs: if [ "$is_draft" = "false" ]; then is_immutable="$( gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ --jq .immutable )" if [ "$is_immutable" != "true" ]; then echo "Published release is mutable; refusing to reuse its assets." >&2 exit 1 fi - remote_body="$(gh release view "$GITHUB_REF_NAME" --json body --jq .body)" + remote_body="$(gh release view "$RELEASE_VERSION" --json body --jq .body)" local_body="$(cat "$RUNNER_TEMP/release-notes.md")" if [ "$remote_body" != "$local_body" ]; then echo "Published release notes differ from the sealed CHANGELOG." >&2 exit 1 fi + if [ "$IS_RECOVERY" = "true" ]; then + recovery_marker="" + case "$remote_body" in + *"$recovery_marker"*) ;; + *) + echo "Public release is not bound to this exact recovery run." >&2 + exit 1 + ;; + esac + fi published_dir="$RUNNER_TEMP/published-release" mkdir -p "$published_dir" - gh release download "$GITHUB_REF_NAME" \ + gh release download "$RELEASE_VERSION" \ --dir "$published_dir" \ --pattern 'dws-*' \ --pattern 'checksums.txt' \ --clobber DWS_PACKAGE_DIST_DIR="$published_dir" \ - ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do + if [ "$IS_RECOVERY" = "true" ] && ! cmp -s "$asset" "dist/$(basename "$asset")"; then + echo "Public recovery asset differs from this run's sealed artifact: $(basename "$asset")" >&2 + exit 1 + fi cp "$asset" dist/ done DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ - ./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME" + ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" echo "Published GitHub Release is immutable and verified; reusing its exact assets." exit 0 fi - gh release edit "$GITHUB_REF_NAME" \ - --title "$GITHUB_REF_NAME" \ + gh release edit "$RELEASE_VERSION" \ + --title "$RELEASE_VERSION" \ --notes-file "$RUNNER_TEMP/release-notes.md" else # shellcheck disable=SC2086 - gh release create "$GITHUB_REF_NAME" \ + gh release create "$RELEASE_VERSION" \ --verify-tag \ --draft \ - --title "$GITHUB_REF_NAME" \ + --title "$RELEASE_VERSION" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ $prerelease_flag fi - gh release upload "$GITHUB_REF_NAME" \ + gh release upload "$RELEASE_VERSION" \ dist/dws-*.tar.gz \ dist/dws-windows-*.zip \ dist/dws-skills.zip \ dist/checksums.txt \ --clobber - ./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME" + ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" sealed_upload="$RUNNER_TEMP/sealed-upload" rm -rf "$sealed_upload" mkdir -p "$sealed_upload" - gh release download "$GITHUB_REF_NAME" \ + gh release download "$RELEASE_VERSION" \ --dir "$sealed_upload" \ --pattern 'dws-*' \ --pattern 'checksums.txt' \ --clobber DWS_PACKAGE_DIST_DIR="$sealed_upload" \ - ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do local_asset="dist/$(basename "$remote_asset")" cmp -s "$local_asset" "$remote_asset" || { @@ -517,7 +965,7 @@ jobs: exit 1 } done - gh release edit "$GITHUB_REF_NAME" --draft=false + gh release edit "$RELEASE_VERSION" --draft=false - name: Require immutable published GitHub Release env: @@ -526,15 +974,16 @@ jobs: set -eu immutable="$( gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ --jq .immutable )" test "$immutable" = true || { echo "GitHub Release is not immutable; refusing downstream publication." >&2 exit 1 } - ./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME" - ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ + "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Open stable Homebrew formula PR if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }} @@ -543,9 +992,9 @@ jobs: DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} DWS_TAP_PR_REPOSITORY: ${{ github.repository }} - DWS_TAP_PR_BRANCH: "automation/homebrew-${{ github.ref_name }}" - DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}" - DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}" + DWS_TAP_PR_BRANCH: "automation/homebrew-${{ needs.release-contract.outputs.release_version }}" + DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ needs.release-contract.outputs.release_version }}" + DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }}" - name: Open beta Homebrew formula PR if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }} @@ -556,27 +1005,40 @@ jobs: DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} DWS_TAP_PR_REPOSITORY: ${{ github.repository }} - DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ github.ref_name }}" - DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}" - DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}" + DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}" + DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ needs.release-contract.outputs.release_version }}" + DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }}" - name: Reverify exact immutable npm package - run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" publish-channels: name: Publish npm and mirrors - if: ${{ github.event_name == 'push' }} + if: ${{ needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }} needs: [release-contract, publish-release] runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read + env: + RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }} steps: - name: Check out sealed release source uses: actions/checkout@v4 with: + ref: ${{ needs.release-contract.outputs.release_commit }} + persist-credentials: false fetch-depth: 0 + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tmp/trusted-release-tooling + persist-credentials: false + - name: Fetch release branch and official tags run: | git fetch --force origin \ @@ -585,11 +1047,16 @@ jobs: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ '+refs/tags/v*:refs/tags/v*' + - name: Verify sealed tag before executing channel source + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: $GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + - name: Revalidate sealed contract run: | ./scripts/release/release-contract.sh \ --channel "${{ needs.release-contract.outputs.channel }}" \ - --version "$GITHUB_REF_NAME" \ + --version "$RELEASE_VERSION" \ --context ci \ --remote origin \ --branch main @@ -610,31 +1077,32 @@ jobs: set -eu release_state="$( gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \ + "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ --jq '[.draft, .immutable] | @tsv' )" test "$release_state" = "$(printf 'false\ttrue')" || { echo "GitHub Release must be public and immutable before channel publication." >&2 exit 1 } - ./scripts/release/verify-github-release-assets.sh "$GITHUB_REF_NAME" - ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" + ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ + "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" mkdir -p dist - gh release download "$GITHUB_REF_NAME" \ + gh release download "$RELEASE_VERSION" \ --dir dist \ --pattern 'dws-*' \ --pattern 'checksums.txt' \ --clobber - ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ - ./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME" + ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" - name: Verify immutable npm package without publication credentials run: | ./scripts/release/verify-package-managers.sh \ --npm-only \ - --expected-version "$GITHUB_REF_NAME" + --expected-version "$RELEASE_VERSION" - name: Inspect npm channel state id: npm-state @@ -644,7 +1112,7 @@ jobs: run: | set -eu . ./scripts/release/release-lib.sh - semver="${GITHUB_REF_NAME#v}" + semver="${RELEASE_VERSION#v}" npm_tag=latest if [ "$RELEASE_CHANNEL" = "prerelease" ]; then npm_tag=beta; fi current="$(npm view dingtalk-workspace-cli "dist-tags.$npm_tag")" @@ -676,12 +1144,12 @@ jobs: advance_channel=false if [ "$current" = "$semver" ]; then : - elif [ -n "$current" ] && release_version_is_greater "v$current" "$GITHUB_REF_NAME"; then + elif [ -n "$current" ] && release_version_is_greater "v$current" "$RELEASE_VERSION"; then echo "npm $npm_tag already points to newer v$current; leaving it unchanged." - elif [ -z "$current" ] || release_version_is_greater "$GITHUB_REF_NAME" "v$current"; then + elif [ -z "$current" ] || release_version_is_greater "$RELEASE_VERSION" "v$current"; then advance_channel=true else - echo "Cannot reconcile npm $npm_tag=$current with $GITHUB_REF_NAME" >&2 + echo "Cannot reconcile npm $npm_tag=$current with $RELEASE_VERSION" >&2 exit 1 fi { @@ -692,8 +1160,8 @@ jobs: } >> "$GITHUB_OUTPUT" exit 0 fi - if [ -n "$current" ] && ! release_version_is_greater "$GITHUB_REF_NAME" "v$current"; then - echo "$GITHUB_REF_NAME would move npm $npm_tag backwards from v$current" >&2 + if [ -n "$current" ] && ! release_version_is_greater "$RELEASE_VERSION" "v$current"; then + echo "$RELEASE_VERSION would move npm $npm_tag backwards from v$current" >&2 exit 1 fi { @@ -764,7 +1232,7 @@ jobs: - name: Sync release artifacts to China OSS mirror run: ./scripts/release/sync-to-oss.sh env: - VERSION: ${{ github.ref_name }} + VERSION: ${{ needs.release-contract.outputs.release_version }} DWS_RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} @@ -775,16 +1243,40 @@ jobs: mirror-gitee-release: name: Mirror immutable release to Gitee - if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }} - needs: [release, publish-channels] + if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }} + needs: [release-contract, release, publish-channels] runs-on: ubuntu-latest timeout-minutes: 120 permissions: contents: read + env: + RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }} + RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }} steps: - name: Check out sealed release source uses: actions/checkout@v4 timeout-minutes: 5 + with: + ref: ${{ needs.release-contract.outputs.release_commit }} + persist-credentials: false + + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tmp/trusted-release-tooling + persist-credentials: false + + - name: Fetch and verify sealed release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ + "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Restore finalized distribution files uses: actions/download-artifact@v4 @@ -797,14 +1289,15 @@ jobs: timeout-minutes: 100 run: ./scripts/release/sync-to-gitee.sh env: - VERSION: ${{ github.ref_name }} + VERSION: ${{ needs.release-contract.outputs.release_version }} GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} GITEE_USER: ${{ secrets.GITEE_USER }} GITEE_REPO: ${{ secrets.GITEE_REPO }} DWS_REQUIRE_GITEE: "1" repair-npm: - if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }} + needs: dispatch-contract + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_npm' && github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -814,8 +1307,9 @@ jobs: - name: Check out trusted release tooling uses: actions/checkout@v4 with: - ref: ${{ github.event.repository.default_branch }} + ref: ${{ github.sha }} path: tooling + persist-credentials: false - name: Validate repair version id: version @@ -929,13 +1423,71 @@ jobs: jobs.some( (job) => job.name === "Publish immutable GitHub Release" && - job.conclusion === "success", + 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; @@ -947,6 +1499,7 @@ jobs: with: ref: ${{ steps.authority.outputs.commit_sha }} path: release-source + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index b256aa6a..9f6b61c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed - **Personal event structured output is now flat** — `event consume` projects NDJSON/JSON/pretty/compact output into event-specific top-level DTOs, so consumers read fields such as `content`, `sender`, and `conversation_id` directly instead of parsing `.data | fromjson`. This is a breaking change for scripts using the former transport envelope; the original server payload remains available through `-f raw`, while `--debug-raw-events` preserves the full diagnostic envelope. +- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated. +- **Protected existing-tag recovery** — `dws-release recover ` can resume a failed, unpublished annotated tag through the normal contract, build, Developer ID signing, immutable GitHub Release, Homebrew, npm, and OSS jobs. Recovery requires the exact tag object, peeled commit, failed tag-push run, typed version confirmation, and the protected `release-recovery` environment; successful runs are accepted as future beta/stable delivery evidence. + +### Fixed + +- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget. ## [1.0.53-beta.3] - 2026-07-17 diff --git a/Makefile b/Makefile index db5b9810..4c647575 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ rebuild: @./scripts/dev/build.sh test: - @./test/scripts/run_all_tests.sh + @./test/scripts/run_all_tests.sh --timeout 5m lint: @./scripts/dev/lint.sh diff --git a/docs/automation.md b/docs/automation.md index 868cb5ed..c8bed099 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -80,6 +80,25 @@ built-in `GITHUB_TOKEN` is insufficient because organization policy prevents Actions from creating pull requests, and its generated PR events may require separate workflow approval. +## Release Governance and Recovery + +Store `RELEASE_GOVERNANCE_TOKEN` as a dedicated Actions secret with only +repository `Administration: read`. The immutable-releases REST endpoint is an +administration setting and cannot be read by the workflow's built-in +`GITHUB_TOKEN`. Both the default-branch governance preflight and the tag +contract use this same credential so a missing or expired identity is detected +before an irreversible tag is created. + +Create a protected `release-recovery` environment limited to protected +branches, with a required reviewer, self-review disabled, and administrator +bypass disabled. The workflow reads the environment through the GitHub API and +fails closed unless the required-reviewer, prevent-self-review, and protected- +branch rules are present. +Recovery is restricted to an existing annotated tag whose exact tag object, +commit, and failed tag-push run all match; it then reuses the normal release +jobs. Do not put publication secrets in temporary branches or create ad-hoc +recovery workflows. + ## Handoff Checklist Before handoff, include: diff --git a/docs/releasing.md b/docs/releasing.md index 220fcc07..31905574 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -45,13 +45,13 @@ dws-release v1.2.3-beta.1 dws-release v1.2.3-beta.1 ``` -预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布: +预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`: ```bash dws-release v1.2.3-beta.1 --publish ``` -命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。 +若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。 ## 正式发布 @@ -99,15 +99,35 @@ npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_versio OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。 +## 既有 tag 的紧急恢复 + +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、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。 +- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。 +- `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 当成已接入的安装通道。 Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。 ## 平台治理前置 -仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则: +仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则: - `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。 - 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。 +- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。 +- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。 immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/release/dws-release.sh b/scripts/release/dws-release.sh index d030d975..a7f5ed36 100755 --- a/scripts/release/dws-release.sh +++ b/scripts/release/dws-release.sh @@ -16,6 +16,7 @@ usage() { cat >&2 <<'EOF' usage: dws-release [version] [options] dws-release config [--remote ] + dws-release recover [--failed-run ] [--remote ] With no arguments, starts an interactive release guide. For a version that has no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the @@ -34,6 +35,7 @@ Examples: dws-release v1.2.3-beta.1 --publish dws-release v1.2.3 --from-beta v1.2.3-beta.1 dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish + dws-release recover v1.2.3-beta.1 EOF } @@ -191,9 +193,46 @@ configure_remote() { exit 0 } +recover_release() { + shift + recovery_version="${1:-}" + [ -n "$recovery_version" ] || die_usage 'recover requires a release version' + case "$recovery_version" in -h|--help) usage; exit 0 ;; esac + shift + recovery_failed_run="" + while [ "$#" -gt 0 ]; do + case "$1" in + --failed-run) + [ "$#" -ge 2 ] || die_usage '--failed-run requires a workflow run ID' + recovery_failed_run="$2" + shift 2 + ;; + --remote) + [ "$#" -ge 2 ] || die_usage '--remote requires a value' + REMOTE="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) die_usage "unknown recovery argument: $1" ;; + esac + done + + cd "$ROOT" + require_remote + sync_main_if_safe + set -- "$recovery_version" --remote "$REMOTE" + if [ -n "$recovery_failed_run" ]; then + set -- "$@" --failed-run "$recovery_failed_run" + fi + exec "$SCRIPT_DIR/recover-release.sh" "$@" +} + if [ "${1:-}" = "config" ]; then configure_remote "$@" fi +if [ "${1:-}" = "recover" ]; then + recover_release "$@" +fi [ "$#" -gt 0 ] || WIZARD=1 while [ "$#" -gt 0 ]; do diff --git a/scripts/release/recover-release.sh b/scripts/release/recover-release.sh new file mode 100755 index 00000000..aabaa099 --- /dev/null +++ b/scripts/release/recover-release.sh @@ -0,0 +1,153 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +VERSION="${1:-}" +REMOTE="" +FAILED_RUN_ID="" +EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli" + +usage() { + cat >&2 <<'EOF' +usage: recover-release.sh --remote [--failed-run ] + +Recovers one failed existing release tag through the protected default-branch +Release workflow. It never creates, moves, or deletes a tag. +EOF +} + +[ -n "$VERSION" ] || { usage; exit 2; } +shift +while [ "$#" -gt 0 ]; do + case "$1" in + --remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;; + --failed-run) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ID="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +channel="$(release_channel_for_version "$VERSION")" || exit 2 +release_validate_version_channel "$channel" "$VERSION" +[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; } +if [ -n "$FAILED_RUN_ID" ]; then + printf '%s\n' "$FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid failed Release run ID: %s\n' "$FAILED_RUN_ID" >&2 + exit 2 + } +fi +command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 1; } + +cd "$ROOT" +[ "$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" = "main" ] || { + printf '%s\n' 'release recovery must run from the main worktree' >&2 + exit 1 +} +[ -z "$(git status --porcelain --untracked-files=all)" ] || { + printf '%s\n' 'release recovery requires a clean main worktree' >&2 + exit 1 +} + +push_url="$(git remote get-url --push "$REMOTE" 2>/dev/null)" || { + printf 'unknown release remote: %s\n' "$REMOTE" >&2 + exit 1 +} +case "$push_url" in + https://github.com/*) repository_path="${push_url#https://github.com/}" ;; + git@github.com:*) repository_path="${push_url#git@github.com:}" ;; + ssh://git@github.com/*) repository_path="${push_url#ssh://git@github.com/}" ;; + *) printf 'release recovery requires the official GitHub remote, got: %s\n' "$push_url" >&2; exit 1 ;; +esac +repository_path="${repository_path%/}" +repository_path="${repository_path%.git}" +[ "$repository_path" = "$EXPECTED_REPOSITORY" ] || { + printf 'release recovery is restricted to %s, got %s\n' "$EXPECTED_REPOSITORY" "$repository_path" >&2 + exit 1 +} + +printf '==> Refreshing main and exact recovery tag %s\n' "$VERSION" +git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main" +recovery_ref="refs/dws-release-recovery/$VERSION" +cleanup() { git update-ref -d "$recovery_ref" >/dev/null 2>&1 || true; } +trap cleanup EXIT HUP INT TERM +git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref" +[ "$(git cat-file -t "$recovery_ref")" = "tag" ] || { + printf '%s must be an annotated tag\n' "$VERSION" >&2 + exit 1 +} +tag_object="$(git rev-parse "$recovery_ref")" +commit="$(git rev-parse "$recovery_ref^{commit}")" +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 + failed_runs="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?branch=$VERSION&event=push&status=completed&per_page=100" \ + --jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\" and (.conclusion as \\$c | [\"failure\", \"cancelled\", \"timed_out\", \"startup_failure\", \"stale\"] | index(\\$c))) | .id" + )" || { + printf 'could not query failed Release runs for %s\n' "$VERSION" >&2 + exit 1 + } + FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')" + [ -n "$FAILED_RUN_ID" ] || { + printf 'no failed exact-tag Release run found for %s at %s\n' "$VERSION" "$commit" >&2 + exit 1 + } +fi + +printf 'Recovery target:\n' +printf ' version: %s\n' "$VERSION" +printf ' tag object: %s\n' "$tag_object" +printf ' commit: %s\n' "$commit" +printf ' failed run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" +[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; } +printf 'Type %s to request protected recovery: ' "$VERSION" +IFS= read -r confirmation +[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; } + +nonce="${commit}-$(date +%s)-$$" +workflow_sha="$(git rev-parse "refs/remotes/$REMOTE/main")" +gh workflow run release.yml \ + --repo "$EXPECTED_REPOSITORY" \ + --ref main \ + -f "recover_release_version=$VERSION" \ + -f "recover_release_tag_object=$tag_object" \ + -f "recover_release_commit=$commit" \ + -f "recover_failed_run_id=$FAILED_RUN_ID" \ + -f "recover_release_nonce=$nonce" \ + -f "recover_release_confirmation=$VERSION" + +expected_title="Release recovery $VERSION at $commit $nonce" +started_at="$(date +%s)" +run_id="" +while :; do + run_id="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?event=workflow_dispatch&branch=main&per_page=100" \ + --jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$workflow_sha\") | .id" \ + | sed -n '1p' + )" || exit 1 + [ -z "$run_id" ] || break + now="$(date +%s)" + [ $((now - started_at)) -lt 60 ] || { + printf 'recovery was dispatched but its run could not be located: %s\n' "$expected_title" >&2 + exit 1 + } + sleep 2 +done + +printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id" +printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.' +if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then + gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true + exit 1 +fi +printf 'Release recovery completed: %s\n' "$VERSION" diff --git a/scripts/release/release.sh b/scripts/release/release.sh index 3285bda9..b664f98b 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -120,21 +120,6 @@ require_github_publication_authority() { return 1 } - immutable_enabled="$( - gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$github_repository/immutable-releases" \ - --jq '.enabled' - )" || { - printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2 - return 1 - } - [ "$immutable_enabled" = "true" ] || { - printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2 - return 1 - } - active_runs="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \ @@ -195,18 +180,82 @@ require_github_publication_authority() { } done beta_commit="$(git rev-parse "$FROM_BETA^{commit}")" - beta_runs="$( - gh api -H 'Accept: application/vnd.github+json' \ - "repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \ - --jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv' - )" || return 1 - printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" ' - $1 == sha && $2 == tag && $3 == "success" { found = 1 } - END { exit(found ? 0 : 1) } - ' || { - printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2 + DWS_RELEASE_OFFICIAL_REPOSITORY="$github_repository" \ + "$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$FROM_BETA" "$beta_commit" || return 1 + fi +} + +require_release_governance_ci() { + governance_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" + if [ -z "$governance_repository" ]; then + [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { + printf 'release governance preflight requires a github.com publication remote\n' >&2 return 1 } + printf 'warning: Release governance CI preflight explicitly disabled for non-GitHub test remote\n' >&2 + return 0 + fi + "$SCRIPT_DIR/verify-release-governance-ci.sh" \ + "$governance_repository" \ + "$(git rev-parse HEAD)" +} + +preflight_proof_path() { + proof_dir="$(git rev-parse --git-path dws-release-preflight)" + printf '%s/%s.proof\n' "$proof_dir" "$VERSION" +} + +preflight_proof_is_reusable() { + proof_file="$1" + [ -f "$proof_file" ] || return 1 + [ "$(wc -l < "$proof_file" | tr -d '[:space:]')" -eq 9 ] || return 1 + [ "$(sed -n 's/^format=//p' "$proof_file")" = "1" ] || return 1 + [ "$(sed -n 's/^channel=//p' "$proof_file")" = "$CHANNEL" ] || return 1 + [ "$(sed -n 's/^version=//p' "$proof_file")" = "$VERSION" ] || return 1 + [ "$(sed -n 's/^commit=//p' "$proof_file")" = "$(git rev-parse HEAD)" ] || return 1 + [ "$(sed -n 's/^repository=//p' "$proof_file")" = "$push_identity" ] || return 1 + [ "$(sed -n 's/^branch=//p' "$proof_file")" = "$BRANCH" ] || return 1 + [ "$(sed -n 's/^from_beta=//p' "$proof_file")" = "$FROM_BETA" ] || return 1 + [ "$(sed -n 's/^previous_stable=//p' "$proof_file")" = "$previous_stable" ] || return 1 + + proof_created_at="$(sed -n 's/^created_at=//p' "$proof_file")" + printf '%s\n' "$proof_created_at" | grep -Eq '^[0-9]+$' || return 1 + proof_now="$(date +%s)" + [ "$proof_created_at" -le "$proof_now" ] || return 1 + [ $((proof_now - proof_created_at)) -le 21600 ] || return 1 +} + +write_preflight_proof() { + proof_file="$1" + proof_dir="$(dirname "$proof_file")" + umask 077 + mkdir -p "$proof_dir" + proof_tmp="${proof_file}.tmp.$$" + { + printf 'format=1\n' + printf 'channel=%s\n' "$CHANNEL" + printf 'version=%s\n' "$VERSION" + printf 'commit=%s\n' "$(git rev-parse HEAD)" + printf 'repository=%s\n' "$push_identity" + printf 'branch=%s\n' "$BRANCH" + printf 'from_beta=%s\n' "$FROM_BETA" + printf 'previous_stable=%s\n' "$previous_stable" + printf 'created_at=%s\n' "$(date +%s)" + } > "$proof_tmp" + mv "$proof_tmp" "$proof_file" +} + +build_policy_binary() { + policy_tmp="$ROOT/tmp/go-build" + mkdir -p "$policy_tmp" + GOTMPDIR="$policy_tmp" make build + if [ "$(uname -s)" = "Darwin" ] && [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" != "1" ]; then + command -v codesign >/dev/null 2>&1 || { + printf '%s\n' 'codesign is required to prepare the macOS policy binary' >&2 + return 1 + } + codesign --force --sign - "$ROOT/dws" + codesign --verify --strict "$ROOT/dws" fi } @@ -251,37 +300,45 @@ previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)" printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}" require_delivered_previous_stable -if [ "$PUBLISH" -eq 1 ]; then - printf '==> Verifying GitHub publication authority before local gates\n' - require_github_publication_authority +printf '==> Verifying GitHub publication authority before local gates\n' +require_github_publication_authority + +proof_path="$(preflight_proof_path)" +ran_expensive_gates=0 +if [ "$PUBLISH" -eq 1 ] && preflight_proof_is_reusable "$proof_path"; then + printf '==> Reusing exact preflight proof for %s at %s\n' "$VERSION" "$(git rev-parse HEAD)" +else + rm -f "$proof_path" + printf '==> Running repository test, build, and policy gates\n' + make test + # Installer tests intentionally exercise source builds. Rebuild the release + # binary before policy so policy never depends on a pre-existing ./dws. + build_policy_binary + make policy + + if [ -n "$previous_stable" ]; then + printf '==> Comparing command tree with %s\n' "$previous_stable" + "$ROOT/scripts/policy/check-command-compatibility.sh" \ + --base-ref "$REMOTE/$BRANCH" \ + --stable-ref "$previous_stable" + fi + + printf '==> Building local release artifacts for %s\n' "$VERSION" + make package VERSION="$VERSION" + + printf '==> Verifying release artifact set and checksums\n' + "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION" + + printf '==> Verifying npm package installation\n' + "$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION" + if command -v brew >/dev/null 2>&1; then + printf '==> Verifying Homebrew package installation\n' + "$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION" + fi + ran_expensive_gates=1 fi -printf '==> Running repository test and policy gates\n' -make test -make policy - -if [ -n "$previous_stable" ]; then - printf '==> Comparing command tree with %s\n' "$previous_stable" - "$ROOT/scripts/policy/check-command-compatibility.sh" \ - --base-ref "$REMOTE/$BRANCH" \ - --stable-ref "$previous_stable" -fi - -printf '==> Building local release artifacts for %s\n' "$VERSION" -make package VERSION="$VERSION" - -printf '==> Verifying release artifact set and checksums\n' -"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION" - -printf '==> Verifying npm package installation\n' -"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION" -if command -v brew >/dev/null 2>&1; then - printf '==> Verifying Homebrew package installation\n' - "$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION" -fi - -# Collect human confirmation before the final authority refresh. Nothing may -# block between that refresh and tag creation. +# Collect human confirmation before the final remote validations. if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then if [ ! -t 0 ]; then printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2 @@ -292,34 +349,65 @@ if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then [ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; } fi -# Re-check after every local gate so tag creation cannot race with a modified -# source tree. dist/ is ignored and therefore does not make the tree dirty. +printf '==> Verifying the Release workflow publication identity\n' +require_release_governance_ci + +# Refresh after every local gate and the remote governance check. dist/ is +# ignored and therefore does not make the tree dirty. printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH" git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" fetch_release_tags run_contract --metadata-output "$final_metadata" final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)" -if [ "$final_previous_stable" != "$previous_stable" ]; then +[ -n "$final_previous_stable" ] || { + printf 'latest stable authority unexpectedly became empty\n' >&2 + exit 1 +} +previous_stable_before_refresh="$previous_stable" +previous_stable="$final_previous_stable" + +printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable" +require_delivered_previous_stable + +if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \ - "${previous_stable:-none}" "${final_previous_stable:-none}" - [ -n "$final_previous_stable" ] || { - printf 'latest stable authority unexpectedly became empty\n' >&2 - exit 1 - } + "${previous_stable_before_refresh:-none}" "$previous_stable" "$ROOT/scripts/policy/check-command-compatibility.sh" \ --base-ref "$REMOTE/$BRANCH" \ - --stable-ref "$final_previous_stable" + --stable-ref "$previous_stable" +fi + +if [ "$PUBLISH" -eq 1 ]; then + printf '==> Reconfirming GitHub publication authority on the settled baseline\n' + require_github_publication_authority +fi + +# Delivery, compatibility, and publication checks above may take long enough +# for main or stable authority to move. This last refresh must be followed only +# by local proof/tag creation. The atomic push advertises main with the tag, so +# an already-advanced remote main rejects the whole transaction; a later main +# advance is safe because the sealed commit remains in protected main history. +printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH" +git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" +fetch_release_tags +run_contract --metadata-output "$final_metadata" +settled_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)" +[ "$settled_previous_stable" = "$previous_stable" ] || { + printf 'stable authority moved from %s to %s during final validation; rerun the release command\n' \ + "$previous_stable" "$settled_previous_stable" >&2 + exit 1 +} + +if [ "$ran_expensive_gates" -eq 1 ]; then + write_preflight_proof "$proof_path" fi if [ "$PUBLISH" -ne 1 ]; then printf '\nPreflight passed. No tag was created.\n' - printf 'Publish with the same command plus --publish.\n' + printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n' exit 0 fi -printf '==> Reconfirming GitHub publication authority immediately before tag creation\n' -require_github_publication_authority - printf '==> Creating annotated tag %s\n' "$VERSION" if [ "$CHANNEL" = "stable" ]; then git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA" @@ -327,7 +415,8 @@ else git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease' fi -if ! git push "$push_url" "refs/tags/$VERSION"; then +if ! git push --atomic "$push_url" \ + "HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then set +e remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" query_status=$? @@ -351,3 +440,4 @@ if ! git push "$push_url" "refs/tags/$VERSION"; then fi printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url" +rm -f "$proof_path" diff --git a/scripts/release/verify-delivered-stable.sh b/scripts/release/verify-delivered-stable.sh index e972745d..2e83fa2b 100755 --- a/scripts/release/verify-delivered-stable.sh +++ b/scripts/release/verify-delivered-stable.sh @@ -79,6 +79,14 @@ printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TA ' && delivered_by_push=1 || delivered_by_push=0 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' \ + "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + recovery_proof="$( python3 - "$RECOVERY_MANIFEST" "$TAG" "$EXPECTED_COMMIT" <<'PY' import json diff --git a/scripts/release/verify-github-tag-authority.sh b/scripts/release/verify-github-tag-authority.sh index 0a75b313..f0ec74d4 100755 --- a/scripts/release/verify-github-tag-authority.sh +++ b/scripts/release/verify-github-tag-authority.sh @@ -3,15 +3,21 @@ set -eu TAG="${1:-}" EXPECTED_COMMIT="${2:-}" +EXPECTED_TAG_OBJECT="${3:-}" REPOSITORY="${GITHUB_REPOSITORY:-}" [ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || { - printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh \n' >&2 + printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh [tag-object]\n' >&2 exit 2 } command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } local_object="$(git rev-parse "refs/tags/$TAG")" +if [ -n "$EXPECTED_TAG_OBJECT" ] && [ "$local_object" != "$EXPECTED_TAG_OBJECT" ]; then + printf 'local tag object for %s changed (expected=%s actual=%s)\n' \ + "$TAG" "$EXPECTED_TAG_OBJECT" "$local_object" >&2 + exit 1 +fi remote_ref="$( gh api -H 'Accept: application/vnd.github+json' \ "repos/$REPOSITORY/git/ref/tags/$TAG" \ diff --git a/scripts/release/verify-release-governance-ci.sh b/scripts/release/verify-release-governance-ci.sh new file mode 100755 index 00000000..8416cae7 --- /dev/null +++ b/scripts/release/verify-release-governance-ci.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +REPOSITORY="${1:-}" +COMMIT="${2:-}" +WORKFLOW="release.yml" +DEFAULT_BRANCH="main" +TIMEOUT_SECONDS="${DWS_RELEASE_GOVERNANCE_TIMEOUT_SECONDS:-180}" +POLL_SECONDS="${DWS_RELEASE_GOVERNANCE_POLL_SECONDS:-2}" + +usage() { + printf '%s\n' 'usage: verify-release-governance-ci.sh ' >&2 +} + +[ -n "$REPOSITORY" ] && [ -n "$COMMIT" ] || { usage; exit 2; } +printf '%s\n' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$' || { + printf 'invalid release commit: %s\n' "$COMMIT" >&2 + exit 2 +} +printf '%s\n' "$TIMEOUT_SECONDS" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid governance timeout: %s\n' "$TIMEOUT_SECONDS" >&2 + exit 2 +} +printf '%s\n' "$POLL_SECONDS" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid governance poll interval: %s\n' "$POLL_SECONDS" >&2 + exit 2 +} +command -v gh >/dev/null 2>&1 || { + printf '%s\n' 'gh is required to run the Release governance preflight' >&2 + exit 1 +} + +nonce="${COMMIT}-$(date +%s)-$$" +expected_title="Release governance preflight $nonce" + +printf '==> Dispatching Release governance preflight for %s\n' "$COMMIT" +gh workflow run "$WORKFLOW" \ + --repo "$REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f "governance_preflight_commit=$COMMIT" \ + -f "governance_preflight_nonce=$nonce" + +started_at="$(date +%s)" +run_id="" +while :; do + # nonce is restricted to a commit SHA, decimal timestamp, PID, and hyphens, + # so it is safe to embed in this jq string literal. + run_id="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/actions/workflows/$WORKFLOW/runs?event=workflow_dispatch&branch=$DEFAULT_BRANCH&per_page=100" \ + --jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$COMMIT\") | .id" \ + | sed -n '1p' + )" || { + printf '%s\n' 'could not query the dispatched Release governance preflight' >&2 + exit 1 + } + [ -z "$run_id" ] || break + + now="$(date +%s)" + if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then + printf 'timed out waiting for Release governance preflight: %s\n' "$expected_title" >&2 + exit 1 + fi + sleep "$POLL_SECONDS" +done + +run_state="" +while :; do + run_state="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/actions/runs/$run_id" \ + --jq '[.display_title, .event, .status, .conclusion, .head_branch, .head_sha, .path, .repository.full_name] | @tsv' + )" || { + printf 'could not verify Release governance preflight run %s\n' "$run_id" >&2 + exit 1 + } + run_status="$(printf '%s\n' "$run_state" | cut -f3)" + [ "$run_status" != "completed" ] || break + now="$(date +%s)" + if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then + printf 'timed out waiting for Release governance preflight run %s\n' "$run_id" >&2 + exit 1 + fi + sleep "$POLL_SECONDS" +done + +expected_state="$(printf '%s\tworkflow_dispatch\tcompleted\tsuccess\t%s\t%s\t.github/workflows/release.yml\t%s' \ + "$expected_title" "$DEFAULT_BRANCH" "$COMMIT" "$REPOSITORY")" +[ "$run_state" = "$expected_state" ] || { + printf 'Release governance preflight run identity mismatch:\n expected: %s\n actual: %s\n' \ + "$expected_state" "$run_state" >&2 + gh run view "$run_id" --repo "$REPOSITORY" --log-failed >&2 || true + exit 1 +} + +printf 'Release governance preflight passed: https://github.com/%s/actions/runs/%s\n' \ + "$REPOSITORY" "$run_id" diff --git a/scripts/release/verify-release-workflow-delivery.sh b/scripts/release/verify-release-workflow-delivery.sh new file mode 100755 index 00000000..f06397ee --- /dev/null +++ b/scripts/release/verify-release-workflow-delivery.sh @@ -0,0 +1,156 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + +TAG="${1:-}" +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 \n' >&2 + exit 2 +} +if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then + printf 'invalid delivered release tag: %s\n' "$TAG" >&2 + exit 2 +fi +printf '%s\n' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || { + printf 'invalid delivered release commit: %s\n' "$EXPECTED_COMMIT" >&2 + exit 2 +} +command -v curl >/dev/null 2>&1 || { printf '%s\n' 'curl is required to verify release delivery' >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { printf '%s\n' 'python3 is required to verify release delivery' >&2; exit 1; } + +API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}" +if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then + API_TOKEN="$(gh auth token 2>/dev/null || true)" +fi +if [ -z "$API_TOKEN" ]; then API_TOKEN="${GITHUB_TOKEN:-}"; fi + +github_get() { + if [ -n "$API_TOKEN" ]; then + curl -fsSL \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + -H "Authorization: Bearer $API_TOKEN" \ + "https://api.github.com/$1" 2>/dev/null && return 0 + fi + curl -fsSL \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "https://api.github.com/$1" +} + +find_push_delivery() { + page=1 + while :; do + page_result="$( + github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \ + | python3 -c 'import json,sys +tag,commit=sys.argv[1:] +runs=json.load(sys.stdin).get("workflow_runs", []) +print(len(runs)) +for run in runs: + if run.get("head_sha") == commit and run.get("head_branch") == tag and run.get("conclusion") == "success": + print(run.get("id", "")) + break' "$TAG" "$EXPECTED_COMMIT" + )" || return 1 + page_count="$(printf '%s\n' "$page_result" | sed -n '1p')" + page_match="$(printf '%s\n' "$page_result" | sed -n '2p')" + if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi + [ "$page_count" -eq 100 ] || return 1 + page=$((page + 1)) + done +} + +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' \ + "$push_delivery" "$TAG" "$EXPECTED_COMMIT" + exit 0 +fi + +find_recovery_identity() { + page=1 + while :; do + page_result="$( + github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=main&event=workflow_dispatch&status=completed&per_page=100&page=$page" \ + | python3 -c 'import json,sys +tag,commit,repository=sys.argv[1:] +title=f"Release recovery {tag} at {commit}" +runs=json.load(sys.stdin).get("workflow_runs", []) +print(len(runs)) +for run in runs: + display=run.get("display_title", "") + nonce=display[len(title) + 1:] if display.startswith(title + " ") else "" + if (__import__("re").fullmatch(__import__("re").escape(commit) + r"-[0-9]+-[0-9]+", nonce) + and run.get("event") == "workflow_dispatch" + and run.get("status") == "completed" and run.get("conclusion") == "success" + and run.get("head_branch") == "main" and run.get("path") == ".github/workflows/release.yml" + and run.get("repository", {}).get("full_name") == repository): + print("%s\t%s" % (run.get("id", ""), run.get("head_sha", ""))) + break' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY" + )" || return 1 + page_count="$(printf '%s\n' "$page_result" | sed -n '1p')" + page_match="$(printf '%s\n' "$page_result" | sed -n '2p')" + if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi + [ "$page_count" -eq 100 ] || return 1 + page=$((page + 1)) + done +} + +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' \ + "$TAG" "$EXPECTED_COMMIT" >&2 + exit 1 +} +recovery_run_id="$(printf '%s\n' "$recovery_identity" | cut -f1)" +recovery_workflow_sha="$(printf '%s\n' "$recovery_identity" | cut -f2)" + +workflow_status="$({ + github_get "repos/$REPOSITORY/compare/$recovery_workflow_sha...main" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", ""))' +} || true)" +case "$workflow_status" in ahead|identical) ;; *) + printf 'protected recovery workflow %s is not contained in current main\n' \ + "$recovery_workflow_sha" >&2 + exit 1 +esac + +passed_jobs="" +page=1 +while :; do + page_result="$( + github_get "repos/$REPOSITORY/actions/runs/$recovery_run_id/jobs?filter=all&per_page=100&page=$page" \ + | python3 -c 'import json,sys +workflow_sha=sys.argv[1] +jobs=json.load(sys.stdin).get("jobs", []) +print(len(jobs)) +for job in jobs: + if (job.get("status") == "completed" and job.get("conclusion") == "success" + and job.get("head_sha") == workflow_sha): + print(job.get("name", ""))' "$recovery_workflow_sha" + )" || exit 1 + page_count="$(printf '%s\n' "$page_result" | sed -n '1p')" + page_jobs="$(printf '%s\n' "$page_result" | sed '1d')" + passed_jobs="$(printf '%s\n%s\n' "$passed_jobs" "$page_jobs" | sed '/^$/d')" + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) +done +for required_job in \ + "Build signed release artifacts" \ + "Verify Apple Developer ID signatures" \ + "Publish immutable GitHub Release" \ + "Publish npm and mirrors"; do + printf '%s\n' "$passed_jobs" | grep -Fqx "$required_job" || { + printf 'protected recovery run %s did not complete the shared release job graph for %s\n' \ + "$recovery_run_id" "$TAG" >&2 + exit 1 + } +done + +printf 'Release workflow delivery verified through protected recovery run %s: %s -> %s\n' \ + "$recovery_run_id" "$TAG" "$EXPECTED_COMMIT" diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index ad0c61a3..368accfe 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -17,21 +17,36 @@ var expectedHomeSkillTargets = []string{ ".cursor/skills/dws", } -func TestInstallScriptSourceModeInstallsBinary(t *testing.T) { - t.Parallel() +type installSourceFixture struct { + root string + scriptPath string + stubRoot string + fakeHome string +} - root := t.TempDir() - installDir := filepath.Join(root, "bin") +func newInstallSourceFixture(t *testing.T) *installSourceFixture { + t.Helper() - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) + repoScript, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) if err != nil { t.Fatalf("Abs(install.sh) error = %v", err) } + scriptData, err := os.ReadFile(repoScript) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", repoScript, err) + } + + root := t.TempDir() + scriptPath := filepath.Join(root, "scripts", "install.sh") + mustWriteFile(t, scriptPath, scriptData, 0o755) + // resolve_source_root requires both go.mod and cmd/. The source installer + // tests need only that layout plus small representative skill trees. + mustWriteFile(t, filepath.Join(root, "go.mod"), []byte("module example.com/dws-install-test\n"), 0o644) + mustWriteFile(t, filepath.Join(root, "cmd", ".keep"), nil, 0o644) + mustWriteFile(t, filepath.Join(root, "skills", "mono", "SKILL.md"), []byte("# Test skill\n"), 0o644) + mustWriteFile(t, filepath.Join(root, "skills", "multi", "dingtalk-test", "SKILL.md"), []byte("# Test split skill\n"), 0o644) - // Stub make: when invoked as "make -C build", create a fake dws binary - // in the project root (the -C target directory). stubRoot := filepath.Join(root, "stubs") - repoRoot, _ := filepath.Abs(filepath.Join("..", "..")) makeStub := `#!/bin/sh set -eu dir="" @@ -44,27 +59,48 @@ done [ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws" ` mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755) - // Also need a stub go so need_cmd check passes mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755) - cmd := exec.Command("sh", scriptPath) - cmd.Env = append(os.Environ(), - "PATH="+stubRoot+":"+os.Getenv("PATH"), + return &installSourceFixture{ + root: root, + scriptPath: scriptPath, + stubRoot: stubRoot, + fakeHome: filepath.Join(root, "home"), + } +} + +func (f *installSourceFixture) env(extra ...string) []string { + env := append(os.Environ(), + "HOME="+f.fakeHome, + "PATH="+f.stubRoot+":"+os.Getenv("PATH"), + "DWS_VERSION=latest", + "DWS_SKILLS_ONLY=0", + "DWS_SKILL_MODE=mono", + ) + return append(env, extra...) +} + +func TestInstallScriptSourceModeInstallsBinary(t *testing.T) { + t.Parallel() + + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") + + cmd := exec.Command("sh", fixture.scriptPath) + cmd.Env = fixture.env( "DWS_INSTALL_DIR="+installDir, "DWS_INSTALL_NAME=dws-test", + "DWS_NO_SKILLS=1", ) output, err := cmd.CombinedOutput() - // Clean up the fake binary created in the real repo root - _ = os.Remove(filepath.Join(repoRoot, "dws")) - if err != nil { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } got := string(output) for _, want := range []string{ - "Installing dws from source checkout", + "Installing dws from source checkout: " + fixture.root, "Install dir: " + installDir, "Binary installed:", installDir + "/dws-test", @@ -87,53 +123,27 @@ done func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) { t.Parallel() - root := t.TempDir() - fakeHome := filepath.Join(root, "home") - installDir := filepath.Join(root, "bin") - - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) - if err != nil { - t.Fatalf("Abs(install.sh) error = %v", err) - } - - stubRoot := filepath.Join(root, "stubs") - repoRoot, _ := filepath.Abs(filepath.Join("..", "..")) - makeStub := `#!/bin/sh -set -eu -dir="" -while [ "$#" -gt 0 ]; do - case "$1" in - -C) dir="$2"; shift 2 ;; - *) shift ;; - esac -done -[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws" -` - mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755) - mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755) + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") // Gate for index>0 agent dirs (matches build/npm/install.js): parent must exist. - if err := os.MkdirAll(filepath.Join(fakeHome, ".cursor"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(fixture.fakeHome, ".cursor"), 0o755); err != nil { t.Fatalf("MkdirAll(.cursor) error = %v", err) } - cmd := exec.Command("sh", scriptPath) - cmd.Env = append(os.Environ(), - "HOME="+fakeHome, - "PATH="+stubRoot+":"+os.Getenv("PATH"), + cmd := exec.Command("sh", fixture.scriptPath) + cmd.Env = fixture.env( "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=0", ) output, err := cmd.CombinedOutput() - // Clean up the fake binary created in the real repo root - _ = os.Remove(filepath.Join(repoRoot, "dws")) - if err != nil { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } for _, rel := range expectedHomeSkillTargets { - skillPath := filepath.Join(fakeHome, filepath.FromSlash(rel), "SKILL.md") + skillPath := filepath.Join(fixture.fakeHome, filepath.FromSlash(rel), "SKILL.md") if _, err := os.Stat(skillPath); err != nil { t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output)) } @@ -694,48 +704,22 @@ func TestInstallScriptsCacheMultiSkills(t *testing.T) { func TestInstallScriptCachesMultiEndToEnd(t *testing.T) { t.Parallel() - root := t.TempDir() - fakeHome := filepath.Join(root, "home") - installDir := filepath.Join(root, "bin") + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") - scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) - if err != nil { - t.Fatalf("Abs(install.sh) error = %v", err) - } - - stubRoot := filepath.Join(root, "stubs") - repoRoot, _ := filepath.Abs(filepath.Join("..", "..")) - makeStub := `#!/bin/sh -set -eu -dir="" -while [ "$#" -gt 0 ]; do - case "$1" in - -C) dir="$2"; shift 2 ;; - *) shift ;; - esac -done -[ -n "$dir" ] && printf 'fake-binary\n' > "$dir/dws" -` - mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755) - mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755) - - cmd := exec.Command("sh", scriptPath) - cmd.Env = append(os.Environ(), - "HOME="+fakeHome, - "PATH="+stubRoot+":"+os.Getenv("PATH"), + cmd := exec.Command("sh", fixture.scriptPath) + cmd.Env = fixture.env( "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=0", ) output, err := cmd.CombinedOutput() - // Clean up the fake binary created in the real repo root - _ = os.Remove(filepath.Join(repoRoot, "dws")) - if err != nil { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } // Verify multi cache was populated. We expect dingtalk-* subdirs. - multiCache := filepath.Join(fakeHome, ".dws", "skills", "multi") + multiCache := filepath.Join(fixture.fakeHome, ".dws", "skills", "multi") entries, err := os.ReadDir(multiCache) if err != nil { t.Fatalf("ReadDir(%s) error = %v\noutput:\n%s", multiCache, err, string(output)) @@ -751,7 +735,7 @@ done } // And verify mono cache. - monoCacheSkill := filepath.Join(fakeHome, ".dws", "skills", "mono", "SKILL.md") + monoCacheSkill := filepath.Join(fixture.fakeHome, ".dws", "skills", "mono", "SKILL.md") if _, err := os.Stat(monoCacheSkill); err != nil { t.Fatalf("missing mono cache SKILL.md at %s: %v", monoCacheSkill, err) } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 0d488084..08b8dcb2 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -598,6 +598,196 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) { } } +func readReleaseWorkflow(t *testing.T) string { + t.Helper() + workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("Abs(release.yml) error = %v", err) + } + data, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", workflowPath, err) + } + return string(data) +} + +func releaseWorkflowSection(t *testing.T, workflow, startMarker, endMarker string) string { + t.Helper() + start := strings.Index(workflow, startMarker) + if start == -1 { + t.Fatalf("release workflow is missing section marker %q", startMarker) + } + end := strings.Index(workflow[start+len(startMarker):], endMarker) + if end == -1 { + t.Fatalf("release workflow section %q is missing end marker %q", startMarker, endMarker) + } + return workflow[start : start+len(startMarker)+end] +} + +func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + + const ( + checksCall = "github.rest.checks.listForRef" + immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"` + governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}` + ) + if got := strings.Count(workflow, checksCall); got != 2 { + t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got) + } + if got := strings.Count(workflow, immutableCall); got != 2 { + t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got) + } + if got := strings.Count(workflow, governanceID); got != 2 { + t.Fatalf("release workflow dedicated governance identity count = %d, want one per immutable check", got) + } + + sections := map[string]string{ + "preflight": releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n"), + "tag": releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n"), + } + for name, section := range sections { + for _, required := range []string{ + "checks: read", + checksCall, + immutableCall, + governanceID, + "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required", + } { + if !strings.Contains(section, required) { + t.Errorf("%s governance path is missing %q", name, required) + } + } + if strings.Contains(section, "contents: write") { + t.Errorf("%s governance path must not grant contents write permission", name) + } + if strings.Contains(section, `github-token: ${{ secrets.GITHUB_TOKEN }}`) { + t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name) + } + } +} + +func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + preflight := releaseWorkflowSection(t, workflow, " governance-preflight:\n", "\n release-contract:\n") + + for _, required := range []string{ + "governance_preflight_commit:", + "governance_preflight_nonce:", + `format('Release governance preflight {0}', inputs.governance_preflight_nonce)`, + "name: Release governance preflight", + "github.event_name == 'workflow_dispatch'", + "EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli", + `DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`, + `test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`, + "governance preflight cannot be combined with npm repair", + } { + if !strings.Contains(workflow, required) { + t.Errorf("governance preflight contract is missing %q", required) + } + } + for _, forbidden := range []string{ + "actions/checkout", + "contents: write", + "goreleaser", + "gh release", + "npm publish", + "sync-to-oss", + "sync-to-gitee", + } { + if strings.Contains(preflight, forbidden) { + t.Errorf("governance preflight must not contain publishing behavior %q", forbidden) + } + } + + mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n") + if !strings.Contains(mirror, "needs: [release-contract, release, publish-channels]") { + t.Error("Gitee publication must remain downstream of the verified release target and channel jobs") + } + repair := workflow[strings.Index(workflow, " repair-npm:\n"):] + for _, required := range []string{ + "needs: dispatch-contract", + "needs.dispatch-contract.outputs.mode == 'repair_npm'", + } { + if !strings.Contains(repair, required) { + t.Errorf("npm repair dispatch contract is missing %q", required) + } + } +} + +func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + + for _, required := range []string{ + "recover_release_version:", + "recover_release_tag_object:", + "recover_release_commit:", + "recover_failed_run_id:", + "recover_release_nonce:", + "recover_release_confirmation:", + `format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce)`, + "workflow_dispatch must select exactly one release mode", + "release recovery confirmation must equal the exact version", + "recover_release_nonce must be bound to the release commit", + "environment: release-recovery", + "prevent_self_review !== true", + "protected_branches !== true", + "can_admins_bypass !== false", + `run.path !== ".github/workflows/release.yml"`, + `run.event !== "push"`, + `["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`, + `run.head_branch !== version`, + `run.head_sha !== commit`, + `tagObject !== expectedTagObject`, + `["ahead", "identical"].includes(comparison.data.status)`, + "already has a public release; use a channel repair instead", + "Bind recovery publication to this workflow run", + "dws-release-recovery run=%s tag-object=%s commit=%s", + "Public release is not bound to this exact recovery run.", + "Public recovery asset differs from this run's sealed artifact", + `ref: process.env.RELEASE_COMMIT`, + `path: tmp/trusted-release-tooling`, + `ref: ${{ github.sha }}`, + `step.name === "Require immutable published GitHub Release"`, + "Require a clean sealed source before GoReleaser", + `git status --porcelain --untracked-files=all`, + } { + if !strings.Contains(workflow, required) { + t.Errorf("release recovery contract is missing %q", required) + } + } + + sections := map[string]string{ + "release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"), + "publish-release": releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n"), + "publish-channels": releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n"), + } + for name, section := range sections { + for _, required := range []string{ + "needs.release-contract.outputs.release_version", + "ref: ${{ needs.release-contract.outputs.release_commit }}", + "persist-credentials: false", + `tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh`, + } { + if !strings.Contains(section, required) { + t.Errorf("%s does not consume the verified recovery target %q", name, required) + } + } + if strings.Contains(section, "github.event_name == 'workflow_dispatch'") { + t.Errorf("%s must not fork into a recovery-specific publisher", name) + } + } + if strings.Count(workflow, "name: Build signed release artifacts") != 1 || + strings.Count(workflow, "name: Verify Apple Developer ID signatures") != 1 || + strings.Count(workflow, "name: Publish immutable GitHub Release") != 1 || + strings.Count(workflow, "name: Publish npm and mirrors") != 1 { + t.Fatal("normal and recovery publication must share one build/sign/publish job graph") + } +} + func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) { t.Parallel() @@ -762,7 +952,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) { } codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4") - publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`) + publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`) if codesign == -1 || publish == -1 { t.Fatal("macOS codesign verification and explicit Draft publication are required") } @@ -848,7 +1038,7 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) { "./scripts/release/publish-homebrew-formula.sh", "secrets.HOMEBREW_PR_TOKEN", "DWS_TAP_PR_REPOSITORY", - "automation/homebrew-${{ github.ref_name }}", + "automation/homebrew-${{ needs.release-contract.outputs.release_version }}", } { if !strings.Contains(section, required) { t.Errorf("Homebrew publication step is missing %q", required) @@ -891,7 +1081,7 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) { "dist/homebrew/dingtalk-workspace-cli-beta.rb", "Formula/dingtalk-workspace-cli-beta.rb", "secrets.HOMEBREW_PR_TOKEN", - "automation/homebrew-beta-${{ github.ref_name }}", + "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}", } { if !strings.Contains(section, required) { t.Errorf("beta Homebrew PR step is missing %q", required) diff --git a/test/scripts/release_entry_test.go b/test/scripts/release_entry_test.go index f81ce5ee..a5dd5073 100644 --- a/test/scripts/release_entry_test.go +++ b/test/scripts/release_entry_test.go @@ -30,10 +30,24 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture { } mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), fakeDelegate("prepare"), 0o755) mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "release.sh"), fakeDelegate("release"), 0o755) + mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "recover-release.sh"), fakeDelegate("recover"), 0o755) r.commitAndPush(t, "install unified release entry fixture") return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")} } +func TestDWSReleaseEntryRoutesProtectedRecovery(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345") + if err != nil { + t.Fatalf("dws-release recovery route error = %v\noutput:\n%s", err, output) + } + want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\n" + if got := f.callLog(t); got != want { + t.Fatalf("recovery delegate calls = %q, want %q", got, want) + } +} + func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) { t.Helper() cmd := exec.Command("sh", append([]string{f.entry}, args...)...) diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index d9450e2b..66e5d5fd 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -392,6 +392,72 @@ esac } } +func TestReleaseWorkflowDeliveryAcceptsOnlySharedProtectedRecovery(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + binDir := t.TempDir() + fakeCurl := filepath.Join(binDir, "curl") + mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh +set -eu +for argument in "$@"; do endpoint="$argument"; done +case "$endpoint" in + *event=push*) + printf '{"workflow_runs":[]}\n' + ;; + *event=workflow_dispatch*) + printf '{"workflow_runs":[{"id":42,"display_title":"Release recovery %s at %s %s-1700000000-42","event":"workflow_dispatch","status":"completed","conclusion":"success","head_branch":"main","head_sha":"%s","path":".github/workflows/release.yml","repository":{"full_name":"owner/repo"}}]}\n' "$TAG" "$RELEASE_COMMIT" "$RELEASE_COMMIT" "$WORKFLOW_SHA" + ;; + */compare/*...main) + printf '{"status":"ahead"}\n' + ;; + */actions/runs/42/jobs*) + printf '{"jobs":[' + printf '{"name":"Build signed release artifacts","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA" + printf '{"name":"Verify Apple Developer ID signatures","status":"completed","conclusion":"success","head_sha":"%s"},' "$WORKFLOW_SHA" + printf '{"name":"Publish immutable GitHub Release","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA" + if [ "${MISSING_CHANNELS:-0}" != 1 ]; then + printf ',{"name":"Publish npm and mirrors","status":"completed","conclusion":"success","head_sha":"%s"}' "$WORKFLOW_SHA" + fi + printf ']}\n' + ;; + *) exit 1 ;; +esac +`), 0o755) + script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh") + tag := "v1.2.3-beta.1" + commit := strings.Repeat("a", 40) + workflowSHA := strings.Repeat("b", 40) + run := func(missingChannels bool) (string, error) { + cmd := exec.Command("sh", script, tag, commit) + missing := "0" + if missingChannels { + missing = "1" + } + cmd.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo", + "TAG=" + tag, + "RELEASE_COMMIT=" + commit, + "WORKFLOW_SHA=" + workflowSHA, + "MISSING_CHANNELS=" + missing, + } + output, err := cmd.CombinedOutput() + return string(output), err + } + + output, err := run(false) + if err != nil || !strings.Contains(output, "protected recovery run 42") { + t.Fatalf("protected shared recovery was rejected: err=%v\noutput:\n%s", err, output) + } + output, err = run(true) + if err == nil || !strings.Contains(output, "did not complete the shared release job graph") { + t.Fatalf("incomplete recovery job graph passed: err=%v\noutput:\n%s", err, output) + } +} + func releaseChangelog(sections ...string) string { text := "# Changelog\n\n## [Unreleased]\n\n" for _, section := range sections { @@ -1002,6 +1068,178 @@ func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) { } } +func TestReleaseCommandReusesExactPreflightProof(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, ".gitignore"), []byte("/dws\n"), 0o644) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte(`test: + @rm -f dws + @printf 'test\n' >> "$$(git rev-parse --git-path release-phases)" +build: + @printf '#!/bin/sh\nexit 0\n' > dws + @chmod +x dws + @printf 'build\n' >> "$$(git rev-parse --git-path release-phases)" +policy: + @test -x dws + @printf 'policy\n' >> "$$(git rev-parse --git-path release-phases)" +package: + @printf 'package\n' >> "$$(git rev-parse --git-path release-phases)" +`), 0o644) + r.commitAndPush(t, "install proof-aware release automation") + + command := filepath.Join(r.root, "scripts", "release", "release.sh") + output, err := runReleaseScript(t, r.root, command, + "prerelease", "v1.0.1-beta.1", "--remote", "origin", + ) + if err != nil { + t.Fatalf("release validation error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "Publish within six hours") { + t.Fatalf("validation output missing reusable-proof guidance:\n%s", output) + } + phasePath := filepath.Join(r.root, ".git", "release-phases") + phaseData, err := os.ReadFile(phasePath) + if err != nil { + t.Fatalf("ReadFile(release phases) error = %v", err) + } + wantPhases := "test\nbuild\npolicy\npackage\n" + if string(phaseData) != wantPhases { + t.Fatalf("release phase order = %q, want %q", phaseData, wantPhases) + } + proofPath := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "--git-path", "dws-release-preflight/v1.0.1-beta.1.proof")) + if !filepath.IsAbs(proofPath) { + proofPath = filepath.Join(r.root, proofPath) + } + proofBefore, err := os.ReadFile(proofPath) + if err != nil { + t.Fatalf("ReadFile(preflight proof) error = %v", err) + } + hook := filepath.Join(r.remote, "hooks", "pre-receive") + mustWriteFile(t, hook, []byte("#!/bin/sh\nset -eu\nwhile read -r old new ref; do\n case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\nexit 0\n"), 0o755) + failedOutput, failedErr := runReleaseScript(t, r.root, command, + "prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes", + ) + if failedErr == nil || !strings.Contains(failedOutput, "new local tag was removed") { + t.Fatalf("rejected proof-based publish did not fail safely: err=%v\noutput:\n%s", failedErr, failedOutput) + } + proofAfterFailure, err := os.ReadFile(proofPath) + if err != nil { + t.Fatalf("ReadFile(preflight proof after failure) error = %v", err) + } + if string(proofAfterFailure) != string(proofBefore) { + t.Fatalf("failed publish refreshed reusable proof:\nbefore:\n%s\nafter:\n%s", proofBefore, proofAfterFailure) + } + if err := os.Remove(hook); err != nil { + t.Fatalf("Remove(rejecting hook) error = %v", err) + } + + output, err = runReleaseScript(t, r.root, command, + "prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes", + ) + if err != nil { + t.Fatalf("release publish error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "Reusing exact preflight proof") { + t.Fatalf("publish did not reuse the exact preflight proof:\n%s", output) + } + phaseData, err = os.ReadFile(phasePath) + if err != nil { + t.Fatalf("ReadFile(release phases after publish) error = %v", err) + } + if string(phaseData) != wantPhases { + t.Fatalf("publish reran expensive phases: %q", phaseData) + } +} + +func TestReleaseGovernanceCIDispatchBindsExactRunIdentity(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + binDir := t.TempDir() + stateDir := t.TempDir() + fakeGH := filepath.Join(binDir, "gh") + mustWriteFile(t, fakeGH, []byte(`#!/bin/sh +set -eu +command="$1" +shift +case "$command" in + workflow) + test "$1" = run + shift + printf '%s\n' "$@" > "$GH_STATE/workflow-args" + for argument in "$@"; do + case "$argument" in + governance_preflight_nonce=*) + printf 'Release governance preflight %s\n' "${argument#governance_preflight_nonce=}" > "$GH_STATE/title" + ;; + governance_preflight_commit=*) + printf '%s\n' "${argument#governance_preflight_commit=}" > "$GH_STATE/commit" + ;; + esac + done + ;; + api) + for argument in "$@"; do + case "$argument" in repos/*/actions/*) endpoint="$argument" ;; esac + done + case "$endpoint" in + */actions/workflows/release.yml/runs*) printf '42\n' ;; + */actions/runs/42) + title="$(cat "$GH_STATE/title")" + commit="$(cat "$GH_STATE/commit")" + repository=owner/repo + if [ "${GH_BAD_STATE:-0}" = 1 ]; then repository=attacker/repo; fi + printf '%s\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s\t.github/workflows/release.yml\t%s\n' "$title" "$commit" "$repository" + ;; + *) exit 1 ;; + esac + ;; + run) + test "$1" = watch + ;; + *) exit 1 ;; +esac +`), 0o755) + script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-governance-ci.sh") + commit := strings.Repeat("a", 40) + run := func(badState bool) (string, error) { + cmd := exec.Command("sh", script, "owner/repo", commit) + cmd.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "GH_STATE=" + stateDir, + fmt.Sprintf("GH_BAD_STATE=%d", map[bool]int{false: 0, true: 1}[badState]), + } + output, err := cmd.CombinedOutput() + return string(output), err + } + + output, err := run(false) + if err != nil || !strings.Contains(output, "Release governance preflight passed") { + t.Fatalf("governance CI preflight error = %v\noutput:\n%s", err, output) + } + args, err := os.ReadFile(filepath.Join(stateDir, "workflow-args")) + if err != nil { + t.Fatalf("ReadFile(workflow args) error = %v", err) + } + for _, required := range []string{ + "--ref\nmain\n", + "governance_preflight_commit=" + commit, + "governance_preflight_nonce=" + commit + "-", + } { + if !strings.Contains(string(args), required) { + t.Errorf("workflow dispatch args are missing %q:\n%s", required, args) + } + } + + output, err = run(true) + if err == nil || !strings.Contains(output, "run identity mismatch") { + t.Fatalf("mismatched governance run identity passed: err=%v\noutput:\n%s", err, output) + } +} + func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) { r := newReleaseTestRepo(t) installReleaseCommandFixture(t, r) @@ -1048,7 +1286,7 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) { section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n" mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644) mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755) - mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644) + mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644) r.commitAndPush(t, "install advancing release fixture") output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), @@ -1071,7 +1309,7 @@ func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) { mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755) - mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644) + mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644) } func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) {