From 22d19863fb09ffcf6abf1fb189d198a57e6bde2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:15:37 +0800 Subject: [PATCH] feat(release): add guarded prerelease and stable pipeline (#54) * feat(release): add guarded prerelease and stable pipeline * feat(release): add guided dws-release entry (cherry picked from commit f7fa7b78f325f3574f0487862fc3e65bba5cdc96) --- .github/actionlint.yaml | 6 + .github/workflows/ci.yml | 152 ++- .github/workflows/mirror-to-gitee.yml | 24 +- .github/workflows/publish-npm-release.yml | 71 -- .github/workflows/release.yml | 946 +++++++++++++++--- .github/workflows/sync-release-to-gitee.yml | 55 - .goreleaser.yaml | 9 +- CHANGELOG.md | 4 + Makefile | 49 +- build/homebrew.rb.tmpl | 42 +- docs/releasing.md | 113 +++ scripts/README.md | 12 +- scripts/release/build-and-publish-gitee.sh | 55 +- scripts/release/dws-release.sh | 322 ++++++ scripts/release/pack-npm-package.sh | 51 + scripts/release/post-goreleaser.sh | 59 +- scripts/release/prepare-changelog.sh | 97 ++ scripts/release/publish-gitee-local.sh | 71 +- scripts/release/release-contract.sh | 221 ++++ scripts/release/release-lib.sh | 167 ++++ scripts/release/release.sh | 353 +++++++ scripts/release/stage-npm-package.sh | 56 ++ scripts/release/sync-to-gitee.sh | 207 +++- scripts/release/sync-to-oss.sh | 160 ++- scripts/release/verify-delivered-stable.sh | 84 ++ .../release/verify-github-release-assets.sh | 35 + .../release/verify-github-tag-authority.sh | 37 + scripts/release/verify-package-managers.sh | 97 +- scripts/release/verify-release-artifacts.sh | 107 ++ test/scripts/package_script_test.go | 31 +- test/scripts/release_entry_test.go | 301 ++++++ test/scripts/release_script_test.go | 924 +++++++++++++++++ 32 files changed, 4327 insertions(+), 591 deletions(-) create mode 100644 .github/actionlint.yaml delete mode 100644 .github/workflows/publish-npm-release.yml delete mode 100644 .github/workflows/sync-release-to-gitee.yml create mode 100644 docs/releasing.md create mode 100755 scripts/release/dws-release.sh create mode 100755 scripts/release/pack-npm-package.sh create mode 100755 scripts/release/prepare-changelog.sh create mode 100755 scripts/release/release-contract.sh create mode 100755 scripts/release/release-lib.sh create mode 100755 scripts/release/release.sh create mode 100755 scripts/release/stage-npm-package.sh create mode 100755 scripts/release/verify-delivered-stable.sh create mode 100755 scripts/release/verify-github-release-assets.sh create mode 100755 scripts/release/verify-github-tag-authority.sh create mode 100755 scripts/release/verify-release-artifacts.sh create mode 100644 test/scripts/release_entry_test.go create mode 100644 test/scripts/release_script_test.go diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..eea35124 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +paths: + .github/workflows/release.yml: + ignore: + # GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's + # bundled workflow schema has not caught up with the platform syntax. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3a81156..f83fce6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,10 +63,17 @@ jobs: # package timeout aligned with the macOS race job so the Linux runner's # five-minute default does not expire while that gate is still making # progress. - run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/... + run: | + go test -v -race -count=1 -timeout=10m \ + ./cmd/... \ + ./internal/... \ + ./pkg/... \ + ./test/unit/... + go test -v -count=1 -timeout=5m \ + ./test/cli/... - - name: Test release scripts - run: go test -v -count=1 -timeout=5m ./test/scripts + - name: Test engineering scripts + run: go test -v -count=1 -timeout=5m ./test/scripts/... test-darwin: name: Test (macOS auth/keychain) @@ -408,41 +415,25 @@ jobs: - name: Run edition contract tests run: go test -v -count=1 ./pkg/editiontest/... - ci-gate: - name: CI Gate - needs: - - lint - - test - - test-darwin - - test-windows - - coverage - - coverage-darwin - - coverage-windows - - policy - - interface-integrity - - cli-smoke - - mock-mcp-smoke - - edition-tests + code-check: + name: Code Check + needs: [lint, test, test-darwin, test-windows, coverage, policy, edition-tests] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} steps: - - name: Verify required checks + - name: Verify code checks env: LINT_RESULT: ${{ needs.lint.result }} TEST_RESULT: ${{ needs.test.result }} TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }} TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }} COVERAGE_RESULT: ${{ needs.coverage.result }} - COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }} - COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }} POLICY_RESULT: ${{ needs.policy.result }} - INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }} - CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }} - MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }} EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} run: | + set -eu failed=0 for check in \ "Lint:$LINT_RESULT" \ @@ -450,12 +441,7 @@ jobs: "Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \ "Test (Windows):$TEST_WINDOWS_RESULT" \ "Coverage:$COVERAGE_RESULT" \ - "Coverage (macOS):$COVERAGE_DARWIN_RESULT" \ - "Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \ "Policy Check:$POLICY_RESULT" \ - "Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \ - "CLI Smoke:$CLI_SMOKE_RESULT" \ - "Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \ "Edition Contract Tests:$EDITION_TESTS_RESULT" do name="${check%%:*}" @@ -467,6 +453,114 @@ jobs: done test "$failed" -eq 0 + command-compatibility: + name: Command Compatibility + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: read + contents: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch official release tags + run: | + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check command compatibility + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + set -eu + base_ref="$PUSH_BEFORE_SHA" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="$PR_BASE_SHA" + fi + stable_ref="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" + test -n "$base_ref" + test -n "$stable_ref" + stable_commit="$(git rev-parse "$stable_ref^{commit}")" + ./scripts/release/verify-delivered-stable.sh "$stable_ref" "$stable_commit" + ./scripts/policy/check-command-compatibility.sh \ + --base-ref "$base_ref" \ + --stable-ref "$stable_ref" + + cli-smoke: + name: CLI Smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run CLI smoke tests + run: go test -count=1 -timeout=5m ./test/smoke/... + + mock-mcp-smoke: + name: Mock MCP Smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run Mock MCP smoke tests + run: go test -count=1 -timeout=5m ./test/mock_mcp/... + + ci-gate: + name: CI Gate + needs: [code-check, command-compatibility, cli-smoke, mock-mcp-smoke] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Verify required checks + env: + CODE_CHECK_RESULT: ${{ needs.code-check.result }} + COMMAND_COMPATIBILITY_RESULT: ${{ needs.command-compatibility.result }} + CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }} + MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }} + run: | + set -eu + failed=0 + for check in \ + "Code Check:$CODE_CHECK_RESULT" \ + "Command Compatibility:$COMMAND_COMPATIBILITY_RESULT" \ + "CLI Smoke:$CLI_SMOKE_RESULT" \ + "Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" + do + name="${check%%:*}" + result="${check#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "success" ]; then + failed=1 + fi + done + test "$failed" -eq 0 + notify-downstream: name: Notify Wukong Overlay needs: [ci-gate] diff --git a/.github/workflows/mirror-to-gitee.yml b/.github/workflows/mirror-to-gitee.yml index 2ae83b28..c440312f 100644 --- a/.github/workflows/mirror-to-gitee.yml +++ b/.github/workflows/mirror-to-gitee.yml @@ -1,4 +1,5 @@ -# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。 +# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。 +# Release tag 与附件只由 release.yml 的受控 publication queue 发布。 # 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret: # GITEE_TOKEN —— Gitee 私人令牌(勾 projects) # GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权) @@ -10,8 +11,6 @@ on: push: branches: - main - tags: - - 'v*' schedule: - cron: '0 18 * * *' workflow_dispatch: @@ -23,13 +22,14 @@ concurrency: jobs: mirror: runs-on: ubuntu-latest + if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }} # GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。 env: GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} GITEE_USER: ${{ secrets.GITEE_USER }} GITEE_REPO: ${{ secrets.GITEE_REPO }} steps: - - name: Checkout (full history + tags) + - name: Checkout main history if: env.GITEE_TOKEN != '' uses: actions/checkout@v4 with: @@ -41,14 +41,7 @@ jobs: set -eu REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git" - if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then - VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh - echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}" - exit 0 - fi - - # 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突) - git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main' + git fetch --force origin 'refs/heads/main:refs/remotes/origin/main' # Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。 # GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。 @@ -80,9 +73,6 @@ jobs: git add README.md README_zh.md 2>/dev/null || true git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true - # 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化) + # main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。 git push --force "$REMOTE" 'gitee-main:refs/heads/main' - # Release tags are immutable. Push only missing tags and fail closed - # on a conflicting existing ref instead of trying to move it. - timeout --signal=TERM 180s git push --tags "$REMOTE" - echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}" + echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}" diff --git a/.github/workflows/publish-npm-release.yml b/.github/workflows/publish-npm-release.yml deleted file mode 100644 index 20cd62c8..00000000 --- a/.github/workflows/publish-npm-release.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Publish npm release - -on: - workflow_dispatch: - inputs: - version: - description: "Release tag to publish to npm (e.g. v1.0.48)" - required: true - type: string - -permissions: - contents: read - -jobs: - publish-npm: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Download GitHub release assets - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -eu - mkdir -p dist - gh release download "${{ inputs.version }}" \ - --repo "${{ github.repository }}" \ - --dir dist \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber - ls -la dist - - - name: Stage npm package - run: | - set -eu - version="${{ inputs.version }}" - semver="${version#v}" - pkg_root="dist/npm/dingtalk-workspace-cli" - rm -rf "$pkg_root" - mkdir -p "$pkg_root/assets" "$pkg_root/bin" - cp build/npm/install.js "$pkg_root/install.js" - cp build/npm/bin/dws.js "$pkg_root/bin/dws.js" - cp build/npm/README.md "$pkg_root/README.md" - sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json" - cp dist/dws-* "$pkg_root/assets/" - cp dist/checksums.txt "$pkg_root/assets/" - test -f "$pkg_root/assets/dws-skills.zip" - cat "$pkg_root/package.json" - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - registry-url: "https://registry.npmjs.org" - - - name: Publish stable to npm - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Publish prerelease to npm beta - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public --tag beta - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9471a744..dc9bde54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,43 +7,239 @@ on: workflow_dispatch: inputs: repair_npm_version: - description: "Only publish an existing release to npm, e.g. v1.0.48" + description: "Recover npm for a public immutable release without moving latest/beta" required: false type: string permissions: - contents: write + 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. +concurrency: + group: dws-release-publication + queue: max jobs: - release: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }} + release-contract: + if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest - timeout-minutes: 30 - + timeout-minutes: 20 + permissions: + actions: read + checks: read + contents: read + outputs: + 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: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check Homebrew PR automation token - if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} - # Organization policy intentionally prevents the broad, built-in - # GITHUB_TOKEN from creating PRs. Keep Formula automation on a - # repository-scoped token instead of weakening that policy. - env: - HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} + - name: Fetch release branch and official tags run: | - if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then - echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2 - exit 1 - fi + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + + - name: Validate release contract + id: contract + run: | + set -eu + . ./scripts/release/release-lib.sh + channel="$(release_channel_for_version "$GITHUB_REF_NAME")" + ./scripts/release/release-contract.sh \ + --channel "$channel" \ + --version "$GITHUB_REF_NAME" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" \ + --metadata-output "$GITHUB_OUTPUT" + + - 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" + + - name: Require successful CI Gate on the sealed commit + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: context.sha, + 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 ${context.sha}`); + } + + - name: Require delivered previous stable baseline + env: + 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" + + - name: Require immutable releases governance + uses: actions/github-script@v7 + with: + 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"); + } + + - name: Require successful beta delivery before stable promotion + if: ${{ steps.contract.outputs.channel == 'stable' }} + uses: actions/github-script@v7 + env: + FROM_BETA: ${{ steps.contract.outputs.from_beta }} + FROM_BETA_COMMIT: ${{ steps.contract.outputs.from_beta_commit }} + with: + script: | + const { owner, repo } = context.repo; + const expectedAssets = new Set([ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", + "dws-skills.zip", + "checksums.txt", + ]); + const { data: release } = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: process.env.FROM_BETA, + }); + if (release.draft || !release.prerelease || !release.immutable) { + core.setFailed(`${process.env.FROM_BETA} is not a published immutable prerelease`); + return; + } + const assetNames = release.assets.map((asset) => asset.name); + const names = new Set(assetNames); + const missing = [...expectedAssets].filter((name) => !names.has(name)); + const extra = assetNames.filter((name) => !expectedAssets.has(name)); + if ( + missing.length > 0 || + extra.length > 0 || + assetNames.length !== expectedAssets.size + ) { + core.setFailed( + `beta release asset set differs: missing=${missing.join(",")} extra=${extra.join(",")}`, + ); + 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: Set up Go uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Test release automation + run: go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease' + + - name: Verify module files are tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: Verify released command tree + env: + PREVIOUS_STABLE: ${{ steps.contract.outputs.previous_stable }} + run: | + set -eu + test -n "$PREVIOUS_STABLE" + ./scripts/policy/check-command-compatibility.sh \ + --base-ref HEAD \ + --stable-ref "$PREVIOUS_STABLE" + + release: + name: Build signed release artifacts + if: ${{ github.event_name == 'push' }} + needs: [release-contract] + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch release branch and official tags + run: | + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + + - name: Revalidate contract and render release notes + run: | + ./scripts/release/release-contract.sh \ + --channel "${{ needs.release-contract.outputs.channel }}" \ + --version "$GITHUB_REF_NAME" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + - name: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip @@ -94,11 +290,11 @@ jobs: echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV" echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV" - - name: Run GoReleaser + - name: Build release artifacts without publishing uses: goreleaser/goreleaser-action@v6 with: - version: "~> v2" - args: release --clean + version: "v2.16.0" + args: release --clean --skip=publish --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -106,6 +302,7 @@ jobs: 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_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }} - name: Remove Apple Developer ID certificate @@ -114,14 +311,16 @@ jobs: rm -f "$RUNNER_TEMP/dws-developer-id.p12" rm -f "$RUNNER_TEMP/dws-developer-id-password" - # GoReleaser uploads the original archives to a Draft before - # post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed - # file, verify the Draft digests, and keep it private for Apple validation. - - name: Upload finalized signed assets to release + - name: Verify final release artifacts + run: ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + + - name: Verify npm package before sealing GitHub Release + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + + - name: Reverify remote tag before sealing GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DWS_PUBLISH_RELEASE: "false" - run: ./scripts/release/finalize-github-release.sh + run: ./scripts/release/verify-github-tag-authority.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" - name: Preserve finalized distribution files uses: actions/upload-artifact@v4 @@ -132,24 +331,19 @@ jobs: retention-days: 1 verify-darwin-signatures: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }} + name: Verify Apple Developer ID signatures + if: ${{ github.event_name == 'push' }} needs: release runs-on: macos-latest timeout-minutes: 10 - + permissions: + contents: read steps: - - name: Download finalized Darwin assets from Draft release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - mkdir -p dist - gh release download "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist \ - --pattern 'dws-darwin-amd64.tar.gz' \ - --pattern 'dws-darwin-arm64.tar.gz' \ - --clobber + - name: Restore finalized distribution files + uses: actions/download-artifact@v4 + with: + name: finalized-release-dist + path: dist - name: Verify finalized Darwin signatures with Apple codesign run: | @@ -165,69 +359,36 @@ jobs: done publish-release: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }} - needs: - - release - - verify-darwin-signatures + name: Publish immutable GitHub Release + if: ${{ github.event_name == 'push' }} + needs: [release-contract, release, verify-darwin-signatures] runs-on: ubuntu-latest - # The optional Gitee fallback has its own bounded retry budget and may be - # enabled during a cross-border incident. Normal releases skip that step. - timeout-minutes: 120 - + timeout-minutes: 30 + permissions: + contents: write steps: - - name: Check out repository + - name: Check out sealed release source uses: actions/checkout@v4 - - - name: Restore finalized distribution files - uses: actions/download-artifact@v4 with: - name: finalized-release-dist - path: dist + fetch-depth: 0 - - name: Publish verified Draft release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false + - name: Fetch release branch and official tags + run: | + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' - - name: Open stable Homebrew formula PR - # Beta builds must never replace the stable Homebrew formula. Formula - # updates use the normal PR path instead of writing main from a tag job. - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }} - run: ./scripts/release/publish-homebrew-formula.sh - env: - 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 }}" - - - name: Open beta Homebrew formula PR - # Keep beta in a separately named, keg-only Formula so it cannot replace the - # stable dws link for ordinary Homebrew users. - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }} - run: ./scripts/release/publish-homebrew-formula.sh - env: - DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb - DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb - 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 }}" - - - name: Sync release to China OSS mirror - # 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。 - # 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。 - run: ./scripts/release/sync-to-oss.sh - env: - VERSION: ${{ github.ref_name }} - OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} - OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} - OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} - OSS_BUCKET: ${{ secrets.OSS_BUCKET }} - OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + - name: Revalidate contract and render release notes + run: | + ./scripts/release/release-contract.sh \ + --channel "${{ needs.release-contract.outputs.channel }}" \ + --version "$GITHUB_REF_NAME" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" - name: Setup Node.js uses: actions/setup-node@v4 @@ -235,22 +396,343 @@ jobs: node-version: "20" registry-url: "https://registry.npmjs.org" - - name: Publish stable to npm - # 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。 - # 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。 - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Publish prerelease to npm beta - # 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。 - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public --tag beta + - name: Restore finalized distribution files + uses: actions/download-artifact@v4 + with: + name: finalized-release-dist + path: dist + + - name: Verify finalized release artifacts before publication + run: ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + + - name: Verify npm package before sealing GitHub Release + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + + - 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" + + - name: Publish or reuse immutable GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} + run: | + set -eu + expected_prerelease=false + prerelease_flag="" + if [ "$RELEASE_CHANNEL" = "prerelease" ]; then + expected_prerelease=true + 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 [ "$is_prerelease" != "$expected_prerelease" ]; then + echo "Existing release channel does not match $RELEASE_CHANNEL." >&2 + exit 1 + fi + 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" \ + --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)" + 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 + published_dir="$RUNNER_TEMP/published-release" + mkdir -p "$published_dir" + gh release download "$GITHUB_REF_NAME" \ + --dir "$published_dir" \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + DWS_PACKAGE_DIST_DIR="$published_dir" \ + ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do + 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" + 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" \ + --notes-file "$RUNNER_TEMP/release-notes.md" + else + # shellcheck disable=SC2086 + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --draft \ + --title "$GITHUB_REF_NAME" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + $prerelease_flag + fi + gh release upload "$GITHUB_REF_NAME" \ + 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" + sealed_upload="$RUNNER_TEMP/sealed-upload" + rm -rf "$sealed_upload" + mkdir -p "$sealed_upload" + gh release download "$GITHUB_REF_NAME" \ + --dir "$sealed_upload" \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + DWS_PACKAGE_DIST_DIR="$sealed_upload" \ + ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do + local_asset="dist/$(basename "$remote_asset")" + cmp -s "$local_asset" "$remote_asset" || { + echo "Uploaded GitHub asset differs from local sealed bytes: $(basename "$remote_asset")" >&2 + exit 1 + } + done + gh release edit "$GITHUB_REF_NAME" --draft=false + + - name: Require immutable published GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + immutable="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \ + --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" + + - name: Reverify exact immutable npm package + run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$GITHUB_REF_NAME" + + publish-channels: + name: Publish npm and mirrors + if: ${{ github.event_name == 'push' }} + needs: [release-contract, publish-release] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Check out sealed release source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch release branch and official tags + run: | + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + + - name: Revalidate sealed contract + run: | + ./scripts/release/release-contract.sh \ + --channel "${{ needs.release-contract.outputs.channel }}" \ + --version "$GITHUB_REF_NAME" \ + --context ci \ + --remote origin \ + --branch main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Download and verify immutable GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + release_state="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" \ + --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" + mkdir -p dist + gh release download "$GITHUB_REF_NAME" \ + --dir dist \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + ./scripts/release/verify-release-artifacts.sh "$GITHUB_REF_NAME" + DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + ./scripts/release/stage-npm-package.sh "$GITHUB_REF_NAME" + + - name: Verify immutable npm package without publication credentials + run: | + ./scripts/release/verify-package-managers.sh \ + --npm-only \ + --expected-version "$GITHUB_REF_NAME" + + - name: Inspect npm channel state + id: npm-state + if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} + env: + RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} + run: | + set -eu + . ./scripts/release/release-lib.sh + semver="${GITHUB_REF_NAME#v}" + npm_tag=latest + if [ "$RELEASE_CHANNEL" = "prerelease" ]; then npm_tag=beta; fi + current="$(npm view dingtalk-workspace-cli "dist-tags.$npm_tag")" + local_integrity="$( + ./scripts/release/pack-npm-package.sh \ + "$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \ + "$RUNNER_TEMP/dingtalk-workspace-cli.tgz" + )" + version_error="$RUNNER_TEMP/npm-version-error.log" + if existing="$(npm view "dingtalk-workspace-cli@$semver" version 2>"$version_error")"; then + if [ "$existing" != "$semver" ]; then + echo "npm returned unexpected version $existing for $semver" >&2 + exit 1 + fi + elif grep -Eq 'E404|404 Not Found' "$version_error"; then + existing="" + else + cat "$version_error" >&2 + exit 1 + fi + if [ -n "$existing" ]; then + remote_integrity="$(npm view "dingtalk-workspace-cli@$semver" dist.integrity)" + if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then + echo "npm $semver integrity differs from the sealed package." >&2 + echo "local: $local_integrity" >&2 + echo "remote: $remote_integrity" >&2 + exit 1 + fi + advance_channel=false + if [ "$current" = "$semver" ]; then + : + elif [ -n "$current" ] && release_version_is_greater "v$current" "$GITHUB_REF_NAME"; 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 + advance_channel=true + else + echo "Cannot reconcile npm $npm_tag=$current with $GITHUB_REF_NAME" >&2 + exit 1 + fi + echo "published=true" >> "$GITHUB_OUTPUT" + echo "advance_channel=$advance_channel" >> "$GITHUB_OUTPUT" + echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT" + echo "semver=$semver" >> "$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 + exit 1 + fi + echo "published=false" >> "$GITHUB_OUTPUT" + echo "advance_channel=false" >> "$GITHUB_OUTPUT" + echo "npm_tag=$npm_tag" >> "$GITHUB_OUTPUT" + echo "semver=$semver" >> "$GITHUB_OUTPUT" + + - name: Publish missing version to npm channel + if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.published != 'true' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }} + SEMVER: ${{ steps.npm-state.outputs.semver }} + run: | + set -eu + . ./scripts/release/release-lib.sh + current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")" + if [ -n "$current" ] && [ "$current" != "$SEMVER" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then + echo "Refusing to move npm $NPM_TAG backwards from v$current to v$SEMVER" >&2 + exit 1 + fi + npm publish "$RUNNER_TEMP/dingtalk-workspace-cli.tgz" \ + --access public \ + --tag "$NPM_TAG" \ + --ignore-scripts \ + --registry=https://registry.npmjs.org + + - name: Advance npm channel for an existing exact version + if: ${{ github.repository_owner == 'DingTalk-Real-AI' && steps.npm-state.outputs.advance_channel == 'true' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }} + SEMVER: ${{ steps.npm-state.outputs.semver }} + run: | + set -eu + . ./scripts/release/release-lib.sh + current="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")" + if [ "$current" = "$SEMVER" ]; then exit 0; fi + if [ -n "$current" ] && release_version_is_greater "v$current" "v$SEMVER"; then + echo "npm $NPM_TAG advanced to newer v$current; leaving it unchanged." + exit 0 + fi + if [ -n "$current" ] && ! release_version_is_greater "v$SEMVER" "v$current"; then + echo "Refusing unsafe npm $NPM_TAG transition from v$current to v$SEMVER" >&2 + exit 1 + fi + npm dist-tag add "dingtalk-workspace-cli@$SEMVER" "$NPM_TAG" \ + --registry=https://registry.npmjs.org + + - name: Verify npm channel delivery + if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} + env: + NPM_TAG: ${{ steps.npm-state.outputs.npm_tag }} + SEMVER: ${{ steps.npm-state.outputs.semver }} + run: | + set -eu + . ./scripts/release/release-lib.sh + delivered="$(npm view dingtalk-workspace-cli "dist-tags.$NPM_TAG")" + if [ "$delivered" = "$SEMVER" ]; then exit 0; fi + release_version_is_greater "v$delivered" "v$SEMVER" || { + echo "npm $NPM_TAG=$delivered does not deliver or supersede v$SEMVER" >&2 + exit 1 + } + echo "npm $NPM_TAG already supersedes this historical rerun at v$delivered." + + - name: Sync release artifacts to China OSS mirror + run: ./scripts/release/sync-to-oss.sh + env: + VERSION: ${{ github.ref_name }} + 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 }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + DWS_REQUIRE_OSS: ${{ github.repository_owner == 'DingTalk-Real-AI' && '1' || '0' }} mirror-gitee-release: # Keep the optional cross-border fallback out of publish-release so all @@ -274,52 +756,160 @@ jobs: path: dist - name: Mirror release to Gitee (China) - timeout-minutes: 100 + if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }} + timeout-minutes: 20 run: ./scripts/release/sync-to-gitee.sh env: VERSION: ${{ github.ref_name }} 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 != '' }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 + permissions: + actions: read + contents: read steps: - - name: Check out repository + - name: Check out trusted release tooling uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + path: tooling - - name: Download GitHub release assets + - name: Validate repair version + id: version + working-directory: tooling env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.repair_npm_version }} run: | set -eu - mkdir -p dist - gh release download "${{ inputs.repair_npm_version }}" \ - --repo "${{ github.repository }}" \ - --dir dist \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber - ls -la dist + . ./scripts/release/release-lib.sh + if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then + echo "Invalid release version: $VERSION" >&2 + exit 2 + fi + echo "semver=${VERSION#v}" >> "$GITHUB_OUTPUT" - - name: Stage npm package - run: | - set -eu - version="${{ inputs.repair_npm_version }}" - semver="${version#v}" - pkg_root="dist/npm/dingtalk-workspace-cli" - rm -rf "$pkg_root" - mkdir -p "$pkg_root/assets" "$pkg_root/bin" - cp build/npm/install.js "$pkg_root/install.js" - cp build/npm/bin/dws.js "$pkg_root/bin/dws.js" - cp build/npm/README.md "$pkg_root/README.md" - sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json" - cp dist/dws-* "$pkg_root/assets/" - cp dist/checksums.txt "$pkg_root/assets/" - test -f "$pkg_root/assets/dws-skills.zip" - cat "$pkg_root/package.json" + - name: Verify immutable release authority + id: authority + uses: actions/github-script@v7 + env: + VERSION: ${{ inputs.repair_npm_version }} + with: + script: | + const { owner, repo } = context.repo; + const version = process.env.VERSION; + const ref = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if (ref.data.object.type !== "tag") { + core.setFailed(`${version} is not an annotated tag`); + return; + } + const tag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.data.object.sha, + }); + if (tag.data.tag !== version || tag.data.object.type !== "commit") { + core.setFailed(`${version} does not resolve to one annotated commit tag`); + return; + } + const commitSha = tag.data.object.sha; + const defaultBranch = context.payload.repository.default_branch; + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${commitSha}...${branch.data.commit.sha}`, + }); + if (!["ahead", "identical"].includes(comparison.data.status)) { + core.setFailed(`${version} is not contained in ${defaultBranch}`); + return; + } + const release = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: version, + }); + if ( + release.data.tag_name !== version || + release.data.draft || + !release.data.immutable + ) { + core.setFailed(`${version} is not a public immutable GitHub Release`); + return; + } + const expectedAssets = new Set([ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", + "dws-skills.zip", + "checksums.txt", + ]); + const assetNames = release.data.assets.map((asset) => asset.name); + if ( + assetNames.length !== expectedAssets.size || + assetNames.some((name) => !expectedAssets.has(name)) || + new Set(assetNames).size !== expectedAssets.size + ) { + core.setFailed(`${version} does not contain the exact release asset set`); + return; + } + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: "release.yml", + branch: version, + event: "push", + status: "completed", + per_page: 100, + }); + let passed = false; + for (const run of runs) { + if (run.head_sha !== commitSha || run.head_branch !== version) continue; + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: run.id, + filter: "all", + per_page: 100, + }); + if ( + jobs.some( + (job) => + job.name === "Publish immutable GitHub Release" && + job.conclusion === "success", + ) + ) { + passed = true; + break; + } + } + if (!passed) { + core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`); + return; + } + core.setOutput("commit_sha", commitSha); + + - name: Check out target release source + uses: actions/checkout@v4 + with: + ref: ${{ steps.authority.outputs.commit_sha }} + path: release-source - name: Setup Node.js uses: actions/setup-node@v4 @@ -327,16 +917,74 @@ jobs: node-version: "20" registry-url: "https://registry.npmjs.org" - - name: Publish stable to npm - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public + - name: Download and verify GitHub release assets env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.repair_npm_version }} + run: | + set -eu + mkdir -p dist + gh release download "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION" - - name: Publish prerelease to npm beta - if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }} - working-directory: dist/npm/dingtalk-workspace-cli - run: npm publish --access public --tag beta + - name: Stage and verify target npm package + env: + VERSION: ${{ inputs.repair_npm_version }} + run: | + set -eu + DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE/release-source" \ + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tooling/scripts/release/stage-npm-package.sh" "$VERSION" + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tooling/scripts/release/verify-package-managers.sh" \ + --npm-only \ + --expected-version "$VERSION" \ + --skip-skill-targets + + - name: Inspect npm version + id: npm + env: + SEMVER: ${{ steps.version.outputs.semver }} + run: | + set -eu + local_integrity="$( + "$GITHUB_WORKSPACE/tooling/scripts/release/pack-npm-package.sh" \ + "$GITHUB_WORKSPACE/dist/npm/dingtalk-workspace-cli" \ + "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" + )" + version_error="$RUNNER_TEMP/npm-version-error.log" + if existing="$(npm view "dingtalk-workspace-cli@$SEMVER" version 2>"$version_error")"; then + if [ "$existing" != "$SEMVER" ]; then + echo "npm returned unexpected version $existing for $SEMVER" >&2 + exit 1 + fi + elif grep -Eq 'E404|404 Not Found' "$version_error"; then + existing="" + else + cat "$version_error" >&2 + exit 1 + fi + if [ "$existing" = "$SEMVER" ]; then + remote_integrity="$(npm view "dingtalk-workspace-cli@$SEMVER" dist.integrity)" + if [ -z "$remote_integrity" ] || [ "$remote_integrity" != "$local_integrity" ]; then + echo "npm $SEMVER integrity differs from the immutable release package." >&2 + echo "local: $local_integrity" >&2 + echo "remote: $remote_integrity" >&2 + exit 1 + fi + echo "published=true" >> "$GITHUB_OUTPUT" + else + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Backfill exact version to npm + if: ${{ steps.npm.outputs.published != 'true' }} + run: npm publish "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" --access public --tag backfill --ignore-scripts --registry=https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sync-release-to-gitee.yml b/.github/workflows/sync-release-to-gitee.yml deleted file mode 100644 index 6922d30c..00000000 --- a/.github/workflows/sync-release-to-gitee.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Sync release to Gitee - -# Manually mirror a published GitHub release's assets to the matching Gitee -# release. Use this to repair a release whose Gitee mirror is incomplete (e.g. -# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync -# step — it does not run GoReleaser and does not touch the GitHub release, so -# there is no release outage. The sync script skips assets already on Gitee, so -# this only uploads what is missing. - -on: - workflow_dispatch: - inputs: - version: - description: "Release tag to mirror to Gitee (e.g. v1.0.42)" - required: true - type: string - -permissions: - contents: read - -jobs: - sync-gitee: - runs-on: ubuntu-latest - # Each step has its own ceiling. Their 115-minute sum leaves five minutes - # for runner scheduling/teardown inside this 120-minute job deadline. - timeout-minutes: 120 - steps: - - name: Check out repository - uses: actions/checkout@v4 - timeout-minutes: 5 - - - name: Download GitHub release assets - timeout-minutes: 10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -eu - mkdir -p dist - gh release download "${{ inputs.version }}" \ - --repo "${{ github.repository }}" \ - --dir dist \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber - ls -la dist - - - name: Mirror release to Gitee (China) - # Idempotent: uploads only assets not already present on the Gitee release. - timeout-minutes: 100 - run: ./scripts/release/sync-to-gitee.sh - env: - VERSION: ${{ inputs.version }} - GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} - GITEE_USER: ${{ secrets.GITEE_USER }} - GITEE_REPO: ${{ secrets.GITEE_REPO }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ce5ab1a4..ab1ceeed 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,19 +1,14 @@ # GoReleaser configuration for dws # Docs: https://goreleaser.com # -# To release: -# git tag -a v0.1.0 -m "Release v0.1.0" -# git push origin v0.1.0 +# To release, use scripts/release/release.sh. It seals main, validates the +# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish. # # To test locally (no publish): # goreleaser release --snapshot --clean version: 2 -before: - hooks: - - go mod tidy - builds: - main: ./cmd binary: dws diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad33e6e..8a7983b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,10 @@ This release seals the `v1.0.52` line with personal event subscriptions, a deter - **Complex Connect messages and attachments** (#606, #612) — rich-text messages retain all embedded pictures in order, queued turns keep every pending attachment, and unknown or future callback shapes reach each Agent backend with their message type and raw JSON instead of being discarded. Attachment recovery is locator-based, nested `chatRecord` pictures/audio/video/files can be recovered from message APIs after Stream ACK, and OpenCode uses a full-duration storyboard for large videos to avoid base64 OOMs while preserving the original download for the turn. - **macOS auth survives Keychain mode changes** (#597) — credential reads try existing compatible DEKs without creating key material, updates preserve the DEK that decrypted existing ciphertext, unreadable slots fail closed before token exchange, profile slots use the canonical auth backend, and `auth status` reports ciphertext/key mismatches instead of treating them as ordinary logout. Dedicated macOS race and Windows DPAPI coverage protect the cross-platform paths. +### Changed + +- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, and optional Gitee delivery with fail-closed recovery checks. + ## [1.0.51] - 2026-07-10 This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material. diff --git a/Makefile b/Makefile index 17e2608c..27a423aa 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ GO ?= go -DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp -POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go -POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" +REMOTE ?= +PUBLISH ?= 0 +YES ?= 0 -.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test lint fmt policy edition-test test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -28,8 +28,11 @@ help: @printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n" @printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n" @printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n" - @printf " make package - Build all release artifacts locally (goreleaser snapshot)\n" - @printf " make release - Build and publish a release via goreleaser\n" + @printf " make package - Build all release artifacts locally\n" + @printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n" + @printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n" + @printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n" + @printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n" @printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n" build: @@ -129,8 +132,8 @@ generate-schema-catalog: -output internal/cli/schema_catalog.json package: - @./scripts/dev/build-all.sh - @./scripts/release/post-goreleaser.sh + @version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh + @version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh publish-homebrew-formula: @./scripts/release/publish-homebrew-formula.sh @@ -138,6 +141,32 @@ publish-homebrew-formula: setup-hooks: @git config core.hooksPath scripts/hooks 2>/dev/null || true +changelog-pre: + @test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2) + @./scripts/release/prepare-changelog.sh prerelease "$(VERSION)" + +changelog-stable: + @test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2) + @test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2) + @./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" + +release-pre: + @test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2) + @test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2) + @args=""; \ + if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \ + if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \ + ./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args + +release-stable: + @test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2) + @test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2) + @test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2) + @args=""; \ + if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \ + if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \ + ./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args + release: - goreleaser release --clean - @./scripts/release/post-goreleaser.sh + @printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2 + @exit 2 diff --git a/build/homebrew.rb.tmpl b/build/homebrew.rb.tmpl index 0b10c88c..9575adcc 100644 --- a/build/homebrew.rb.tmpl +++ b/build/homebrew.rb.tmpl @@ -2,6 +2,7 @@ class __CLASS_NAME__ < Formula desc "Install locally built DingTalk workspace CLI artifacts for verification" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" url "__ARCHIVE_URL__" + version "__VERSION__" sha256 "__ARCHIVE_SHA256__" license "Apache-2.0" __KEG_ONLY_LINE__ @@ -30,11 +31,42 @@ __KEG_ONLY_LINE__ end end - def caveats - <<~EOS - Agent Skills are bundled in #{pkgshare}/skills/dws. - Run `dws skill setup` to install them into your Agent directories. - EOS + def post_install + require "fileutils" + + skill_root = pkgshare/"skills/dws" + entries = Dir["#{skill_root}/*"] + return if entries.empty? + + skill_home_override = "__SKILL_HOME_OVERRIDE__" + skill_home = skill_home_override.empty? ? Dir.home : skill_home_override + targets = [ + Pathname.new(File.join(skill_home, ".agents/skills/dws")), + Pathname.new(File.join(skill_home, ".claude/skills/dws")), + Pathname.new(File.join(skill_home, ".cursor/skills/dws")), + Pathname.new(File.join(skill_home, ".qoder/skills/dws")), + Pathname.new(File.join(skill_home, ".qoderwork/skills/dws")), + Pathname.new(File.join(skill_home, ".gemini/skills/dws")), + Pathname.new(File.join(skill_home, ".codex/skills/dws")), + Pathname.new(File.join(skill_home, ".github/skills/dws")), + Pathname.new(File.join(skill_home, ".windsurf/skills/dws")), + Pathname.new(File.join(skill_home, ".augment/skills/dws")), + Pathname.new(File.join(skill_home, ".cline/skills/dws")), + Pathname.new(File.join(skill_home, ".amp/skills/dws")), + Pathname.new(File.join(skill_home, ".kiro/skills/dws")), + Pathname.new(File.join(skill_home, ".trae/skills/dws")), + Pathname.new(File.join(skill_home, ".openclaw/skills/dws")), + Pathname.new(File.join(skill_home, ".hermes/skills/dws")), + ] + + targets.each_with_index do |dest, index| + parent_gate = dest.parent.parent + next if index > 0 && !parent_gate.directory? + + FileUtils.rm_rf(dest) + FileUtils.mkdir_p(dest) + FileUtils.cp_r(entries, dest) + end end test do diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..f37a65ed --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,113 @@ +# 发布手册(预发 / 正式) + +发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。 + +发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 + +## 日常只用一个入口 + +安装发布 Skill 后直接运行: + +```bash +dws-release +``` + +零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中: + +```bash +dws-release config --remote origin +``` + +之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。 + +## 发布模型 + +```text +main 上的候选代码 + beta CHANGELOG + → vX.Y.Z-beta.N(预发验证) + → 只允许补正式 CHANGELOG,源码不得再变化 + → vX.Y.Z(正式发布) +``` + +正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 + +## 预发发布 + +运行统一入口: + +```bash +dws-release v1.2.3-beta.1 +``` + +如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检: + +```bash +dws-release v1.2.3-beta.1 +``` + +预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布: + +```bash +dws-release v1.2.3-beta.1 --publish +``` + +命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。 + +## 正式发布 + +beta 验证通过后,运行正式版入口: + +```bash +dws-release v1.2.3 --from-beta v1.2.3-beta.1 +``` + +首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`: + +```bash +dws-release v1.2.3 --from-beta v1.2.3-beta.1 +dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish +``` + +`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 + +## CHANGELOG 契约 + +每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节: + +```markdown +## [1.2.3-beta.1] - 2026-07-11 + +### Changed + +- 本次 beta 验证的用户可见变化。 +``` + +正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。 + +## CI/CD 保证 + +- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。 +- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。 +- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 +- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。 +- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。 +- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。 +- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。 +- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。 + +npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。 + +OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。 + +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 平台配置两项不可由脚本替代的规则: + +- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。 +- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。 + +immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/README.md b/scripts/README.md index 381a2dd5..ce386a83 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,15 +6,15 @@ These repo-local entrypoints are the supported shell entrypoints for building, t - `make test`: run `go test ./...` - `make lint`: run formatting checks and required `golangci-lint` - `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/` -- `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first) -- `make package`: build all release artifacts locally via `goreleaser --snapshot` -- `make release`: build and publish a release via `goreleaser` +- `make package`: build all release artifacts locally +- `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag) +- `make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N`: validate a stable promotion (`PUBLISH=1` pushes its tag) Script groups: - Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh` - Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` -- Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` -- Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace -- Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh` +- Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref `, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` +- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md) +- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh` diff --git a/scripts/release/build-and-publish-gitee.sh b/scripts/release/build-and-publish-gitee.sh index fd8800e2..2f265a2a 100755 --- a/scripts/release/build-and-publish-gitee.sh +++ b/scripts/release/build-and-publish-gitee.sh @@ -1,52 +1,7 @@ -#!/usr/bin/env bash -# Build release artifacts locally in the current runner and publish them to Gitee. - +#!/bin/sh set -eu -ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" -SCRIPT_ROOT="$ROOT" -cd "$SCRIPT_ROOT" - -VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}" -[ -n "$VERSION" ] || { - echo "error: VERSION is required or HEAD must be an exact release tag" >&2 - exit 1 -} -case "$VERSION" in - v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;; - *) TAG="v$VERSION"; SEMVER="$VERSION" ;; -esac - -git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true -target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)" -[ -n "$target_commit" ] || { - echo "error: could not resolve tag ${TAG}" >&2 - exit 1 -} - -current_commit="$(git rev-parse HEAD)" -WORKDIR="$SCRIPT_ROOT" -cleanup_worktree() { - if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then - git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR" - fi -} -trap cleanup_worktree EXIT - -if [ "$current_commit" != "$target_commit" ]; then - WORKDIR="$(mktemp -d)" - rm -rf "$WORKDIR" - git worktree add --detach "$WORKDIR" "$TAG" - mkdir -p "$WORKDIR/scripts/release" - for release_script in publish-gitee-local.sh reconcile-gitee-assets.sh; do - cp "$SCRIPT_ROOT/scripts/release/$release_script" "$WORKDIR/scripts/release/$release_script" - chmod +x "$WORKDIR/scripts/release/$release_script" - done -fi - -cd "$WORKDIR" - -echo "==> Building ${TAG} locally for Gitee" -VERSION="$SEMVER" ./scripts/dev/build-all.sh -DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh -VERSION="$TAG" ./scripts/release/publish-gitee-local.sh +printf '%s\n' \ + 'Direct Gitee release builds are disabled.' \ + 'Use make release-pre/release-stable; release.yml mirrors the exact immutable GitHub assets.' >&2 +exit 2 diff --git a/scripts/release/dws-release.sh b/scripts/release/dws-release.sh new file mode 100755 index 00000000..d030d975 --- /dev/null +++ b/scripts/release/dws-release.sh @@ -0,0 +1,322 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +VERSION="" +FROM_BETA="" +REMOTE="" +MODE="check" +MODE_SET=0 +WIZARD=0 + +usage() { + cat >&2 <<'EOF' +usage: dws-release [version] [options] + dws-release config [--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 +full guarded preflight; add --publish to publish after the preflight succeeds. + +Options: + --from-beta Required stable promotion baseline + --remote Override the configured release remote + --check Validate only (default) + --publish Run the preflight, then create and push the tag + -h, --help Show this help + +Examples: + dws-release config --remote origin + dws-release v1.2.3-beta.1 + dws-release v1.2.3-beta.1 --publish + dws-release v1.2.3 --from-beta v1.2.3-beta.1 + dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish +EOF +} + +die_usage() { + printf '%s\n' "$1" >&2 + usage + exit 2 +} + +is_interactive() { + [ -t 0 ] && [ -t 1 ] +} + +repository_identity() { + identity_url="$1" + case "$identity_url" in + https://github.com/*) identity_path="${identity_url#https://github.com/}" ;; + http://github.com/*) identity_path="${identity_url#http://github.com/}" ;; + git://github.com/*) identity_path="${identity_url#git://github.com/}" ;; + git@github.com:*) identity_path="${identity_url#git@github.com:}" ;; + ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;; + *) printf '%s\n' "$identity_url"; return 0 ;; + esac + identity_path="${identity_path%/}" + identity_path="${identity_path%.git}" + printf 'github.com/%s\n' "$identity_path" +} + +release_remote_identity() { + identity_remote="$1" + identity_fetch_url="$(git remote get-url "$identity_remote" 2>/dev/null)" || { + printf 'configured release remote does not exist: %s\n' "$identity_remote" >&2 + return 1 + } + identity_push_urls="$(git remote get-url --push --all "$identity_remote" 2>/dev/null)" || { + printf 'could not resolve push URL for release remote: %s\n' "$identity_remote" >&2 + return 1 + } + identity_push_count="$(printf '%s\n' "$identity_push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" + [ "$identity_push_count" -eq 1 ] || { + printf 'release remote must have exactly one push URL: %s\n' "$identity_remote" >&2 + return 1 + } + identity_push_url="$(printf '%s\n' "$identity_push_urls" | sed -n '1p')" + identity_fetch="$(repository_identity "$identity_fetch_url")" + identity_push="$(repository_identity "$identity_push_url")" + [ "$identity_fetch" = "$identity_push" ] || { + printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' \ + "$identity_fetch_url" "$identity_push_url" >&2 + return 1 + } + printf '%s\n' "$identity_fetch" +} + +set_mode() { + requested_mode="$1" + if [ "$MODE_SET" -eq 1 ] && [ "$MODE" != "$requested_mode" ]; then + die_usage '--check and --publish cannot be used together' + fi + MODE="$requested_mode" + MODE_SET=1 +} + +require_remote() { + if [ -z "$REMOTE" ]; then + REMOTE="$(git config --local --get dws.releaseRemote 2>/dev/null || true)" + fi + if [ -z "$REMOTE" ] && is_interactive; then + printf 'Configured remotes:\n' >&2 + git remote -v >&2 + printf 'Release remote: ' >&2 + IFS= read -r REMOTE + fi + [ -n "$REMOTE" ] || { + printf '%s\n' 'release remote is not configured; run: dws-release config --remote ' >&2 + exit 1 + } + expected_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)" + [ -n "$expected_repository" ] || { + printf '%s\n' 'release repository identity is not configured; rerun: dws-release config --remote ' >&2 + exit 1 + } + actual_repository="$(release_remote_identity "$REMOTE")" || exit 1 + [ "$actual_repository" = "$expected_repository" ] || { + printf 'release remote %s changed repository identity:\n configured: %s\n current: %s\n' \ + "$REMOTE" "$expected_repository" "$actual_repository" >&2 + printf '%s\n' 'Review the remote and run dws-release config again only if this change is intentional.' >&2 + exit 1 + } +} + +sync_main_if_safe() { + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ "$current_branch" = "main" ] || { + printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2 + exit 1 + } + [ -z "$(git status --porcelain --untracked-files=all)" ] || { + printf '%s\n' 'release main worktree must be clean before synchronization' >&2 + exit 1 + } + + printf '==> Synchronizing %s/main\n' "$REMOTE" + git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main" + remote_main="refs/remotes/$REMOTE/main" + head_commit="$(git rev-parse HEAD)" + remote_commit="$(git rev-parse "$remote_main^{commit}")" + if [ "$head_commit" = "$remote_commit" ]; then + return 0 + fi + if git merge-base --is-ancestor HEAD "$remote_main"; then + git merge --ff-only "$remote_main" + return 0 + fi + if git merge-base --is-ancestor "$remote_main" HEAD; then + printf 'local main is ahead of %s/main; publish only after the commits are reviewed and pushed\n' "$REMOTE" >&2 + else + printf 'local main has diverged from %s/main; resolve it explicitly before release\n' "$REMOTE" >&2 + fi + exit 1 +} + +configure_remote() { + shift + config_remote="" + while [ "$#" -gt 0 ]; do + case "$1" in + --remote) + [ "$#" -ge 2 ] || die_usage '--remote requires a value' + config_remote="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) die_usage "unknown config argument: $1" ;; + esac + done + + cd "$ROOT" + if [ -z "$config_remote" ]; then + configured="$(git config --local --get dws.releaseRemote 2>/dev/null || true)" + configured_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)" + printf 'Configured release remote: %s\n' "${configured:-none}" + printf 'Configured repository: %s\n' "${configured_repository:-none}" + git remote -v + exit 0 + fi + configured_repository="$(release_remote_identity "$config_remote")" || exit 1 + git config --local dws.releaseRemote "$config_remote" + git config --local dws.releaseRepository "$configured_repository" + printf 'Configured release remote: %s\n' "$config_remote" + printf 'Configured repository: %s\n' "$configured_repository" + exit 0 +} + +if [ "${1:-}" = "config" ]; then + configure_remote "$@" +fi + +[ "$#" -gt 0 ] || WIZARD=1 +while [ "$#" -gt 0 ]; do + case "$1" in + --from-beta) + [ "$#" -ge 2 ] || die_usage '--from-beta requires a value' + FROM_BETA="$2" + shift 2 + ;; + --remote) + [ "$#" -ge 2 ] || die_usage '--remote requires a value' + REMOTE="$2" + shift 2 + ;; + --check) set_mode check; shift ;; + --publish) set_mode publish; shift ;; + -h|--help) usage; exit 0 ;; + -*) die_usage "unknown argument: $1" ;; + *) + [ -z "$VERSION" ] || die_usage "unexpected argument: $1" + VERSION="$1" + shift + ;; + esac +done + +if [ -z "$VERSION" ]; then + is_interactive || { usage; exit 2; } + printf 'Release version (vX.Y.Z-beta.N or vX.Y.Z): ' >&2 + IFS= read -r VERSION +fi + +CHANNEL="$(release_channel_for_version "$VERSION")" || exit 2 +if [ "$CHANNEL" = "stable" ]; then + if [ -z "$FROM_BETA" ] && is_interactive; then + printf 'Validated beta tag for %s: ' "$VERSION" >&2 + IFS= read -r FROM_BETA + fi + [ -n "$FROM_BETA" ] || die_usage 'stable release requires --from-beta ' + release_validate_version_channel prerelease "$FROM_BETA" || exit 2 + [ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || { + printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 + exit 2 + } +else + [ -z "$FROM_BETA" ] || die_usage '--from-beta is only valid for stable releases' +fi + +if [ "$WIZARD" -eq 1 ]; then + printf 'Mode [check/publish] (default: check): ' >&2 + IFS= read -r selected_mode + case "$selected_mode" in + ''|check) MODE=check ;; + publish) MODE=publish ;; + *) die_usage "invalid mode: $selected_mode" ;; + esac +fi +[ -z "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-}" ] || { + printf '%s\n' 'DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE is test-only and cannot be set through dws-release' >&2 + exit 1 +} +[ -z "${DWS_RELEASE_OFFICIAL_TAGS_URL:-}" ] || { + printf '%s\n' 'DWS_RELEASE_OFFICIAL_TAGS_URL cannot override release authority through dws-release' >&2 + exit 1 +} +[ -z "${DWS_RELEASE_OFFICIAL_REPOSITORY:-}" ] || { + printf '%s\n' 'DWS_RELEASE_OFFICIAL_REPOSITORY cannot override release authority through dws-release' >&2 + exit 1 +} + +cd "$ROOT" +CHANGELOG="$ROOT/CHANGELOG.md" +[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; } +MAIN_SYNCED=0 +current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +if [ "$current_branch" = "main" ]; then + require_remote + sync_main_if_safe + MAIN_SYNCED=1 +fi +semver="$(release_semver "$VERSION")" +section_count="$(awk -v wanted="$semver" ' + BEGIN { prefix = "## [" wanted "] - "; count = 0 } + index($0, prefix) == 1 { count++ } + END { print count } +' "$CHANGELOG")" + +if [ "$section_count" -eq 0 ]; then + printf '==> Preparing the missing CHANGELOG section for %s\n' "$VERSION" + if [ "$CHANNEL" = "stable" ]; then + "$SCRIPT_DIR/prepare-changelog.sh" stable "$VERSION" --from-beta "$FROM_BETA" + else + "$SCRIPT_DIR/prepare-changelog.sh" prerelease "$VERSION" + fi + printf '\nCHANGELOG preparation is complete; publishing intentionally stopped.\n' + printf 'Replace TODO, review the diff, commit it, and merge it to main. Then run dws-release for %s again.\n' "$VERSION" + exit 0 +fi + +notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-entry.XXXXXX")" +cleanup() { rm -f "$notes_tmp"; } +trap cleanup EXIT HUP INT TERM +release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp" + +if [ "$MAIN_SYNCED" -ne 1 ]; then + require_remote + sync_main_if_safe +fi + +printf '==> DWS release entry\n' +printf ' mode: %s\n' "$MODE" +printf ' channel: %s\n' "$CHANNEL" +printf ' version: %s\n' "$VERSION" +printf ' remote: %s\n' "$REMOTE" +[ -z "$FROM_BETA" ] || printf ' beta: %s\n' "$FROM_BETA" + +set -- "$CHANNEL" "$VERSION" --remote "$REMOTE" +if [ -n "$FROM_BETA" ]; then + set -- "$@" --from-beta "$FROM_BETA" +fi +if [ "$MODE" = "publish" ]; then + set -- "$@" --publish +fi +cleanup +trap - EXIT HUP INT TERM +exec "$SCRIPT_DIR/release.sh" "$@" diff --git a/scripts/release/pack-npm-package.sh b/scripts/release/pack-npm-package.sh new file mode 100755 index 00000000..d04bd9b2 --- /dev/null +++ b/scripts/release/pack-npm-package.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +PACKAGE_DIR="${1:-}" +OUTPUT="${2:-}" + +[ -n "$PACKAGE_DIR" ] && [ -n "$OUTPUT" ] || { + printf 'usage: pack-npm-package.sh \n' >&2 + exit 2 +} +[ -f "$PACKAGE_DIR/package.json" ] || { + printf 'npm package manifest not found: %s/package.json\n' "$PACKAGE_DIR" >&2 + exit 1 +} +command -v npx >/dev/null 2>&1 || { printf 'npx is required\n' >&2; exit 1; } +command -v node >/dev/null 2>&1 || { printf 'node is required\n' >&2; exit 1; } +NPM_PACK_VERSION="10.9.2" + +output_dir="$(CDPATH= cd -- "$(dirname -- "$OUTPUT")" && pwd)" +output_name="$(basename -- "$OUTPUT")" +rm -f "$output_dir/$output_name" + +pack_json="$( + npx --yes --package "npm@$NPM_PACK_VERSION" -- \ + npm pack "$PACKAGE_DIR" --pack-destination "$output_dir" --json --ignore-scripts +)" +pack_metadata="$(printf '%s' "$pack_json" | node -e ' +let input = ""; +process.stdin.on("data", (chunk) => { input += chunk; }); +process.stdin.on("end", () => { + const entries = JSON.parse(input); + if (!Array.isArray(entries) || entries.length !== 1) { + throw new Error("npm pack did not return exactly one package"); + } + const entry = entries[0]; + if (!entry.filename || !entry.integrity) { + throw new Error("npm pack output is missing filename or integrity"); + } + process.stdout.write(`${entry.filename}\n${entry.integrity}\n`); +}); +')" +packed_name="$(printf '%s\n' "$pack_metadata" | sed -n '1p')" +integrity="$(printf '%s\n' "$pack_metadata" | sed -n '2p')" +[ -f "$output_dir/$packed_name" ] || { + printf 'npm pack did not create expected tarball: %s\n' "$packed_name" >&2 + exit 1 +} +if [ "$packed_name" != "$output_name" ]; then + mv "$output_dir/$packed_name" "$output_dir/$output_name" +fi +printf '%s\n' "$integrity" diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh index e0a0fcdd..d74f9143 100755 --- a/scripts/release/post-goreleaser.sh +++ b/scripts/release/post-goreleaser.sh @@ -90,36 +90,27 @@ resolve_release_base_url() { stage_npm_package() { version="$1" - pkg_root="$DIST_DIR/npm/dingtalk-workspace-cli" - - rm -rf "$pkg_root" - mkdir -p "$pkg_root/assets" "$pkg_root/bin" - - cp "$ROOT/build/npm/install.js" "$pkg_root/install.js" - cp "$ROOT/build/npm/bin/dws.js" "$pkg_root/bin/dws.js" - cp "$ROOT/build/npm/README.md" "$pkg_root/README.md" - sed "s|__VERSION__|$version|g" "$ROOT/build/npm/package.json.tmpl" > "$pkg_root/package.json" - - for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/dws-skills.zip; do - if [ -f "$artifact" ]; then - cp "$artifact" "$pkg_root/assets/" - fi - done + DWS_PACKAGE_SOURCE_ROOT="$ROOT" \ + DWS_PACKAGE_DIST_DIR="$DIST_DIR" \ + "$ROOT/scripts/release/stage-npm-package.sh" "$version" } # ---------- Homebrew formula staging ---------- render_homebrew_formula() { class_name="$1" - archive_url="$2" - skills_url="$3" - archive_sha="$4" - skills_sha="$5" - keg_only_line="$6" - output_path="$7" + formula_version="$2" + archive_url="$3" + skills_url="$4" + archive_sha="$5" + skills_sha="$6" + keg_only_line="$7" + output_path="$8" sed \ -e "s|__CLASS_NAME__|$class_name|g" \ + -e "s|__VERSION__|$formula_version|g" \ + -e "s|__SKILL_HOME_OVERRIDE__||g" \ -e "s|__ARCHIVE_URL__|$archive_url|g" \ -e "s|__ARCHIVE_SHA256__|$archive_sha|g" \ -e "s|__SKILLS_URL__|$skills_url|g" \ @@ -193,6 +184,7 @@ stage_homebrew_formula() { render_homebrew_formula \ "DingtalkWorkspaceCliLocal" \ + "$version" \ "file://$archive_path" \ "file://$DIST_DIR/dws-skills.zip" \ "$archive_sha" \ @@ -200,27 +192,12 @@ stage_homebrew_formula() { ' keg_only "Local verification formula to avoid linking conflicts"' \ "$formula_dir/dingtalk-workspace-cli-local.rb" - formula_class="DingtalkWorkspaceCli" - formula_path="$formula_dir/dingtalk-workspace-cli.rb" - keg_only_line="" - channel_caveat="" - case "$version" in - *-*) - formula_class="DingtalkWorkspaceCliBeta" - formula_path="$formula_dir/dingtalk-workspace-cli-beta.rb" - keg_only_line=' keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"' - channel_caveat=' This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.' - ;; - esac - - render_homebrew_release_formula \ - "$formula_class" \ + render_homebrew_formula \ + "DingtalkWorkspaceCli" \ "$version" \ - "$release_url_base" \ - "$(sha256_file "$darwin_amd64")" \ - "$(sha256_file "$darwin_arm64")" \ - "$(sha256_file "$linux_amd64")" \ - "$(sha256_file "$linux_arm64")" \ + "$release_url_base/$archive_name" \ + "$release_url_base/$skills_name" \ + "$archive_sha" \ "$skills_sha" \ "$keg_only_line" \ "$channel_caveat" \ diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh new file mode 100755 index 00000000..99f0fb91 --- /dev/null +++ b/scripts/release/prepare-changelog.sh @@ -0,0 +1,97 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +CHANNEL="${1:-}" +VERSION="${2:-}" +FROM_BETA="" +FROM_REF="" +CHANGELOG="$ROOT/CHANGELOG.md" + +usage() { + cat >&2 <<'EOF' +usage: prepare-changelog.sh [options] + +Options: + --from-beta Required for stable release notes + --from-ref Commit-list baseline for prerelease notes + --changelog Override CHANGELOG.md path +EOF +} + +[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; } +shift 2 +while [ "$#" -gt 0 ]; do + case "$1" in + --from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;; + --from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;; + --changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +release_validate_version_channel "$CHANNEL" "$VERSION" +cd "$ROOT" +[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; } +[ -z "$(git status --porcelain --untracked-files=all)" ] || { + printf 'prepare changelog requires a clean worktree\n' >&2 + exit 1 +} + +semver="$(release_semver "$VERSION")" +if grep -Fq "## [$semver] - " "$CHANGELOG"; then + printf 'CHANGELOG section already exists for %s\n' "$semver" >&2 + exit 1 +fi +if [ "$CHANNEL" = "stable" ]; then + [ -n "$FROM_BETA" ] || { printf 'stable changelog requires --from-beta\n' >&2; exit 1; } + release_is_prerelease_version "$FROM_BETA" || { printf 'invalid beta baseline: %s\n' "$FROM_BETA" >&2; exit 1; } + [ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || { + printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 + exit 1 + } + FROM_REF="$FROM_BETA" +fi + +release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}" +section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")" +output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")" +cleanup() { rm -f "$section" "$output"; } +trap cleanup EXIT HUP INT TERM + +{ + printf '## [%s] - %s\n\n' "$semver" "$release_date" + if [ "$CHANNEL" = "stable" ]; then + printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA" + else + printf '\n\n' + fi + printf '### Changed\n\n' + if [ "$CHANNEL" = "stable" ]; then + printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA" + else + printf -- '- TODO: summarize this beta candidate and its validation scope.\n' + fi +} > "$section" + +inserted=0 +while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" >> "$output" + if [ "$line" = '## [Unreleased]' ]; then + printf '\n' >> "$output" + cat "$section" >> "$output" + inserted=1 + fi +done < "$CHANGELOG" +[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; } +cp "$output" "$CHANGELOG" + +printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" +if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then + printf '\nCommits since %s:\n' "$FROM_REF" + git log --oneline "$FROM_REF..HEAD" +fi diff --git a/scripts/release/publish-gitee-local.sh b/scripts/release/publish-gitee-local.sh index 483c7df6..423f6802 100755 --- a/scripts/release/publish-gitee-local.sh +++ b/scripts/release/publish-gitee-local.sh @@ -1,68 +1,7 @@ -#!/usr/bin/env bash -# Publish locally-built release artifacts to the matching Gitee release. -# -# Intended to run inside Gitee Go after building artifacts in China. This avoids -# the unreliable GitHub Actions -> Gitee cross-border upload path. - +#!/bin/sh set -eu -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -DIST_DIR="${DIST_DIR:-dist}" -GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" -GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" -GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}" -GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}" -GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}" - -err() { - printf 'error: %s\n' "$*" >&2 - exit 1 -} - -[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required" -[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR" - -VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}" -[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag" -case "$VERSION" in - v*) ;; - *) VERSION="v$VERSION" ;; -esac - -OWNER="${GITEE_REPO%%/*}" -NAME="${GITEE_REPO##*/}" -base="${GITEE_API}/repos/${OWNER}/${NAME}" - -api_get() { - curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@" -} - -echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}" - -target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)" - -rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)" -release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)" - -if [ -z "$release_id" ]; then - echo " No Gitee release for ${VERSION} yet — creating it." - rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \ - -X POST "${base}/releases" \ - -F "access_token=${GITEE_TOKEN}" \ - -F "tag_name=${VERSION}" \ - -F "name=${VERSION}" \ - -F "body=Gitee-local build of ${VERSION} for China users." \ - -F "target_commitish=${target_commit}" 2>/dev/null || true)" - release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)" -fi -[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" -echo " Gitee release id = ${release_id}" - -DIST_DIR="$DIST_DIR" \ -GITEE_API="$GITEE_API" \ -GITEE_TOKEN="$GITEE_TOKEN" \ -GITEE_REPO="$GITEE_REPO" \ -GITEE_CURL_CONNECT_TIMEOUT="$GITEE_CURL_CONNECT_TIMEOUT" \ -GITEE_CURL_MAX_TIME="$GITEE_CURL_MAX_TIME" \ -GITEE_RELEASE_ID="$release_id" \ - "$SCRIPT_DIR/reconcile-gitee-assets.sh" +printf '%s\n' \ + 'Direct Gitee artifact publication is disabled.' \ + 'Use the guarded Release workflow so Gitee receives the exact immutable GitHub assets.' >&2 +exit 2 diff --git a/scripts/release/release-contract.sh b/scripts/release/release-contract.sh new file mode 100755 index 00000000..71bbb6b1 --- /dev/null +++ b/scripts/release/release-contract.sh @@ -0,0 +1,221 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +CHANNEL="" +VERSION="" +CONTEXT="local" +REMOTE="origin" +BRANCH="main" +FROM_BETA="" +FROM_BETA_COMMIT="" +CHANGELOG="" +NOTES_OUTPUT="" +METADATA_OUTPUT="" + +usage() { + cat >&2 <<'EOF' +usage: release-contract.sh --channel --version [options] + +Options: + --context Local pre-push checks or tag-workflow checks + --remote Release remote (default: origin) + --branch Sealed release branch (default: main) + --from-beta Required stable promotion baseline + --repo-root Override repository root (primarily for tests) + --changelog Override CHANGELOG.md path + --notes-output Write the exact CHANGELOG section body + --metadata-output Append channel/version/baseline key-value output +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --channel) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANNEL="$2"; shift 2 ;; + --version) [ "$#" -ge 2 ] || { usage; exit 2; }; VERSION="$2"; shift 2 ;; + --context) [ "$#" -ge 2 ] || { usage; exit 2; }; CONTEXT="$2"; shift 2 ;; + --remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;; + --branch) [ "$#" -ge 2 ] || { usage; exit 2; }; BRANCH="$2"; shift 2 ;; + --from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;; + --repo-root) [ "$#" -ge 2 ] || { usage; exit 2; }; ROOT="$2"; shift 2 ;; + --changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;; + --notes-output) [ "$#" -ge 2 ] || { usage; exit 2; }; NOTES_OUTPUT="$2"; shift 2 ;; + --metadata-output) [ "$#" -ge 2 ] || { usage; exit 2; }; METADATA_OUTPUT="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; } +case "$CONTEXT" in local|ci) ;; *) printf 'invalid context: %s\n' "$CONTEXT" >&2; exit 2 ;; esac +release_validate_version_channel "$CHANNEL" "$VERSION" + +cd "$ROOT" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'not a Git worktree: %s\n' "$ROOT" >&2 + exit 1 +} +[ -n "$CHANGELOG" ] || CHANGELOG="$ROOT/CHANGELOG.md" +[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; } + +head_commit="$(git rev-parse HEAD)" +remote_main="refs/remotes/$REMOTE/$BRANCH" +git remote get-url "$REMOTE" >/dev/null 2>&1 || { + printf 'release remote does not exist: %s\n' "$REMOTE" >&2 + exit 1 +} +git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || { + printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2 + exit 1 +} +remote_main_commit="$(git rev-parse "$remote_main^{commit}")" + +if [ "$CONTEXT" = "local" ]; then + [ -z "$(git status --porcelain --untracked-files=all)" ] || { + printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2 + exit 1 + } + current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" + [ "$current_branch" = "$BRANCH" ] || { + printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2 + exit 1 + } + [ "$head_commit" = "$remote_main_commit" ] || { + printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2 + exit 1 + } + if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then + printf 'release tag already exists locally: %s\n' "$VERSION" >&2 + exit 1 + fi + remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || { + printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2 + exit 1 + } + [ -z "$remote_tag" ] || { + printf 'release tag already exists on %s: %s\n' "$REMOTE" "$VERSION" >&2 + exit 1 + } +else + git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || { + printf 'CI release tag is not available: %s\n' "$VERSION" >&2 + exit 1 + } + [ "$(git rev-parse "refs/tags/$VERSION^{commit}")" = "$head_commit" ] || { + printf 'CI checkout HEAD does not match tag %s\n' "$VERSION" >&2 + exit 1 + } + [ "$(git cat-file -t "refs/tags/$VERSION")" = "tag" ] || { + printf 'release tag must be annotated: %s\n' "$VERSION" >&2 + exit 1 + } + git merge-base --is-ancestor HEAD "$remote_main" || { + printf 'release tag commit must be contained in %s/%s\n' "$REMOTE" "$BRANCH" >&2 + exit 1 + } +fi + +previous_stable="" +previous_stable_commit="" +for tag in $(git tag --list 'v*' --sort=-version:refname); do + [ "$tag" = "$VERSION" ] && continue + if release_is_stable_version "$tag"; then + previous_stable="$tag" + previous_stable_commit="$(git rev-parse "$tag^{commit}")" + break + fi +done +if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then + printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2 + exit 1 +fi + +core_tag="$(release_core_tag "$VERSION")" +if [ "$CHANNEL" = "prerelease" ]; then + [ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; } + if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then + printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2 + exit 1 + fi + previous_beta="" + for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do + [ "$tag" = "$VERSION" ] && continue + if release_is_prerelease_version "$tag"; then + previous_beta="$tag" + break + fi + done + beta_number="$(release_beta_number "$VERSION")" + if [ -z "$previous_beta" ]; then + [ "$beta_number" -eq 1 ] || { + printf 'the first prerelease for %s must be beta.1\n' "$core_tag" >&2 + exit 1 + } + else + previous_beta_number="$(release_beta_number "$previous_beta")" + expected_beta_number=$((previous_beta_number + 1)) + [ "$beta_number" -eq "$expected_beta_number" ] || { + printf 'prerelease after %s must be %s-beta.%s\n' "$previous_beta" "$core_tag" "$expected_beta_number" >&2 + exit 1 + } + fi +else + if [ -z "$FROM_BETA" ] && [ "$CONTEXT" = "ci" ]; then + FROM_BETA="$(git for-each-ref "refs/tags/$VERSION" --format='%(contents)' | awk '/^From-Beta:[[:space:]]*/ { sub(/^From-Beta:[[:space:]]*/, ""); print }')" + fi + [ -n "$FROM_BETA" ] || { + printf 'stable release requires an explicit --from-beta tag\n' >&2 + exit 1 + } + release_is_prerelease_version "$FROM_BETA" || { + printf 'invalid stable beta baseline: %s\n' "$FROM_BETA" >&2 + exit 1 + } + [ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || { + printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 + exit 1 + } + git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || { + printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2 + exit 1 + } + FROM_BETA_COMMIT="$(git rev-parse "refs/tags/$FROM_BETA^{commit}")" + git merge-base --is-ancestor "$FROM_BETA^{commit}" HEAD || { + printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2 + exit 1 + } + if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then + printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2 + git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2 + exit 1 + fi +fi + +semver="$(release_semver "$VERSION")" +if [ -n "$NOTES_OUTPUT" ]; then + release_extract_changelog "$CHANGELOG" "$semver" "$NOTES_OUTPUT" +else + notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-contract.XXXXXX")" + trap 'rm -f "$notes_tmp"' EXIT HUP INT TERM + release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp" +fi + +if [ -n "$METADATA_OUTPUT" ]; then + mkdir -p "$(dirname "$METADATA_OUTPUT")" + { + printf 'channel=%s\n' "$CHANNEL" + printf 'version=%s\n' "$VERSION" + printf 'semver=%s\n' "$semver" + printf 'previous_stable=%s\n' "$previous_stable" + printf 'previous_stable_commit=%s\n' "$previous_stable_commit" + printf 'from_beta=%s\n' "$FROM_BETA" + printf 'from_beta_commit=%s\n' "$FROM_BETA_COMMIT" + } >> "$METADATA_OUTPUT" +fi + +printf 'Release contract passed: channel=%s version=%s commit=%s' "$CHANNEL" "$VERSION" "$head_commit" +[ -z "$FROM_BETA" ] || printf ' from-beta=%s' "$FROM_BETA" +printf '\n' diff --git a/scripts/release/release-lib.sh b/scripts/release/release-lib.sh new file mode 100755 index 00000000..c27f6733 --- /dev/null +++ b/scripts/release/release-lib.sh @@ -0,0 +1,167 @@ +#!/bin/sh + +# Shared release version and CHANGELOG helpers. This file is sourced by the +# local release command, the CI contract, and mirror publishing so every stage +# agrees on what "prerelease" and "stable" mean. + +release_stable_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +release_prerelease_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.[1-9][0-9]*$' + +release_is_stable_version() { + printf '%s\n' "$1" | grep -Eq "$release_stable_pattern" +} + +release_is_prerelease_version() { + printf '%s\n' "$1" | grep -Eq "$release_prerelease_pattern" +} + +release_channel_for_version() { + version="$1" + if release_is_stable_version "$version"; then + printf '%s\n' stable + return 0 + fi + if release_is_prerelease_version "$version"; then + printf '%s\n' prerelease + return 0 + fi + printf 'invalid release version: %s (expected vX.Y.Z or vX.Y.Z-beta.N)\n' "$version" >&2 + return 1 +} + +release_validate_version_channel() { + expected="$1" + version="$2" + case "$expected" in + stable|prerelease) ;; + *) + printf 'invalid release channel: %s (expected prerelease or stable)\n' "$expected" >&2 + return 1 + ;; + esac + actual="$(release_channel_for_version "$version")" || return 1 + if [ "$actual" != "$expected" ]; then + printf 'release channel/version mismatch: channel=%s version=%s\n' "$expected" "$version" >&2 + return 1 + fi +} + +release_semver() { + printf '%s\n' "${1#v}" +} + +release_core_tag() { + version="$1" + printf '%s\n' "${version%%-beta.*}" +} + +release_beta_number() { + printf '%s\n' "${1##*.}" +} + +release_core_is_greater() { + candidate="$(release_core_tag "$1")" + baseline="$(release_core_tag "$2")" + candidate="${candidate#v}" + baseline="${baseline#v}" + awk -v candidate="$candidate" -v baseline="$baseline" 'BEGIN { + split(candidate, c, ".") + split(baseline, b, ".") + for (i = 1; i <= 3; i++) { + if ((c[i] + 0) > (b[i] + 0)) exit 0 + if ((c[i] + 0) < (b[i] + 0)) exit 1 + } + exit 1 + }' +} + +release_version_is_greater() { + _rvig_candidate="$1" + _rvig_baseline="$2" + _rvig_candidate_channel="$(release_channel_for_version "$_rvig_candidate")" || return 1 + _rvig_baseline_channel="$(release_channel_for_version "$_rvig_baseline")" || return 1 + + if release_core_is_greater "$_rvig_candidate" "$_rvig_baseline"; then + return 0 + fi + if release_core_is_greater "$_rvig_baseline" "$_rvig_candidate"; then + return 1 + fi + if [ "$_rvig_candidate_channel" = "stable" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then + return 0 + fi + if [ "$_rvig_candidate_channel" = "prerelease" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then + [ "$(release_beta_number "$_rvig_candidate")" -gt "$(release_beta_number "$_rvig_baseline")" ] + return + fi + return 1 +} + +# Extract one exact CHANGELOG section (without its H2 heading) and validate that +# it is dated, unique, non-placeholder content with at least one bullet. +release_extract_changelog() { + changelog="$1" + semver="$2" + output="$3" + tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-notes.XXXXXX")" + + set +e + awk -v wanted="$semver" ' + BEGIN { + prefix = "## [" wanted "] - " + found = 0 + active = 0 + invalid_date = 0 + meaningful = 0 + bullet = 0 + placeholder = 0 + } + /^## / { + active = 0 + if (index($0, prefix) == 1) { + found++ + active = 1 + date = substr($0, length(prefix) + 1) + if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) invalid_date = 1 + } + next + } + active { + print + if ($0 ~ /[^[:space:]]/) meaningful = 1 + if ($0 ~ /^- /) bullet = 1 + lowered = tolower($0) + if (lowered ~ /todo|tbd/) placeholder = 1 + } + END { + if (found != 1) exit 41 + if (invalid_date) exit 42 + if (!meaningful || !bullet) exit 43 + if (placeholder) exit 44 + } + ' "$changelog" > "$tmp" + status=$? + set -e + + case "$status" in + 0) ;; + 41) printf 'CHANGELOG must contain exactly one section: ## [%s] - YYYY-MM-DD\n' "$semver" >&2 ;; + 42) printf 'CHANGELOG section for %s has an invalid date\n' "$semver" >&2 ;; + 43) printf 'CHANGELOG section for %s must contain release notes and at least one bullet\n' "$semver" >&2 ;; + 44) printf 'CHANGELOG section for %s still contains TODO/TBD placeholders\n' "$semver" >&2 ;; + *) printf 'failed to parse CHANGELOG section for %s\n' "$semver" >&2 ;; + esac + if [ "$status" -ne 0 ]; then + rm -f "$tmp" + return "$status" + fi + + if [ "$output" = "-" ]; then + cat "$tmp" + else + mkdir -p "$(dirname "$output")" + mv "$tmp" "$output" + tmp="" + fi + [ -z "$tmp" ] || rm -f "$tmp" +} diff --git a/scripts/release/release.sh b/scripts/release/release.sh new file mode 100755 index 00000000..3285bda9 --- /dev/null +++ b/scripts/release/release.sh @@ -0,0 +1,353 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +CHANNEL="${1:-}" +VERSION="${2:-}" +REMOTE="" +BRANCH="main" +FROM_BETA="" +PUBLISH=0 +YES=0 +OFFICIAL_TAGS_URL="${DWS_RELEASE_OFFICIAL_TAGS_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}" + +usage() { + cat >&2 <<'EOF' +usage: release.sh [options] + +Runs the full test, command-compatibility, package, and install preflight. +The default is validation only; add --publish to create and push the tag. + +Options: + --remote Required tag destination + --from-beta Required for stable releases + --publish Create and push the annotated release tag + --yes Skip the interactive version confirmation +EOF +} + +[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; } +shift 2 +while [ "$#" -gt 0 ]; do + case "$1" in + --remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;; + --from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;; + --publish) PUBLISH=1; shift ;; + --yes) YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;; + esac +done + +release_validate_version_channel "$CHANNEL" "$VERSION" +[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; } +cd "$ROOT" +fetch_url="$(git remote get-url "$REMOTE" 2>/dev/null)" || { printf 'unknown release remote: %s\n' "$REMOTE" >&2; exit 1; } +push_urls="$(git remote get-url --push --all "$REMOTE" 2>/dev/null)" || { + printf 'could not resolve push URL for release remote: %s\n' "$REMOTE" >&2 + exit 1 +} +[ "$(printf '%s\n' "$push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf 'release remote must have exactly one push URL: %s\n' "$REMOTE" >&2 + exit 1 +} +push_url="$(printf '%s\n' "$push_urls" | sed -n '1p')" + +repository_identity() { + identity_url="$1" + case "$identity_url" in + https://github.com/*) identity_path="${identity_url#https://github.com/}" ;; + http://github.com/*) identity_path="${identity_url#http://github.com/}" ;; + git://github.com/*) identity_path="${identity_url#git://github.com/}" ;; + git@github.com:*) identity_path="${identity_url#git@github.com:}" ;; + ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;; + *) printf '%s\n' "$identity_url"; return 0 ;; + esac + identity_path="${identity_path%/}" + identity_path="${identity_path%.git}" + printf 'github.com/%s\n' "$identity_path" +} + +fetch_identity="$(repository_identity "$fetch_url")" +push_identity="$(repository_identity "$push_url")" +[ "$fetch_identity" = "$push_identity" ] || { + printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' "$fetch_url" "$push_url" >&2 + exit 1 +} +printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url" + +github_repository_from_url() { + github_identity="$(repository_identity "$1")" + case "$github_identity" in + github.com/*) printf '%s\n' "${github_identity#github.com/}" ;; + *) return 1 ;; + esac +} + +require_delivered_previous_stable() { + github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" + if [ -z "$github_repository" ]; then + [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { + printf 'release validation requires a github.com remote to prove the stable baseline was delivered\n' >&2 + return 1 + } + return 0 + fi + [ -n "$previous_stable" ] || { + printf 'a delivered previous stable baseline is required\n' >&2 + return 1 + } + stable_commit="$(git rev-parse "$previous_stable^{commit}")" + DWS_RELEASE_OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" \ + "$SCRIPT_DIR/verify-delivered-stable.sh" "$previous_stable" "$stable_commit" +} + +require_github_publication_authority() { + github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)" + if [ -z "$github_repository" ]; then + [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || { + printf 'publishing requires a github.com remote so CI and delivery authority can be verified\n' >&2 + return 1 + } + printf 'warning: GitHub publication checks explicitly disabled for non-GitHub test remote\n' >&2 + return 0 + fi + command -v gh >/dev/null 2>&1 || { + printf 'gh is required to verify CI and release authority before publishing\n' >&2 + 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" \ + --jq '.workflow_runs[] | select(.status != "completed") | [.id, .event, .status, .head_branch] | @tsv' + )" || { + printf 'could not query active Release workflow runs for %s\n' "$github_repository" >&2 + return 1 + } + [ -z "$active_runs" ] || { + printf 'another Release workflow is still active; wait for it before allocating a new tag:\n%s\n' "$active_runs" >&2 + return 1 + } + + sealed_commit="$(git rev-parse HEAD)" + passed_gate="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \ + --jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length' + )" || { + printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2 + return 1 + } + [ "$passed_gate" -gt 0 ] || { + printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2 + return 1 + } + + if [ "$CHANNEL" = "stable" ]; then + beta_state="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$github_repository/releases/tags/$FROM_BETA" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" || { + printf 'could not query beta release %s in %s\n' "$FROM_BETA" "$github_repository" >&2 + return 1 + } + [ "$beta_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" ] || { + printf '%s must be a public immutable prerelease before stable tag allocation (got: %s)\n' "$FROM_BETA" "$beta_state" >&2 + return 1 + } + beta_assets="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$github_repository/releases/tags/$FROM_BETA" \ + --jq '.assets[].name' + )" || return 1 + [ "$(printf '%s\n' "$beta_assets" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 8 ] || { + printf 'beta release %s must contain exactly eight supported assets\n' "$FROM_BETA" >&2 + return 1 + } + for beta_asset in \ + dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \ + dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \ + dws-windows-amd64.zip dws-windows-arm64.zip \ + dws-skills.zip checksums.txt; do + [ "$(printf '%s\n' "$beta_assets" | grep -Fxc "$beta_asset")" -eq 1 ] || { + printf 'beta release %s must contain %s exactly once\n' "$FROM_BETA" "$beta_asset" >&2 + return 1 + } + 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 + return 1 + } + fi +} + +fetch_release_tags() { + git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*' + git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*' +} + +printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH" +git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH" +fetch_release_tags + +metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-metadata.XXXXXX")" +final_metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-final-metadata.XXXXXX")" +cleanup() { rm -f "$metadata" "$final_metadata"; } +trap cleanup EXIT HUP INT TERM + +run_contract() { + if [ -n "$FROM_BETA" ]; then + "$SCRIPT_DIR/release-contract.sh" \ + --channel "$CHANNEL" \ + --version "$VERSION" \ + --context local \ + --remote "$REMOTE" \ + --branch "$BRANCH" \ + --from-beta "$FROM_BETA" \ + "$@" + else + "$SCRIPT_DIR/release-contract.sh" \ + --channel "$CHANNEL" \ + --version "$VERSION" \ + --context local \ + --remote "$REMOTE" \ + --branch "$BRANCH" \ + "$@" + fi +} + +run_contract --metadata-output "$metadata" +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 +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. +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 + exit 1 + fi + printf 'Type %s to create and push the release tag: ' "$VERSION" + IFS= read -r confirmation + [ "$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 '==> 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 + 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 + } + "$ROOT/scripts/policy/check-command-compatibility.sh" \ + --base-ref "$REMOTE/$BRANCH" \ + --stable-ref "$final_previous_stable" +fi + +if [ "$PUBLISH" -ne 1 ]; then + printf '\nPreflight passed. No tag was created.\n' + printf 'Publish with the same command plus --publish.\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" +else + git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease' +fi + +if ! git push "$push_url" "refs/tags/$VERSION"; then + set +e + remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" + query_status=$? + set -e + if [ "$query_status" -ne 0 ]; then + printf 'tag push reported failure and remote state could not be verified; keeping local tag %s for investigation\n' "$VERSION" >&2 + exit 1 + fi + remote_object="$(printf '%s\n' "$remote_refs" | awk '$2 !~ /\^\{\}$/ { print $1; exit }')" + local_object="$(git rev-parse "refs/tags/$VERSION")" + if [ -z "$remote_object" ]; then + git tag -d "$VERSION" >/dev/null 2>&1 || true + printf 'tag push failed; remote has no tag and the new local tag was removed: %s\n' "$VERSION" >&2 + exit 1 + fi + if [ "$remote_object" != "$local_object" ]; then + printf 'tag push failed and remote %s points elsewhere; keeping local tag for investigation\n' "$VERSION" >&2 + exit 1 + fi + printf 'warning: push reported failure, but push target %s has the exact sealed tag; treating it as published\n' "$push_url" >&2 +fi + +printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url" diff --git a/scripts/release/stage-npm-package.sh b/scripts/release/stage-npm-package.sh new file mode 100755 index 00000000..91dac23b --- /dev/null +++ b/scripts/release/stage-npm-package.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +DEFAULT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +SOURCE_ROOT="${DWS_PACKAGE_SOURCE_ROOT:-$DEFAULT_ROOT}" +DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$DEFAULT_ROOT/dist}" +VERSION="${1:-${DWS_PACKAGE_VERSION:-}}" + +[ -n "$VERSION" ] || { printf 'package version is required\n' >&2; exit 2; } +SEMVER="${VERSION#v}" +printf '%s\n' "$SEMVER" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' || { + printf 'invalid npm package version: %s\n' "$VERSION" >&2 + exit 2 +} + +PKG_ROOT="$DIST_DIR/npm/dingtalk-workspace-cli" +rm -rf "$PKG_ROOT" +mkdir -p "$PKG_ROOT/assets" "$PKG_ROOT/bin" + +cp "$SOURCE_ROOT/build/npm/install.js" "$PKG_ROOT/install.js" +cp "$SOURCE_ROOT/build/npm/bin/dws.js" "$PKG_ROOT/bin/dws.js" +cp "$SOURCE_ROOT/build/npm/README.md" "$PKG_ROOT/README.md" +sed "s|__VERSION__|$SEMVER|g" "$SOURCE_ROOT/build/npm/package.json.tmpl" > "$PKG_ROOT/package.json" + +command -v node >/dev/null 2>&1 || { printf 'node is required to validate the npm manifest\n' >&2; exit 1; } +node - "$PKG_ROOT/package.json" "$SEMVER" <<'NODE' +const fs = require("fs"); +const [manifestPath, version] = process.argv.slice(2); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const expectedScripts = { postinstall: "node install.js" }; +if (manifest.name !== "dingtalk-workspace-cli") { + throw new Error(`unexpected npm package name: ${manifest.name}`); +} +if (manifest.version !== version) { + throw new Error(`unexpected npm package version: ${manifest.version}`); +} +if (JSON.stringify(manifest.scripts) !== JSON.stringify(expectedScripts)) { + throw new Error(`unexpected npm lifecycle scripts: ${JSON.stringify(manifest.scripts)}`); +} +if (!manifest.bin || manifest.bin.dws !== "./bin/dws.js") { + throw new Error("unexpected npm binary entrypoint"); +} +NODE + +copied=0 +for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do + [ -f "$artifact" ] || continue + cp "$artifact" "$PKG_ROOT/assets/" + copied=$((copied + 1)) +done +[ "$copied" -gt 0 ] || { printf 'no release assets found in %s\n' "$DIST_DIR" >&2; exit 1; } +[ -f "$PKG_ROOT/assets/dws-skills.zip" ] || { printf 'dws-skills.zip is missing\n' >&2; exit 1; } +[ -f "$PKG_ROOT/assets/checksums.txt" ] || { printf 'checksums.txt is missing\n' >&2; exit 1; } + +printf 'Staged npm package %s at %s\n' "$SEMVER" "$PKG_ROOT" diff --git a/scripts/release/sync-to-gitee.sh b/scripts/release/sync-to-gitee.sh index 7d5d4897..6ce19325 100755 --- a/scripts/release/sync-to-gitee.sh +++ b/scripts/release/sync-to-gitee.sh @@ -24,7 +24,7 @@ # Gating: if GITEE_TOKEN / GITEE_USER / GITEE_REPO are unset, exit 0 with a # notice so the step can live in release.yml without breaking forks. -set -eu +set -euo pipefail SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" DIST_DIR="${DIST_DIR:-dist}" @@ -68,6 +68,10 @@ missing="" [ -z "${GITEE_USER:-}" ] && missing="$missing GITEE_USER" [ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO" if [ -n "$missing" ]; then + if [ "${DWS_REQUIRE_GITEE:-0}" = "1" ]; then + echo "❌ Gitee mirror sync is enabled but credentials are missing:${missing}" >&2 + exit 1 + fi echo "ℹ️ Gitee mirror sync skipped — missing:${missing}" echo " Set these as repo secrets to auto-mirror releases to Gitee for China users." exit 0 @@ -96,6 +100,7 @@ bounded_max_time() { } VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}" +DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION" OWNER="${GITEE_REPO%%/*}" NAME="${GITEE_REPO##*/}" base="${GITEE_API}/repos/${OWNER}/${NAME}" @@ -121,6 +126,29 @@ api_get() { --max-time "$max_time" "$@" } +gitee_commit="$(gitee_tag_commit || true)" +if [ -n "$gitee_commit" ] && [ "$gitee_commit" != "$target_commit" ]; then + echo "❌ Existing Gitee tag ${VERSION} points to ${gitee_commit}, expected ${target_commit}; refusing to move it." >&2 + exit 1 +fi +if [ -z "$gitee_commit" ]; then + echo " Creating Gitee tag ${VERSION} -> ${target_commit}" + git push "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null +else + echo " Gitee tag ${VERSION} already points to ${target_commit}." +fi + +for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do + gitee_commit="$(gitee_tag_commit || true)" + [ "$gitee_commit" = "$target_commit" ] && break + sleep 5 +done +[ "$gitee_commit" = "$target_commit" ] || { + echo "❌ Gitee tag ${VERSION} is not aligned after push: got ${gitee_commit:-}, want ${target_commit}" >&2 + exit 1 +} +echo " Gitee tag ${VERSION} is aligned." + # ── Resolve or create the Gitee release for this tag ────────────────────────── rel_json="$(api_get "$GITEE_RELEASE_LOOKUP_MAX_TIME" \ "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)" @@ -143,26 +171,165 @@ fi [ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; } echo " Gitee release id = ${release_id}" -# ── Reconcile the complete, byte-verified release asset set ────────────────── -remaining="$(deadline_remaining)" || err "overall Gitee sync deadline exhausted before asset reconciliation" -reconcile_timeout="$GITEE_RECONCILE_TIMEOUT_SECONDS" -max_child_timeout=$(( remaining - GITEE_CHILD_DEADLINE_RESERVE_SECONDS )) -[ "$max_child_timeout" -gt 0 ] || err "insufficient Gitee sync budget for asset reconciliation" -if [ "$reconcile_timeout" -gt "$max_child_timeout" ]; then - reconcile_timeout="$max_child_timeout" +# ── Mirror each artifact, verifying content so re-runs self-heal ───────────── +# Pull the current attachment list (name + attach id + download url) so we can, +# per file: +# • skip it when it is already on Gitee, unique, AND byte-identical, +# • REPLACE it when present but stale (different bytes) — e.g. the darwin +# binaries are re-signed and so differ between GitHub and a prior mirror +# run, which breaks install.sh's checksums.txt verification on macOS, +# • DEDUP it when the same name has >1 attachment — a prior run that failed to +# delete (see below) left the old copy *and* an extra upload; Gitee then +# serves the OLDER one by name, so the stale binary wins. We delete every +# copy and upload one fresh. +# • upload it when missing. +# This brings the Gitee release into byte-for-byte agreement with $DIST_DIR, +# which the caller fills from the GitHub release whose checksums.txt is what +# install.sh verifies against. +# +# We list attachments via the dedicated /attach_files endpoint, NOT the release +# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach +# id, so DELETE /attach_files/{id} was previously called with an empty id and +# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee. +load_assets_map() { + curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \ + "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" \ + | python3 -c 'import json,sys +try: + data=json.load(sys.stdin) + rows=data if isinstance(data,list) else data.get("attach_files",[]) + for a in rows: + n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","") + if n and i!="": + print("%s\t%s\t%s" % (n, i, u)) +except Exception: + sys.exit(1)' +} + +assets_map="$(load_assets_map)" || { + echo "❌ Could not list Gitee release attachments; refusing a blind upload." >&2 + exit 1 +} + +sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin + if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}'; + else shasum -a 256 ${1:+"$1"} | awk '{print $1}'; fi +} + +gitee_attach() { # upload file $1; success when the response carries a download url + file="$1" + fn="$(basename "$file")" + attempt=1 + while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do + response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \ + --retry 2 --retry-delay 5 --retry-all-errors \ + -X POST "${base}/releases/${release_id}/attach_files" \ + -F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)" + if printf '%s' "$response" | grep -q '"browser_download_url"'; then + return 0 + fi + echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2 + attempt=$((attempt + 1)) + [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY" + done + return 1 +} + +gitee_delete() { # delete attachment by id $1 + curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \ + -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \ + >/dev/null +} + +uploaded=0 +replaced=0 +skipped=0 +failed=0 +for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do + [ -f "$f" ] || continue + fn="$(basename "$f")" + local_sha="$(sha256_of "$f")" + # Every attach id currently carrying this name (may be >1 from a botched run). + ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')" + aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')" + count="$(printf '%s' "$ids" | grep -c . || true)" + + if [ "$count" -eq 0 ]; then + echo " ⬆ ${fn} (new)" + if gitee_attach "$f"; then + uploaded=$((uploaded + 1)) + else + echo " ❌ upload failed for ${fn}" >&2 + failed=$((failed + 1)) + fi + continue + fi + + if [ "$count" -eq 1 ]; then + gitee_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)" + if [ "$gitee_sha" = "$local_sha" ]; then + echo " ✓ ${fn} already correct on Gitee — skip" + skipped=$((skipped + 1)) + continue + fi + echo " ↻ ${fn} differs on Gitee (stale) — deleting + re-uploading" + else + echo " ↻ ${fn} has ${count} copies on Gitee (dup) — deleting all + re-uploading one" + fi + + # Delete every copy, then upload exactly one fresh, correct file. + delete_failed=0 + for aid in $ids; do + if ! gitee_delete "$aid"; then + echo " ❌ failed to delete stale Gitee attachment ${aid} for ${fn}" >&2 + delete_failed=1 + fi + done + if [ "$delete_failed" -ne 0 ]; then + failed=$((failed + 1)) + continue + fi + if gitee_attach "$f"; then + replaced=$((replaced + 1)) + else + echo " ❌ re-upload failed for ${fn}" >&2 + failed=$((failed + 1)) + fi +done + +if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then + echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2 + exit 1 fi -DIST_DIR="$DIST_DIR" \ -GITEE_API="$GITEE_API" \ -GITEE_TOKEN="$GITEE_TOKEN" \ -GITEE_REPO="$GITEE_REPO" \ -GITEE_CURL_CONNECT_TIMEOUT="$GITEE_CURL_CONNECT_TIMEOUT" \ -GITEE_CURL_MAX_TIME="$GITEE_CURL_MAX_TIME" \ -GITEE_RELEASE_ID="$release_id" \ -GITEE_OVERALL_TIMEOUT_SECONDS="$reconcile_timeout" \ - "$SCRIPT_DIR/reconcile-gitee-assets.sh" - -deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted after asset reconciliation" - +# Gitee may accept an upload before the attachment list is consistent. Re-read +# the release and require every supported asset to be unique and byte-identical. +verified=0 +for verify_attempt in 1 2 3 4 5; do + final_map="$(load_assets_map || true)" + verify_failed=0 + for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do + fn="$(basename "$f")" + rows="$(printf '%s\n' "$final_map" | awk -F'\t' -v n="$fn" '$1==n')" + count="$(printf '%s\n' "$rows" | grep -c . || true)" + if [ "$count" -ne 1 ]; then + verify_failed=1 + continue + fi + aurl="$(printf '%s\n' "$rows" | awk -F'\t' 'NR==1 {print $3}')" + remote_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)" + [ "$remote_sha" = "$(sha256_of "$f")" ] || verify_failed=1 + done + if [ "$verify_failed" -eq 0 ] && [ "$failed" -eq 0 ]; then + verified=1 + break + fi + [ "$verify_attempt" -lt 5 ] && sleep 5 +done +[ "$verified" -eq 1 ] || { + echo "❌ Gitee release ${VERSION} does not contain one byte-identical copy of every release asset." >&2 + exit 1 +} +echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)." echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\" echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh" diff --git a/scripts/release/sync-to-oss.sh b/scripts/release/sync-to-oss.sh index 54a7332d..0d972a49 100755 --- a/scripts/release/sync-to-oss.sh +++ b/scripts/release/sync-to-oss.sh @@ -2,19 +2,16 @@ # Copyright 2026 Alibaba Group # Licensed under the Apache License, Version 2.0 # -# Sync release artifacts to a China-accessible OSS mirror so that the install -# scripts' DWS_RELEASE_BASE switch can serve domestic users. +# Sync release artifacts to a China-accessible OSS mirror. Channel pointers are +# mirror metadata; repository installers currently resolve GitHub/Gitee and do +# not consume these OSS pointers directly. # -# Mirror layout produced (matches install.sh RELEASE_BASE="//"): +# Mirror layout produced: # oss://$OSS_BUCKET/$OSS_PREFIX/download//dws--.tar.gz|.zip # oss://$OSS_BUCKET/$OSS_PREFIX/download//checksums.txt # oss://$OSS_BUCKET/$OSS_PREFIX/download//dws-skills.zip -# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (plain version string) -# -# So with the CDN/custom domain CNAME'd to the bucket, China users run: -# DWS_RELEASE_BASE=https:///$OSS_PREFIX/download \ -# DWS_VERSION= \ -# curl -fsSL https:///$OSS_PREFIX/install.sh | sh +# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (stable version only) +# oss://$OSS_BUCKET/$OSS_PREFIX/beta.txt (prerelease version only) # # Required environment (CI secrets): # OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_ENDPOINT, OSS_BUCKET @@ -28,6 +25,9 @@ set -eu +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + DIST_DIR="${DIST_DIR:-dist}" OSS_PREFIX="${OSS_PREFIX:-dws}" @@ -38,6 +38,10 @@ for v in OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET OSS_ENDPOINT OSS_BUCKET; do [ -z "$val" ] && missing="$missing $v" done if [ -n "$missing" ]; then + if [ "${DWS_REQUIRE_OSS:-0}" = "1" ]; then + echo "❌ OSS mirror sync is required but credentials are missing:${missing}" >&2 + exit 1 + fi echo "ℹ️ OSS mirror sync skipped — missing:${missing}" echo " Set these as repo secrets to auto-publish releases to the China mirror." exit 0 @@ -45,20 +49,60 @@ fi # ── Resolve version ────────────────────────────────────────────────────────── VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}" +CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}" +release_validate_version_channel "$CHANNEL" "$VERSION" echo "📦 Syncing release ${VERSION} → oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${VERSION}/" +# Never move a channel pointer to an incomplete or mixed-version directory, +# even when this helper is invoked outside the normal workflow. +DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION" + # ── Ensure ossutil is available ─────────────────────────────────────────────── OSSUTIL="${OSSUTIL:-ossutil}" +OSSUTIL_INSTALL_DIR="" if ! command -v "$OSSUTIL" >/dev/null 2>&1; then - echo "⬇ ossutil not found; installing to ./.ossutil ..." - os="$(uname -s | tr '[:upper:]' '[:lower:]')" + echo "⬇ ossutil not found; installing a verified temporary copy ..." + case "$(uname -s)" in + Linux) os=linux ;; + Darwin) os=mac ;; + *) printf 'unsupported ossutil operating system: %s\n' "$(uname -s)" >&2; exit 1 ;; + esac arch="$(uname -m)" case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac - curl -fsSL "https://gosspublic.alicdn.com/ossutil/v2/2.0.0/ossutil-2.0.0-${os}-${arch}.zip" -o /tmp/ossutil.zip - unzip -qo /tmp/ossutil.zip -d /tmp/ossutil-extract - found="$(find /tmp/ossutil-extract -name ossutil -type f | head -1)" - mkdir -p ./.ossutil && cp "$found" ./.ossutil/ossutil && chmod +x ./.ossutil/ossutil - OSSUTIL="./.ossutil/ossutil" + case "${os}-${arch}" in + linux-amd64) expected_ossutil_sha256=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a ;; + linux-arm64) expected_ossutil_sha256=f6c95ba0c2d2ef30290af686ce4d706c701f4734ce8090bee4288a77e3f1d764 ;; + mac-amd64) expected_ossutil_sha256=8437fdd3ef1a3eb12310f61fcf1c00a5bff5cdab47b4fea815527472e7cf896c ;; + mac-arm64) expected_ossutil_sha256=058fd048f321f8c80def8b748030531646eefe3a82837bf16b581ba7d9c84ac7 ;; + *) printf 'unsupported ossutil architecture: %s-%s\n' "$os" "$arch" >&2; exit 1 ;; + esac + ossutil_archive="$(mktemp "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX.zip")" + ossutil_extract="$(mktemp -d "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX")" + env -u OSS_ACCESS_KEY_ID -u OSS_ACCESS_KEY_SECRET \ + curl -fsSL \ + "https://gosspublic.alicdn.com/ossutil/v2/2.3.0/ossutil-2.3.0-${os}-${arch}.zip" \ + -o "$ossutil_archive" + if command -v sha256sum >/dev/null 2>&1; then + actual_ossutil_sha256="$(sha256sum "$ossutil_archive" | awk '{print $1}')" + else + actual_ossutil_sha256="$(shasum -a 256 "$ossutil_archive" | awk '{print $1}')" + fi + [ "$actual_ossutil_sha256" = "$expected_ossutil_sha256" ] || { + rm -rf "$ossutil_archive" "$ossutil_extract" + printf 'ossutil archive checksum mismatch for %s-%s\n' "$os" "$arch" >&2 + exit 1 + } + unzip -qo "$ossutil_archive" -d "$ossutil_extract" + found="$(find "$ossutil_extract" -name ossutil -type f | head -1)" + [ -n "$found" ] || { + rm -rf "$ossutil_archive" "$ossutil_extract" + printf 'verified ossutil archive does not contain the executable\n' >&2 + exit 1 + } + OSSUTIL_INSTALL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-ossutil.XXXXXX")" + cp "$found" "$OSSUTIL_INSTALL_DIR/ossutil" && chmod +x "$OSSUTIL_INSTALL_DIR/ossutil" + rm -rf "$ossutil_archive" "$ossutil_extract" + OSSUTIL="$OSSUTIL_INSTALL_DIR/ossutil" fi oss_cp() { @@ -70,8 +114,59 @@ oss_cp() { "$1" "oss://${OSS_BUCKET}/$2" } +oss_get() { + # oss_get + "$OSSUTIL" cp -f \ + --access-key-id "$OSS_ACCESS_KEY_ID" \ + --access-key-secret "$OSS_ACCESS_KEY_SECRET" \ + --endpoint "$OSS_ENDPOINT" \ + "oss://${OSS_BUCKET}/$1" "$2" +} + base="${OSS_PREFIX}/download/${VERSION}" +if [ "$CHANNEL" = "stable" ]; then + pointer_name="latest.txt" +else + pointer_name="beta.txt" +fi +current_pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-current-pointer.XXXXXX")" +pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-release-pointer.XXXXXX")" +pointer_status_file="$(mktemp "${TMPDIR:-/tmp}/dws-pointer-status.XXXXXX")" +trap 'rm -f "$current_pointer_file" "$pointer_file" "$pointer_status_file"; [ -z "$OSSUTIL_INSTALL_DIR" ] || rm -rf "$OSSUTIL_INSTALL_DIR"' EXIT HUP INT TERM +rm -f "$current_pointer_file" +current_version="" +update_pointer=1 +if oss_get "${OSS_PREFIX}/${pointer_name}" "$current_pointer_file" >"$pointer_status_file" 2>&1; then + [ -s "$current_pointer_file" ] || { + cat "$pointer_status_file" >&2 + echo "❌ OSS ${pointer_name} was read successfully but is empty; refusing to publish." >&2 + exit 1 + } + current_version="$(tr -d '[:space:]' < "$current_pointer_file")" + release_validate_version_channel "$CHANNEL" "$current_version" || { + printf '❌ OSS %s contains an invalid %s channel version: %s\n' "$pointer_name" "$CHANNEL" "$current_version" >&2 + exit 1 + } + if [ "$current_version" != "$VERSION" ]; then + if release_version_is_greater "$VERSION" "$current_version"; then + : + elif release_version_is_greater "$current_version" "$VERSION"; then + update_pointer=0 + echo "ℹ️ OSS ${pointer_name} already points to newer ${current_version}; assets will be repaired without moving it." + else + echo "❌ Cannot order OSS ${pointer_name}=${current_version} against ${VERSION}." >&2 + exit 1 + fi + fi +elif grep -Eq '(^|[^[:alnum:]])NoSuchKey([^[:alnum:]]|$)' "$pointer_status_file"; then + printf 'ℹ️ OSS %s does not exist yet; creating it.\n' "$pointer_name" +else + cat "$pointer_status_file" >&2 + printf '❌ Could not read OSS %s; refusing to move the channel pointer.\n' "$pointer_name" >&2 + exit 1 +fi + # ── Upload binaries + checksums + skills ────────────────────────────────────── uploaded=0 for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do @@ -85,16 +180,27 @@ if [ "$uploaded" -eq 0 ]; then exit 1 fi -# ── Publish the latest pointer (for DWS_VERSION=latest resolution on mirror) ── -echo "$VERSION" > /tmp/dws-latest.txt -oss_cp /tmp/dws-latest.txt "${OSS_PREFIX}/latest.txt" +# ── Publish a channel-specific pointer ─────────────────────────────────────── +# A beta must never move latest.txt: that pointer is consumed by ordinary +# installs and is the stable channel contract. +pointer_summary="${pointer_name}" +if [ "$update_pointer" -eq 1 ]; then + echo "$VERSION" > "$pointer_file" + oss_cp "$pointer_file" "${OSS_PREFIX}/${pointer_name}" +else + pointer_summary="${pointer_name} unchanged at ${current_version}" +fi -# ── Publish the install scripts themselves (entry layer) ────────────────────── -for s in install.sh install.ps1 install-skills.sh; do - [ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s" -done +# Shared installer entrypoints are stable-only. A prerelease must not replace +# the stable entry layer, even though those scripts currently fetch from +# GitHub/Gitee rather than this asset mirror. +installer_summary="" +if [ "$CHANNEL" = "stable" ] && [ "$update_pointer" -eq 1 ]; then + for s in install.sh install.ps1 install-skills.sh; do + [ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s" + done + installer_summary=" + stable install scripts" +fi -echo "✅ Synced ${uploaded} artifact(s) + install scripts + latest.txt to the OSS mirror." -echo " China install:" -echo " DWS_RELEASE_BASE=https:///${OSS_PREFIX}/download DWS_VERSION=${VERSION} \\" -echo " curl -fsSL https:///${OSS_PREFIX}/install.sh | sh" +echo "✅ Synced ${uploaded} artifact(s) + ${pointer_summary}${installer_summary} to the OSS mirror." +echo " Mirror path: ${OSS_PREFIX}/download/${VERSION}/" diff --git a/scripts/release/verify-delivered-stable.sh b/scripts/release/verify-delivered-stable.sh new file mode 100755 index 00000000..46b293a6 --- /dev/null +++ b/scripts/release/verify-delivered-stable.sh @@ -0,0 +1,84 @@ +#!/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-delivered-stable.sh \n' >&2 + exit 2 +} +release_is_stable_version "$TAG" || { + printf 'invalid stable delivery baseline: %s\n' "$TAG" >&2 + exit 2 +} +command -v curl >/dev/null 2>&1 || { printf 'curl is required to verify stable delivery\n' >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { printf 'python3 is required to verify stable delivery\n' >&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" +} + +if git rev-parse --verify --quiet "refs/tags/$TAG^{commit}" >/dev/null; then + local_commit="$(git rev-parse "refs/tags/$TAG^{commit}")" + [ "$local_commit" = "$EXPECTED_COMMIT" ] || { + printf 'stable tag %s resolves to %s, expected %s\n' "$TAG" "$local_commit" "$EXPECTED_COMMIT" >&2 + exit 1 + } +fi + +stable_state="$( + github_get "repos/$REPOSITORY/releases/tags/$TAG" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("%s\t%s\t%s" % (r.get("tag_name", ""), str(r.get("draft", "")).lower(), str(r.get("prerelease", "")).lower()))' +)" || { + printf 'could not query previous stable release %s in %s\n' "$TAG" "$REPOSITORY" >&2 + exit 1 +} +[ "$stable_state" = "$(printf '%s\tfalse\tfalse' "$TAG")" ] || { + printf '%s is not a public stable GitHub Release in %s (got: %s)\n' \ + "$TAG" "$REPOSITORY" "$stable_state" >&2 + exit 1 +} + +stable_runs="$( + github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100" \ + | python3 -c 'import json,sys +for run in json.load(sys.stdin).get("workflow_runs", []): + print("%s\t%s\t%s" % (run.get("head_sha", ""), run.get("head_branch", ""), run.get("conclusion", "")))' +)" || { + printf 'could not query Release workflow delivery for %s in %s\n' "$TAG" "$REPOSITORY" >&2 + exit 1 +} +printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TAG" ' + $1 == sha && $2 == tag && $3 == "success" { found = 1 } + END { exit(found ? 0 : 1) } +' || { + printf 'Release workflow did not complete successfully for stable baseline %s at %s\n' \ + "$TAG" "$EXPECTED_COMMIT" >&2 + exit 1 +} + +printf 'Delivered stable baseline verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT" diff --git a/scripts/release/verify-github-release-assets.sh b/scripts/release/verify-github-release-assets.sh new file mode 100755 index 00000000..3d80771d --- /dev/null +++ b/scripts/release/verify-github-release-assets.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +TAG="${1:-}" +REPOSITORY="${GITHUB_REPOSITORY:-}" + +[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || { + printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh \n' >&2 + exit 2 +} +command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +cat > "$tmp/expected" <<'EOF' +checksums.txt +dws-darwin-amd64.tar.gz +dws-darwin-arm64.tar.gz +dws-linux-amd64.tar.gz +dws-linux-arm64.tar.gz +dws-skills.zip +dws-windows-amd64.zip +dws-windows-arm64.zip +EOF +LC_ALL=C sort "$tmp/expected" -o "$tmp/expected" + +gh api -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/releases/tags/$TAG" \ + --jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual" +if ! diff -u "$tmp/expected" "$tmp/actual"; then + printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2 + exit 1 +fi + +printf 'GitHub Release asset set verified: %s\n' "$TAG" diff --git a/scripts/release/verify-github-tag-authority.sh b/scripts/release/verify-github-tag-authority.sh new file mode 100755 index 00000000..0a75b313 --- /dev/null +++ b/scripts/release/verify-github-tag-authority.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +TAG="${1:-}" +EXPECTED_COMMIT="${2:-}" +REPOSITORY="${GITHUB_REPOSITORY:-}" + +[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || { + printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh \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")" +remote_ref="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/git/ref/tags/$TAG" \ + --jq '[.object.type, .object.sha] | @tsv' +)" +[ "$remote_ref" = "$(printf 'tag\t%s' "$local_object")" ] || { + printf 'remote tag object for %s differs from the sealed annotated tag (local=%s remote=%s)\n' \ + "$TAG" "$local_object" "$remote_ref" >&2 + exit 1 +} + +remote_tag="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$REPOSITORY/git/tags/$local_object" \ + --jq '[.tag, .object.type, .object.sha] | @tsv' +)" +[ "$remote_tag" = "$(printf '%s\tcommit\t%s' "$TAG" "$EXPECTED_COMMIT")" ] || { + printf 'remote annotated tag %s does not peel to expected commit %s (got: %s)\n' \ + "$TAG" "$EXPECTED_COMMIT" "$remote_tag" >&2 + exit 1 +} + +printf 'GitHub tag authority verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT" diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 14399cc9..28ac1a2c 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -5,6 +5,10 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}" FORMULA_PATH="$DIST_DIR/homebrew/dingtalk-workspace-cli-local.rb" NPM_STAGE_DIR="$DIST_DIR/npm/dingtalk-workspace-cli" +RUN_NPM=1 +RUN_BREW=1 +EXPECTED_VERSION="" +VERIFY_SKILL_TARGETS=1 say() { printf '%s\n' "$*" @@ -23,6 +27,34 @@ need_file() { [ -f "$1" ] || err "required file not found: $1" } +usage() { + printf '%s\n' "usage: $0 [--npm-only|--brew-only] [--expected-version ] [--skip-skill-targets]" >&2 +} + +mode_seen=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --npm-only) + [ "$mode_seen" -eq 0 ] || { usage; exit 2; } + RUN_NPM=1; RUN_BREW=0; mode_seen=1; shift + ;; + --brew-only) + [ "$mode_seen" -eq 0 ] || { usage; exit 2; } + RUN_NPM=0; RUN_BREW=1; mode_seen=1; shift + ;; + --expected-version) + [ "$#" -ge 2 ] || { usage; exit 2; } + EXPECTED_VERSION="${2#v}" + shift 2 + ;; + --skip-skill-targets) VERIFY_SKILL_TARGETS=0; shift ;; + -h|--help) usage; exit 0 ;; + *) usage; exit 2 ;; + esac +done + +[ -z "$EXPECTED_VERSION" ] || need_cmd strings + TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" HOME_AGENT_PARENTS=" .claude @@ -60,13 +92,11 @@ HOME_SKILL_TARGETS=" .hermes/skills/dws " cleanup() { - if command -v brew >/dev/null 2>&1; then - HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true - if [ -n "${BREW_TAP_NAME:-}" ]; then - HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true - fi + if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + brew uninstall --force "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null 2>&1 || true + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true fi rm -rf "$TMP_ROOT" } @@ -111,8 +141,18 @@ verify_npm() { npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - "$npm_prefix/bin/dws" --help >/dev/null - verify_skill_targets "$npm_home" + HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + if [ -n "$EXPECTED_VERSION" ]; then + vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" + need_file "$vendor_bin" + strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \ + err "npm-installed binary does not embed expected version v$EXPECTED_VERSION" + EXPECTED_VERSION="$EXPECTED_VERSION" node -e ' + const pkg = require(process.argv[1]); + if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); + ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" + fi + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null @@ -123,6 +163,10 @@ verify_npm() { verify_brew() { need_cmd brew need_file "$FORMULA_PATH" + if [ -n "$EXPECTED_VERSION" ]; then + grep -Fq " version \"$EXPECTED_VERSION\"" "$FORMULA_PATH" || \ + err "Homebrew formula does not declare version $EXPECTED_VERSION" + fi brew_home="$TMP_ROOT/brew-home" mkdir -p "$brew_home" @@ -130,36 +174,39 @@ verify_brew() { BREW_TAP_NAME="local/dws-package-verify-$$" say "==> verifying Homebrew formula install" - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true - - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null tap_repo="$( - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ brew --repository "$BREW_TAP_NAME" )" mkdir -p "$tap_repo/Formula" - cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb" + sed "s|skill_home_override = \"\"|skill_home_override = \"$brew_home\"|" \ + "$FORMULA_PATH" > "$tap_repo/Formula/dingtalk-workspace-cli-local.rb" - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew install -y "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null prefix="$( - HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ - brew --prefix dingtalk-workspace-cli-local + HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ + brew --prefix "$BREW_TAP_NAME/dingtalk-workspace-cli-local" )" [ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws" - "$prefix/bin/dws" --help >/dev/null - need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md" + HOME="$brew_home" "$prefix/bin/dws" --help >/dev/null + if [ -n "$EXPECTED_VERSION" ]; then + strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \ + err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION" + installed_version="$(HOMEBREW_NO_AUTO_UPDATE=1 brew list --versions "$BREW_TAP_NAME/dingtalk-workspace-cli-local" | awk '{ print $2 }')" + [ "$installed_version" = "$EXPECTED_VERSION" ] || \ + err "Homebrew installed version $installed_version, expected $EXPECTED_VERSION" + fi + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$brew_home" } need_file "$DIST_DIR/dws-skills.zip" -verify_npm -verify_brew +[ "$RUN_NPM" -eq 0 ] || verify_npm +[ "$RUN_BREW" -eq 0 ] || verify_brew say "Package-manager verification complete." diff --git a/scripts/release/verify-release-artifacts.sh b/scripts/release/verify-release-artifacts.sh new file mode 100755 index 00000000..0ddc65df --- /dev/null +++ b/scripts/release/verify-release-artifacts.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}" +VERSION="${1:-${DWS_PACKAGE_VERSION:-}}" + +[ -n "$VERSION" ] || { printf 'expected release version is required\n' >&2; exit 2; } +SEMVER="${VERSION#v}" +CHECKSUMS="$DIST_DIR/checksums.txt" +EXPECTED_PLATFORM_ASSETS=" +dws-darwin-amd64.tar.gz +dws-darwin-arm64.tar.gz +dws-linux-amd64.tar.gz +dws-linux-arm64.tar.gz +dws-windows-amd64.zip +dws-windows-arm64.zip +" +EXPECTED_ASSETS="$EXPECTED_PLATFORM_ASSETS +dws-skills.zip +" + +[ -f "$CHECKSUMS" ] || { printf 'missing checksums.txt in %s\n' "$DIST_DIR" >&2; exit 1; } + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-binary.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +# The public asset namespace is an exact set. GoReleaser may also leave local +# metadata files (artifacts.json/config.yaml/metadata.json), but the upload and +# npm staging globs never publish them. +printf '%s\nchecksums.txt\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-root" +for path in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$CHECKSUMS"; do + [ -f "$path" ] || continue + basename "$path" +done | LC_ALL=C sort > "$tmp/actual-root" +if ! diff -u "$tmp/expected-root" "$tmp/actual-root"; then + printf 'public release assets must contain exactly the supported files\n' >&2 + exit 1 +fi + +checksum_format_ok=1 +awk ' + NF != 2 { bad = 1; next } + $1 !~ /^[0-9a-fA-F]{64}$/ { bad = 1; next } + { print $2 } + END { if (bad) exit 1 } +' "$CHECKSUMS" > "$tmp/checksum-assets" || checksum_format_ok=0 +[ "$checksum_format_ok" -eq 1 ] || { + printf 'checksums.txt must contain only SHA-256 and filename pairs\n' >&2 + exit 1 +} + +skills_checksum_count="$(awk '$2 == "dws-skills.zip" { count++ } END { print count + 0 }' "$CHECKSUMS")" +[ "$skills_checksum_count" -eq 1 ] || { + printf 'checksums.txt must contain dws-skills.zip exactly once (found %s)\n' "$skills_checksum_count" >&2 + exit 1 +} +printf '%s\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-checksums" +LC_ALL=C sort "$tmp/checksum-assets" > "$tmp/actual-checksums" +if ! diff -u "$tmp/expected-checksums" "$tmp/actual-checksums"; then + printf 'checksums.txt must describe exactly the supported release assets\n' >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$DIST_DIR" && sha256sum --check checksums.txt) +elif command -v shasum >/dev/null 2>&1; then + (cd "$DIST_DIR" && shasum -a 256 --check checksums.txt) +else + printf 'sha256sum or shasum is required\n' >&2 + exit 1 +fi + +verify_binary_version() { + asset="$1" + extract_dir="$tmp/extract-${asset}" + mkdir -p "$extract_dir" + case "$asset" in + *.tar.gz) + tar -xzf "$DIST_DIR/$asset" -C "$extract_dir" + binary="$extract_dir/dws" + ;; + *.zip) + unzip -q "$DIST_DIR/$asset" -d "$extract_dir" + binary="$extract_dir/dws.exe" + ;; + *) + printf 'unsupported release archive: %s\n' "$asset" >&2 + return 1 + ;; + esac + [ -f "$binary" ] || { + printf '%s does not contain the expected dws binary\n' "$asset" >&2 + return 1 + } + strings "$binary" | grep -Fqx "v$SEMVER" || { + printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2 + return 1 + } +} + +for asset in $EXPECTED_PLATFORM_ASSETS; do + verify_binary_version "$asset" +done + +printf 'Release artifacts verified for v%s.\n' "$SEMVER" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index b6620246..0dd71e37 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -105,6 +105,8 @@ func seedDistArchive(t *testing.T, path string) { func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string { t.Helper() + trimmedBaseURL := strings.TrimRight(releaseBaseURL, "/") + version := trimmedBaseURL[strings.LastIndex(trimmedBaseURL, "/")+1:] binDir := t.TempDir() fakeCodesign := filepath.Join(binDir, "codesign") @@ -114,7 +116,7 @@ func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string { return append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), - "DWS_PACKAGE_VERSION=v0.0.0", + "DWS_PACKAGE_VERSION="+version, "DWS_PACKAGE_DIST_DIR="+distDir, "DWS_RELEASE_BASE_URL="+releaseBaseURL, ) @@ -186,6 +188,8 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { formulaText := string(formulaData) for _, want := range []string{ "class DingtalkWorkspaceCliLocal < Formula", + "version \"1.2.3\"", + "skill_home_override = \"\"", "resource \"skills\" do", "Install locally built DingTalk workspace CLI artifacts for verification", } { @@ -202,14 +206,8 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { releaseFormulaText := string(releaseFormulaData) for _, want := range []string{ "class DingtalkWorkspaceCli < Formula", - `desc "Automate DingTalk workspace tasks from the terminal"`, - `version "0.0.0"`, - "on_macos do", - "on_linux do", - "https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-amd64.tar.gz", - "https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-arm64.tar.gz", - "https://downloads.example.com/dws/releases/v1.2.3/dws-linux-amd64.tar.gz", - "https://downloads.example.com/dws/releases/v1.2.3/dws-linux-arm64.tar.gz", + "version \"1.2.3\"", + "https://downloads.example.com/dws/releases/v1.2.3/" + archiveName, "https://downloads.example.com/dws/releases/v1.2.3/dws-skills.zip", } { if !strings.Contains(releaseFormulaText, want) { @@ -259,13 +257,20 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { } } - // Verify checksums.txt was updated to include skills zip + // Re-running post packaging must replace, not duplicate, the skills checksum. + cmd = exec.Command("sh", scriptPath) + cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("second post-goreleaser.sh error = %v\noutput:\n%s", err, output) + } + + // Verify checksums.txt includes exactly one skills zip entry. checksumsData, err := os.ReadFile(filepath.Join(distDir, "checksums.txt")) if err != nil { t.Fatalf("ReadFile(checksums.txt) error = %v", err) } - if !strings.Contains(string(checksumsData), "dws-skills.zip") { - t.Fatalf("checksums.txt missing dws-skills.zip entry:\n%s", string(checksumsData)) + if count := strings.Count(string(checksumsData), "dws-skills.zip"); count != 1 { + t.Fatalf("checksums.txt dws-skills.zip count = %d, want 1:\n%s", count, checksumsData) } } @@ -449,7 +454,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) { } packageAssetsDir := filepath.Join(distDir, "npm", "dingtalk-workspace-cli", "assets") - for _, rel := range append(allArchives, "dws-skills.zip") { + for _, rel := range append(allArchives, "dws-skills.zip", "checksums.txt") { if _, err := os.Stat(filepath.Join(packageAssetsDir, rel)); err != nil { t.Fatalf("npm asset missing %q: %v", rel, err) } diff --git a/test/scripts/release_entry_test.go b/test/scripts/release_entry_test.go new file mode 100644 index 00000000..f81ce5ee --- /dev/null +++ b/test/scripts/release_entry_test.go @@ -0,0 +1,301 @@ +package scripts_test + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type releaseEntryFixture struct { + repo *releaseTestRepo + entry string + log string +} + +func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture { + t.Helper() + r := newReleaseTestRepo(t) + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + entry := filepath.Join(r.root, "scripts", "release", "dws-release.sh") + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, filepath.Join(sourceRoot, "scripts", "release", "dws-release.sh"), entry, 0o755) + fakeDelegate := func(name string) []byte { + return []byte("#!/bin/sh\nset -eu\nprintf '" + name + "' >> \"$DWS_RELEASE_CALL_LOG\"\nfor arg in \"$@\"; do printf '\\t%s' \"$arg\" >> \"$DWS_RELEASE_CALL_LOG\"; done\nprintf '\\n' >> \"$DWS_RELEASE_CALL_LOG\"\nexit \"${DWS_RELEASE_FAKE_EXIT:-0}\"\n") + } + 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) + r.commitAndPush(t, "install unified release entry fixture") + return &releaseEntryFixture{repo: r, entry: entry, log: filepath.Join(t.TempDir(), "calls.log")} +} + +func (f *releaseEntryFixture) run(t *testing.T, extraEnv []string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command("sh", append([]string{f.entry}, args...)...) + cmd.Dir = f.repo.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_CALL_LOG="+f.log) + cmd.Env = append(cmd.Env, extraEnv...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func (f *releaseEntryFixture) configureRemote(t *testing.T, remote string) { + t.Helper() + output, err := f.run(t, nil, "config", "--remote", remote) + if err != nil { + t.Fatalf("config remote error = %v\noutput:\n%s", err, output) + } +} + +func (f *releaseEntryFixture) callLog(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(f.log) + if errors.Is(err, os.ErrNotExist) { + return "" + } + if err != nil { + t.Fatalf("ReadFile(call log) error = %v", err) + } + return string(data) +} + +func TestDWSReleaseEntryPreparesMissingChangelogAndStops(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "prerelease", + args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"}, + want: "prepare\tprerelease\tv1.0.1-beta.1\n", + }, + { + name: "stable", + args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"}, + want: "prepare\tstable\tv1.0.1\t--from-beta\tv1.0.1-beta.1\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + output, err := f.run(t, nil, test.args...) + if err != nil { + t.Fatalf("dws-release prepare error = %v\noutput:\n%s", err, output) + } + if got := f.callLog(t); got != test.want { + t.Fatalf("delegate calls = %q, want %q", got, test.want) + } + if !strings.Contains(output, "publishing intentionally stopped") { + t.Fatalf("prepare output did not make the stop boundary clear:\n%s", output) + } + }) + } +} + +func TestDWSReleaseEntryRoutesOneGuardedCommand(t *testing.T) { + tests := []struct { + name string + section string + args []string + wantCall string + }{ + { + name: "check prerelease", + section: betaSection(), + args: []string{"v1.0.1-beta.1", "--remote", "origin"}, + wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n", + }, + { + name: "publish prerelease keeps confirmation", + section: betaSection(), + args: []string{"v1.0.1-beta.1", "--remote", "origin", "--publish"}, + wantCall: "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\t--publish\n", + }, + { + name: "check stable", + section: stableSection(), + args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin"}, + wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\n", + }, + { + name: "publish stable keeps confirmation", + section: stableSection(), + args: []string{"v1.0.1", "--from-beta", "v1.0.1-beta.1", "--remote", "origin", "--publish"}, + wantCall: "release\tstable\tv1.0.1\t--remote\torigin\t--from-beta\tv1.0.1-beta.1\t--publish\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(test.section)), 0o644) + f.repo.commitAndPush(t, "add candidate changelog") + output, err := f.run(t, nil, test.args...) + if err != nil { + t.Fatalf("dws-release route error = %v\noutput:\n%s", err, output) + } + if got := f.callLog(t); got != test.wantCall { + t.Fatalf("delegate calls = %q, want %q\noutput:\n%s", got, test.wantCall, output) + } + }) + } +} + +func TestDWSReleaseEntryRejectsInvalidInvocationBeforeDelegation(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "invalid version", args: []string{"1.0.1"}, want: "invalid release version"}, + {name: "stable missing beta", args: []string{"v1.0.1"}, want: "requires --from-beta"}, + {name: "prerelease with beta", args: []string{"v1.0.1-beta.1", "--from-beta", "v1.0.1-beta.1"}, want: "only valid for stable"}, + {name: "conflicting mode", args: []string{"v1.0.1-beta.1", "--check", "--publish"}, want: "cannot be used together"}, + {name: "yes bypass", args: []string{"v1.0.1-beta.1", "--publish", "--yes"}, want: "unknown argument"}, + {name: "unknown flag", args: []string{"v1.0.1-beta.1", "--force"}, want: "unknown argument"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newReleaseEntryFixture(t) + output, err := f.run(t, nil, test.args...) + if err == nil || !strings.Contains(output, test.want) { + t.Fatalf("invalid invocation: err=%v, want=%q\noutput:\n%s", err, test.want, output) + } + if got := f.callLog(t); got != "" { + t.Fatalf("invalid invocation delegated work: %q", got) + } + }) + } +} + +func TestDWSReleaseEntryUsesConfiguredRemote(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + f.repo.commitAndPush(t, "add beta changelog") + output, err := f.run(t, nil, "v1.0.1-beta.1") + if err != nil { + t.Fatalf("configured remote route error = %v\noutput:\n%s", err, output) + } + want := "release\tprerelease\tv1.0.1-beta.1\t--remote\torigin\n" + if got := f.callLog(t); got != want { + t.Fatalf("delegate calls = %q, want %q", got, want) + } +} + +func TestDWSReleaseEntryFailsClosedWithoutRemote(t *testing.T) { + f := newReleaseEntryFixture(t) + mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + f.repo.commitAndPush(t, "add beta changelog") + output, err := f.run(t, nil, "v1.0.1-beta.1") + if err == nil || !strings.Contains(output, "release remote is not configured") { + t.Fatalf("missing remote did not fail closed: err=%v\noutput:\n%s", err, output) + } + if got := f.callLog(t); got != "" { + t.Fatalf("missing remote delegated work: %q", got) + } +} + +func TestDWSReleaseEntryPropagatesDelegateFailure(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + f.repo.commitAndPush(t, "add beta changelog") + output, err := f.run(t, []string{"DWS_RELEASE_FAKE_EXIT=23"}, "v1.0.1-beta.1", "--remote", "origin") + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 { + t.Fatalf("delegate error was not propagated: err=%v\noutput:\n%s", err, output) + } +} + +func TestDWSReleaseEntryFastForwardsCleanMain(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + other := filepath.Join(t.TempDir(), "other") + mustRun(t, filepath.Dir(other), "git", "clone", "-b", "main", f.repo.remote, other) + mustRun(t, other, "git", "config", "user.name", "Release Test") + mustRun(t, other, "git", "config", "user.email", "release-test@example.com") + mustWriteFile(t, filepath.Join(other, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + mustRun(t, other, "git", "add", "CHANGELOG.md") + mustRun(t, other, "git", "commit", "-m", "add remote beta changelog") + mustRun(t, other, "git", "push", "origin", "main") + + output, err := f.run(t, nil, "v1.0.1-beta.1", "--remote", "origin") + if err != nil { + t.Fatalf("fast-forward route error = %v\noutput:\n%s", err, output) + } + localHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "HEAD")) + remoteHead := strings.TrimSpace(mustOutput(t, f.repo.root, "git", "rev-parse", "refs/remotes/origin/main")) + if localHead != remoteHead { + t.Fatalf("local main was not fast-forwarded: local=%s remote=%s", localHead, remoteHead) + } + if got := f.callLog(t); !strings.HasPrefix(got, "release\tprerelease\tv1.0.1-beta.1") { + t.Fatalf("fast-forward did not reach guarded delegate: %q", got) + } +} + +func TestDWSReleaseEntryRejectsRetargetedRemote(t *testing.T) { + f := newReleaseEntryFixture(t) + f.configureRemote(t, "origin") + mustWriteFile(t, filepath.Join(f.repo.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + f.repo.commitAndPush(t, "add beta changelog") + otherRemote := filepath.Join(t.TempDir(), "other.git") + mustRun(t, f.repo.root, "git", "init", "--bare", otherRemote) + mustRun(t, f.repo.root, "git", "remote", "set-url", "origin", otherRemote) + + output, err := f.run(t, nil, "v1.0.1-beta.1") + if err == nil || !strings.Contains(output, "changed repository identity") { + t.Fatalf("retargeted remote was not rejected: err=%v\noutput:\n%s", err, output) + } + if got := f.callLog(t); got != "" { + t.Fatalf("retargeted remote delegated work: %q", got) + } +} + +func TestDWSReleaseEntryRejectsAuthorityOverrides(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "non github remote", env: "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1", want: "test-only"}, + {name: "official tags URL", env: "DWS_RELEASE_OFFICIAL_TAGS_URL=https://example.com/tags.git", want: "cannot override"}, + {name: "official repository", env: "DWS_RELEASE_OFFICIAL_REPOSITORY=other/repo", want: "cannot override"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := newReleaseEntryFixture(t) + output, err := f.run(t, []string{test.env}, "v1.0.1-beta.1") + if err == nil || !strings.Contains(output, test.want) { + t.Fatalf("authority override was not rejected: err=%v, want=%q\noutput:\n%s", err, test.want, output) + } + if got := f.callLog(t); got != "" { + t.Fatalf("authority override delegated work: %q", got) + } + }) + } +} + +func TestReleaseCommandRejectsNonInteractivePublishWithoutYes(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "install release automation") + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", + ) + if err == nil || !strings.Contains(output, "interactive confirmation is unavailable") { + t.Fatalf("non-interactive publish did not require confirmation: err=%v\noutput:\n%s", err, output) + } + if got := mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1"); got != "" { + t.Fatalf("rejected non-interactive publish created a tag: %s", got) + } +} diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go new file mode 100644 index 00000000..50773aff --- /dev/null +++ b/test/scripts/release_script_test.go @@ -0,0 +1,924 @@ +package scripts_test + +import ( + "crypto/sha256" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +var releasePlatformAssets = []string{ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", +} + +func writeVersionedReleaseArchive(t *testing.T, dist, asset, version string) { + t.Helper() + stage := t.TempDir() + binary := "dws" + if strings.HasSuffix(asset, ".zip") { + binary = "dws.exe" + } + mustWriteFile(t, filepath.Join(stage, binary), []byte("fake release binary\n"+version+"\n"), 0o755) + if strings.HasSuffix(asset, ".zip") { + mustRun(t, stage, "zip", "-q", filepath.Join(dist, asset), binary) + return + } + mustRun(t, stage, "tar", "-czf", filepath.Join(dist, asset), binary) +} + +func writeReleaseChecksums(t *testing.T, dist string, includeSkills bool) { + t.Helper() + assets := append([]string{}, releasePlatformAssets...) + if includeSkills { + assets = append(assets, "dws-skills.zip") + } + sort.Strings(assets) + var lines []string + for _, asset := range assets { + data, err := os.ReadFile(filepath.Join(dist, asset)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", asset, err) + } + lines = append(lines, fmt.Sprintf("%x %s", sha256.Sum256(data), asset)) + } + mustWriteFile(t, filepath.Join(dist, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + +func seedVersionedReleaseArtifacts(t *testing.T, dist, version string) { + t.Helper() + if err := os.MkdirAll(dist, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", dist, err) + } + for _, asset := range releasePlatformAssets { + writeVersionedReleaseArchive(t, dist, asset, version) + } + mustWriteFile(t, filepath.Join(dist, "dws-skills.zip"), []byte("fake skills\n"), 0o644) + writeReleaseChecksums(t, dist, true) +} + +type releaseTestRepo struct { + root string + remote string + contract string + prepare string + releaseCmd string + lib string + verify string +} + +func newReleaseTestRepo(t *testing.T) *releaseTestRepo { + t.Helper() + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + + base := t.TempDir() + root := filepath.Join(base, "work") + remote := filepath.Join(base, "remote.git") + mustRun(t, base, "git", "init", "--bare", remote) + mustRun(t, base, "git", "init", "-b", "main", root) + mustRun(t, root, "git", "config", "user.name", "Release Test") + mustRun(t, root, "git", "config", "user.email", "release-test@example.com") + + mustWriteFile(t, filepath.Join(root, "CHANGELOG.md"), []byte(releaseChangelog()), 0o644) + mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("initial\n"), 0o644) + mustRun(t, root, "git", "add", ".") + mustRun(t, root, "git", "commit", "-m", "initial stable") + mustRun(t, root, "git", "tag", "-a", "v1.0.0", "-m", "Release v1.0.0") + mustRun(t, root, "git", "remote", "add", "origin", remote) + mustRun(t, root, "git", "push", "-u", "origin", "main") + mustRun(t, root, "git", "push", "origin", "v1.0.0") + + return &releaseTestRepo{ + root: root, + remote: remote, + contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"), + prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"), + releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"), + lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"), + verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"), + } +} + +func TestReleaseVersionOrdering(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + lib := filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh") + tests := []struct { + candidate string + baseline string + greater bool + }{ + {candidate: "v1.0.2", baseline: "v1.0.1", greater: true}, + {candidate: "v1.1.0-beta.1", baseline: "v1.0.9-beta.9", greater: true}, + {candidate: "v1.0.1-beta.2", baseline: "v1.0.1-beta.1", greater: true}, + {candidate: "v1.0.1", baseline: "v1.0.1-beta.9", greater: true}, + {candidate: "v1.0.1-beta.1", baseline: "v1.0.1-beta.2", greater: false}, + {candidate: "v1.0.1-beta.1", baseline: "v1.0.1", greater: false}, + {candidate: "v1.0.1", baseline: "v1.0.1", greater: false}, + } + for _, test := range tests { + cmd := exec.Command("sh", "-c", `. "$1"; release_version_is_greater "$2" "$3"`, "sh", lib, test.candidate, test.baseline) + err := cmd.Run() + if (err == nil) != test.greater { + t.Fatalf("release_version_is_greater(%s, %s) error = %v, want greater=%v", test.candidate, test.baseline, err, test.greater) + } + } +} + +func TestReleaseNpmPackingIgnoresLifecycleScripts(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + packageDir := filepath.Join(t.TempDir(), "package") + marker := filepath.Join(t.TempDir(), "prepack-ran") + manifest := fmt.Sprintf(`{ + "name": "dingtalk-workspace-cli", + "version": "1.2.3", + "scripts": {"prepack": "touch %s"}, + "files": ["README.md"] +} +`, marker) + mustWriteFile(t, filepath.Join(packageDir, "package.json"), []byte(manifest), 0o644) + mustWriteFile(t, filepath.Join(packageDir, "README.md"), []byte("test package\n"), 0o644) + outputTarball := filepath.Join(t.TempDir(), "package.tgz") + cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "pack-npm-package.sh"), packageDir, outputTarball) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("pack-npm-package error = %v\noutput:\n%s", err, output) + } + if !strings.HasPrefix(strings.TrimSpace(string(output)), "sha512-") { + t.Fatalf("pack output is not an integrity value: %s", output) + } + if _, err := os.Stat(outputTarball); err != nil { + t.Fatalf("packed tarball missing: %v", err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("npm prepack lifecycle unexpectedly ran: %v", err) + } +} + +func TestReleaseNpmStagingRejectsUnexpectedLifecycleScripts(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + root := t.TempDir() + source := filepath.Join(root, "source") + dist := filepath.Join(root, "dist") + mustWriteFile(t, filepath.Join(source, "build", "npm", "install.js"), []byte("\n"), 0o644) + mustWriteFile(t, filepath.Join(source, "build", "npm", "bin", "dws.js"), []byte("\n"), 0o755) + mustWriteFile(t, filepath.Join(source, "build", "npm", "README.md"), []byte("test\n"), 0o644) + mustWriteFile(t, filepath.Join(source, "build", "npm", "package.json.tmpl"), []byte(`{ + "name": "dingtalk-workspace-cli", + "version": "__VERSION__", + "bin": {"dws": "./bin/dws.js"}, + "scripts": {"postinstall": "node install.js", "prepublishOnly": "node steal.js"} +} +`), 0o644) + cmd := exec.Command("sh", filepath.Join(sourceRoot, "scripts", "release", "stage-npm-package.sh"), "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_SOURCE_ROOT="+source, "DWS_PACKAGE_DIST_DIR="+dist) + output, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(output), "unexpected npm lifecycle scripts") { + t.Fatalf("unexpected lifecycle script was not rejected: err=%v\noutput:\n%s", err, output) + } +} + +func TestReleaseGitHubAssetSetIsExact(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + binDir := t.TempDir() + fakeGH := filepath.Join(binDir, "gh") + mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755) + exact := strings.Join([]string{ + "dws-windows-arm64.zip", + "dws-linux-amd64.tar.gz", + "checksums.txt", + "dws-skills.zip", + "dws-darwin-amd64.tar.gz", + "dws-windows-amd64.zip", + "dws-linux-arm64.tar.gz", + "dws-darwin-arm64.tar.gz", + }, "\n") + script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh") + run := func(assets string) (string, error) { + cmd := exec.Command("sh", script, "v1.2.3") + cmd.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "GITHUB_REPOSITORY=owner/repo", + "GH_ASSETS=" + assets, + } + output, err := cmd.CombinedOutput() + return string(output), err + } + if output, err := run(exact); err != nil { + t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output) + } + if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") { + t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output) + } +} + +func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + r := newReleaseTestRepo(t) + commit := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}")) + binDir := t.TempDir() + fakeCurl := filepath.Join(binDir, "curl") + mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh +set -eu +for arg in "$@"; do last="$arg"; done +case "$last" in + */releases/tags/*) + printf '{"tag_name":"v1.0.0","draft":false,"prerelease":false}\n' + ;; + */actions/workflows/release.yml/runs*) + printf '{"workflow_runs":[{"head_sha":"%s","head_branch":"v1.0.0","conclusion":"%s"}]}\n' "$EXPECTED_COMMIT" "$RUN_CONCLUSION" + ;; + *) exit 1 ;; +esac +`), 0o755) + script := filepath.Join(sourceRoot, "scripts", "release", "verify-delivered-stable.sh") + run := func(conclusion string) (string, error) { + cmd := exec.Command("sh", script, "v1.0.0", commit) + cmd.Dir = r.root + cmd.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo", + "EXPECTED_COMMIT=" + commit, + "RUN_CONCLUSION=" + conclusion, + } + output, err := cmd.CombinedOutput() + return string(output), err + } + if output, err := run("success"); err != nil { + t.Fatalf("delivered stable was rejected: %v\n%s", err, output) + } + if output, err := run("failure"); err == nil || !strings.Contains(output, "did not complete successfully") { + t.Fatalf("orphan stable tag was not rejected: err=%v\n%s", err, output) + } +} + +func releaseChangelog(sections ...string) string { + text := "# Changelog\n\n## [Unreleased]\n\n" + for _, section := range sections { + text += section + if !strings.HasSuffix(text, "\n\n") { + text += "\n" + } + } + text += "## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n" + return text +} + +func betaSection() string { + return "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate the sealed beta candidate.\n\n" +} + +func stableSection() string { + return "## [1.0.1] - 2026-07-11\n\nThis release promotes the sealed `v1.0.1-beta.1` contents to stable.\n\n### Changed\n\n- Publish the validated candidate to the stable channel.\n\n" +} + +func (r *releaseTestRepo) commitAndPush(t *testing.T, message string) { + t.Helper() + mustRun(t, r.root, "git", "add", ".") + mustRun(t, r.root, "git", "commit", "-m", message) + mustRun(t, r.root, "git", "push", "origin", "main") +} + +func (r *releaseTestRepo) seedBeta(t *testing.T) { + t.Helper() + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + mustWriteFile(t, filepath.Join(r.root, "feature.txt"), []byte("sealed candidate\n"), 0o644) + r.commitAndPush(t, "prepare beta") + mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1", "-m", "Channel: prerelease") + mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1") +} + +func runReleaseScript(t *testing.T, workdir, script string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Dir = workdir + cmd.Env = append(os.Environ(), "DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE=1") + if remote, err := exec.Command("git", "-C", workdir, "remote", "get-url", "origin").Output(); err == nil { + cmd.Env = append(cmd.Env, "DWS_RELEASE_OFFICIAL_TAGS_URL="+strings.TrimSpace(string(remote))) + } + output, err := cmd.CombinedOutput() + return string(output), err +} + +func TestReleaseContractAcceptsPrereleaseAndWritesNotes(t *testing.T) { + r := newReleaseTestRepo(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "prepare beta changelog") + + notes := filepath.Join(t.TempDir(), "notes.md") + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--context", "local", + "--remote", "origin", + "--notes-output", notes, + ) + if err != nil { + t.Fatalf("release contract error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "Release contract passed") { + t.Fatalf("contract output missing success:\n%s", output) + } + notesData, err := os.ReadFile(notes) + if err != nil { + t.Fatalf("ReadFile(notes) error = %v", err) + } + if !strings.Contains(string(notesData), "Validate the sealed beta candidate") { + t.Fatalf("notes did not come from exact changelog section:\n%s", notesData) + } +} + +func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) { + r := newReleaseTestRepo(t) + tests := []struct { + channel string + version string + }{ + {channel: "prerelease", version: "v1.0.1"}, + {channel: "stable", version: "v1.0.1-beta.1"}, + {channel: "prerelease", version: "v1.0.1-rc.1"}, + {channel: "prerelease", version: "v1.0.1-preview"}, + {channel: "stable", version: "1.0.1"}, + {channel: "stable", version: "v01.0.1"}, + {channel: "prerelease", version: "v1.0.1-beta.0"}, + {channel: "prerelease", version: "v1.0.1-beta.2"}, + } + for _, test := range tests { + t.Run(test.channel+"_"+strings.ReplaceAll(test.version, ".", "_"), func(t *testing.T) { + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", test.channel, + "--version", test.version, + ) + if err == nil { + t.Fatalf("invalid pair unexpectedly passed:\n%s", output) + } + }) + } +} + +func TestReleaseContractRejectsBadChangelogSections(t *testing.T) { + tests := []struct { + name string + sections string + want string + }{ + {name: "missing", sections: "", want: "exactly one section"}, + {name: "empty", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n", want: "must contain release notes"}, + {name: "placeholder", sections: "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- TODO: write this.\n\n", want: "TODO/TBD"}, + {name: "duplicate", sections: betaSection() + betaSection(), want: "exactly one section"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := newReleaseTestRepo(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(test.sections)), 0o644) + mustWriteFile(t, filepath.Join(r.root, "candidate.txt"), []byte(test.name+"\n"), 0o644) + r.commitAndPush(t, "write candidate changelog") + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--remote", "origin", + ) + if err == nil { + t.Fatalf("bad changelog unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, test.want) { + t.Fatalf("output missing %q:\n%s", test.want, output) + } + }) + } +} + +func TestReleaseContractRejectsDirtyOrUnsyncedMain(t *testing.T) { + r := newReleaseTestRepo(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "prepare beta") + + mustWriteFile(t, filepath.Join(r.root, "dirty.txt"), []byte("dirty\n"), 0o644) + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--remote", "origin", + ) + if err == nil || !strings.Contains(output, "worktree must be clean") { + t.Fatalf("dirty worktree was not blocked: err=%v\noutput:\n%s", err, output) + } + + mustRun(t, r.root, "git", "add", "dirty.txt") + mustRun(t, r.root, "git", "commit", "-m", "local commit not pushed") + output, err = runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--remote", "origin", + ) + if err == nil || !strings.Contains(output, "must exactly match origin/main") { + t.Fatalf("unsynced main was not blocked: err=%v\noutput:\n%s", err, output) + } +} + +func TestReleaseContractStablePromotionAllowsOnlyChangelogDiff(t *testing.T) { + t.Run("sealed", func(t *testing.T) { + r := newReleaseTestRepo(t) + r.seedBeta(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644) + r.commitAndPush(t, "prepare stable changelog") + + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "stable", + "--version", "v1.0.1", + "--from-beta", "v1.0.1-beta.1", + "--remote", "origin", + ) + if err != nil { + t.Fatalf("sealed stable promotion error = %v\noutput:\n%s", err, output) + } + }) + + t.Run("source drift", func(t *testing.T) { + r := newReleaseTestRepo(t) + r.seedBeta(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644) + mustWriteFile(t, filepath.Join(r.root, "drift.txt"), []byte("untested change\n"), 0o644) + r.commitAndPush(t, "drift after beta") + + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "stable", + "--version", "v1.0.1", + "--from-beta", "v1.0.1-beta.1", + "--remote", "origin", + ) + if err == nil { + t.Fatalf("drifted stable promotion unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, "only CHANGELOG.md may differ") || !strings.Contains(output, "drift.txt") { + t.Fatalf("drift output is not actionable:\n%s", output) + } + }) +} + +func TestReleaseContractCIReadsStableBaselineFromAnnotatedTag(t *testing.T) { + r := newReleaseTestRepo(t) + r.seedBeta(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644) + r.commitAndPush(t, "prepare stable changelog") + mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1", "-m", "Channel: stable", "-m", "From-Beta: v1.0.1-beta.1") + mustRun(t, r.root, "git", "push", "origin", "v1.0.1") + mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1") + + metadata := filepath.Join(t.TempDir(), "metadata") + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "stable", + "--version", "v1.0.1", + "--context", "ci", + "--remote", "origin", + "--metadata-output", metadata, + ) + if err != nil { + t.Fatalf("CI stable contract error = %v\noutput:\n%s", err, output) + } + metadataData, err := os.ReadFile(metadata) + if err != nil { + t.Fatalf("ReadFile(metadata) error = %v", err) + } + if !strings.Contains(string(metadataData), "from_beta=v1.0.1-beta.1") { + t.Fatalf("metadata missing annotated tag baseline:\n%s", metadataData) + } +} + +func TestReleaseContractCIRejectsLightweightTag(t *testing.T) { + r := newReleaseTestRepo(t) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "prepare beta") + mustRun(t, r.root, "git", "tag", "v1.0.1-beta.1") + mustRun(t, r.root, "git", "push", "origin", "v1.0.1-beta.1") + + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--context", "ci", + "--remote", "origin", + ) + if err == nil { + t.Fatalf("lightweight release tag unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, "must be annotated") { + t.Fatalf("output missing annotated-tag guidance:\n%s", output) + } +} + +func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) { + r := newReleaseTestRepo(t) + r.seedBeta(t) + mustWriteFile(t, filepath.Join(r.root, "after-seal.txt"), []byte("main advanced\n"), 0o644) + r.commitAndPush(t, "advance main after beta seal") + mustRun(t, r.root, "git", "checkout", "--detach", "v1.0.1-beta.1") + + output, err := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--context", "ci", + "--remote", "origin", + ) + if err != nil { + t.Fatalf("sealed tag should remain valid after main advances: %v\noutput:\n%s", err, output) + } +} + +func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) { + r := newReleaseTestRepo(t) + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755) + r.commitAndPush(t, "install changelog preparation") + + cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1") + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") { + t.Fatalf("prepared changelog missing guarded template:\n%s", changelog) + } + + r.commitAndPush(t, "commit unfinished release notes") + contractOutput, contractErr := runReleaseScript(t, r.root, r.contract, + "--repo-root", r.root, + "--channel", "prerelease", + "--version", "v1.0.1-beta.1", + "--remote", "origin", + ) + if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") { + t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput) + } +} + +func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh") + + for _, test := range []struct { + name string + version string + channel string + want string + doNotWant string + installer bool + }{ + {name: "prerelease", version: "v1.2.3-beta.1", channel: "prerelease", want: "/beta.txt", doNotWant: "/latest.txt", installer: false}, + {name: "stable", version: "v1.2.3", channel: "stable", want: "/latest.txt", doNotWant: "/beta.txt", installer: true}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + dist := filepath.Join(root, "dist") + seedVersionedReleaseArtifacts(t, dist, test.version) + logPath := filepath.Join(root, "ossutil.log") + fakeOSSUtil := filepath.Join(root, "ossutil") + mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) echo 'ErrorCode=NoSuchKey' >&2; exit 1 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755) + + cmd := exec.Command("bash", script) + cmd.Dir = sourceRoot + cmd.Env = append(os.Environ(), + "DIST_DIR="+dist, + "VERSION="+test.version, + "DWS_RELEASE_CHANNEL="+test.channel, + "OSS_ACCESS_KEY_ID=test-key", + "OSS_ACCESS_KEY_SECRET=test-secret", + "OSS_ENDPOINT=https://oss.example.com", + "OSS_BUCKET=test-bucket", + "OSS_PREFIX=dws", + "OSSUTIL="+fakeOSSUtil, + "OSSUTIL_LOG="+logPath, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sync-to-oss error = %v\noutput:\n%s", err, output) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile(ossutil log) error = %v", err) + } + if !strings.Contains(string(logData), test.want) { + t.Fatalf("mirror log missing %q:\n%s", test.want, logData) + } + if strings.Contains(string(logData), test.doNotWant) { + t.Fatalf("mirror log unexpectedly contains %q:\n%s", test.doNotWant, logData) + } + hasInstaller := strings.Contains(string(logData), "/dws/install.sh") + if hasInstaller != test.installer { + t.Fatalf("installer upload = %v, want %v:\n%s", hasInstaller, test.installer, logData) + } + }) + } +} + +func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + script := filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh") + + for _, test := range []struct { + name string + readAction string + want string + }{ + {name: "transport error", readAction: "echo 'connection timed out' >&2; exit 7", want: "Could not read OSS beta.txt"}, + {name: "empty pointer", readAction: ": > \"$last\"; exit 0", want: "read successfully but is empty"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + dist := filepath.Join(root, "dist") + seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1") + logPath := filepath.Join(root, "ossutil.log") + fakeOSSUtil := filepath.Join(root, "ossutil") + scriptText := "#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) " + test.readAction + " ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n" + mustWriteFile(t, fakeOSSUtil, []byte(scriptText), 0o755) + + cmd := exec.Command("bash", script) + cmd.Dir = sourceRoot + cmd.Env = append(os.Environ(), + "DIST_DIR="+dist, + "VERSION=v1.2.3-beta.1", + "DWS_RELEASE_CHANNEL=prerelease", + "OSS_ACCESS_KEY_ID=test-key", + "OSS_ACCESS_KEY_SECRET=test-secret", + "OSS_ENDPOINT=https://oss.example.com", + "OSS_BUCKET=test-bucket", + "OSS_PREFIX=dws", + "OSSUTIL="+fakeOSSUtil, + "OSSUTIL_LOG="+logPath, + ) + output, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(output), test.want) { + t.Fatalf("pointer failure was not closed: err=%v, want=%q\noutput:\n%s", err, test.want, output) + } + if logData, readErr := os.ReadFile(logPath); readErr == nil && strings.Contains(string(logData), "/beta.txt") { + t.Fatalf("failed pointer read still published beta pointer:\n%s", logData) + } + }) + } +} + +func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + root := t.TempDir() + dist := filepath.Join(root, "dist") + seedVersionedReleaseArtifacts(t, dist, "v1.2.3-beta.1") + logPath := filepath.Join(root, "ossutil.log") + fakeOSSUtil := filepath.Join(root, "ossutil") + mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprevious=\npenultimate=\nfor arg in \"$@\"; do penultimate=\"$previous\"; previous=\"$arg\"; done\nlast=\"$previous\"\ncase \"$penultimate\" in oss://*) printf 'v1.2.4-beta.1\\n' > \"$last\"; exit 0 ;; esac\nprintf '%s\\n' \"$last\" >> \"$OSSUTIL_LOG\"\n"), 0o755) + cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", "sync-to-oss.sh")) + cmd.Dir = sourceRoot + cmd.Env = append(os.Environ(), + "DIST_DIR="+dist, + "VERSION=v1.2.3-beta.1", + "DWS_RELEASE_CHANNEL=prerelease", + "OSS_ACCESS_KEY_ID=test-key", + "OSS_ACCESS_KEY_SECRET=test-secret", + "OSS_ENDPOINT=https://oss.example.com", + "OSS_BUCKET=test-bucket", + "OSS_PREFIX=dws", + "OSSUTIL="+fakeOSSUtil, + "OSSUTIL_LOG="+logPath, + ) + output, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(output), "assets will be repaired without moving it") { + t.Fatalf("historical OSS repair failed: err=%v\noutput:\n%s", err, output) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile(ossutil log) error = %v", err) + } + if strings.Contains(string(logData), "/beta.txt") { + t.Fatalf("historical repair moved beta pointer:\n%s", logData) + } + if !strings.Contains(string(logData), "/download/v1.2.3-beta.1/") { + t.Fatalf("historical repair did not upload target assets:\n%s", logData) + } +} + +func TestReleaseRequiredMirrorsFailWithoutCredentials(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + for _, test := range []struct { + name string + script string + require string + want string + }{ + {name: "oss", script: "sync-to-oss.sh", require: "DWS_REQUIRE_OSS=1", want: "OSS mirror sync is required"}, + {name: "gitee", script: "sync-to-gitee.sh", require: "DWS_REQUIRE_GITEE=1", want: "Gitee mirror sync is enabled"}, + } { + t.Run(test.name, func(t *testing.T) { + cmd := exec.Command("bash", filepath.Join(sourceRoot, "scripts", "release", test.script)) + cmd.Dir = sourceRoot + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), test.require} + output, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(output), test.want) { + t.Fatalf("required mirror did not fail closed: err=%v, want=%q\noutput:\n%s", err, test.want, output) + } + }) + } +} + +func TestReleaseArtifactVerificationRequiresEveryChecksum(t *testing.T) { + r := newReleaseTestRepo(t) + dist := t.TempDir() + seedVersionedReleaseArtifacts(t, dist, "v1.2.3") + cmd := exec.Command("sh", r.verify, "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("artifact verification error = %v\noutput:\n%s", err, output) + } + + writeReleaseChecksums(t, dist, false) + cmd = exec.Command("sh", r.verify, "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) + output, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(output), "dws-skills.zip exactly once") { + t.Fatalf("missing checksum was not blocked: err=%v\noutput:\n%s", err, output) + } + + writeReleaseChecksums(t, dist, true) + mustWriteFile(t, filepath.Join(dist, "dws-linux-riscv64.tar.gz"), []byte("unexpected\n"), 0o644) + cmd = exec.Command("sh", r.verify, "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) + if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "public release assets") { + t.Fatalf("extra public archive was not rejected: err=%v\noutput:\n%s", err, output) + } + if err := os.Remove(filepath.Join(dist, "dws-linux-riscv64.tar.gz")); err != nil { + t.Fatalf("Remove(extra archive) error = %v", err) + } + + writeVersionedReleaseArchive(t, dist, "dws-windows-arm64.zip", "v1.2.2") + writeReleaseChecksums(t, dist, true) + cmd = exec.Command("sh", r.verify, "v1.2.3") + cmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+dist) + if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "dws-windows-arm64.zip binary") { + t.Fatalf("mixed-version archive was not rejected: err=%v\noutput:\n%s", err, output) + } +} + +func TestReleaseCommandValidatesThenPushesAnnotatedTag(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "install release automation") + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "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, "No tag was created") { + t.Fatalf("validation output missing dry-run result:\n%s", output) + } + if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" { + t.Fatal("validation-only release created a tag") + } + + output, err = runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes", + ) + if err != nil { + t.Fatalf("release publish error = %v\noutput:\n%s", err, output) + } + if got := strings.TrimSpace(mustOutput(t, r.root, "git", "cat-file", "-t", "v1.0.1-beta.1")); got != "tag" { + t.Fatalf("release tag type = %q, want annotated tag", got) + } + if got := mustOutput(t, r.root, "git", "ls-remote", "--tags", "origin", "refs/tags/v1.0.1-beta.1"); !strings.Contains(got, "refs/tags/v1.0.1-beta.1") { + t.Fatalf("remote release tag missing:\n%s", got) + } +} + +func TestReleaseCommandCleansLocalTagWhenPushFails(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "install release automation") + + 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) + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.1-beta.1", "--remote", "origin", "--publish", "--yes", + ) + if err == nil { + t.Fatalf("rejected tag push unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, "new local tag was removed") { + t.Fatalf("push failure output missing cleanup result:\n%s", output) + } + if mustOutput(t, r.root, "git", "tag", "--list", "v1.0.1-beta.1") != "" { + t.Fatal("failed push left the local release tag behind") + } +} + +func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644) + r.commitAndPush(t, "install release automation") + otherRemote := filepath.Join(t.TempDir(), "other.git") + mustRun(t, r.root, "git", "init", "--bare", otherRemote) + mustRun(t, r.root, "git", "remote", "set-url", "--push", "origin", otherRemote) + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.1-beta.1", "--remote", "origin", + ) + if err == nil || !strings.Contains(output, "fetch and push URLs target different repositories") { + t.Fatalf("split release authority was not rejected: err=%v\noutput:\n%s", err, output) + } +} + +func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) { + r := newReleaseTestRepo(t) + installReleaseCommandFixture(t, r) + 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) + r.commitAndPush(t, "install advancing release fixture") + + output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"), + "prerelease", "v1.0.2-beta.1", "--remote", "origin", + ) + if err != nil { + t.Fatalf("release validation error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") || + !strings.Contains(output, "--stable-ref v1.0.1") { + t.Fatalf("advanced stable command tree was not rechecked:\n%s", output) + } +} + +func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) { + t.Helper() + for _, source := range []string{r.lib, r.contract, r.releaseCmd} { + releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755) + } + 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) +} + +func releaseCopyFile(t *testing.T, source, target string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(source) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", source, err) + } + mustWriteFile(t, target, data, mode) +}