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 71a1263e..9c509570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,22 @@ jobs: # version: v1.64.8 # args: ./... + actionlint: + name: Actionlint + 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: Check GitHub Actions workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + test: name: Test runs-on: ubuntu-latest @@ -412,6 +428,7 @@ jobs: name: CI Gate needs: - lint + - actionlint - test - test-darwin - test-windows @@ -431,6 +448,7 @@ jobs: - name: Verify required checks env: LINT_RESULT: ${{ needs.lint.result }} + ACTIONLINT_RESULT: ${{ needs.actionlint.result }} TEST_RESULT: ${{ needs.test.result }} TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }} TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }} @@ -446,6 +464,7 @@ jobs: failed=0 for check in \ "Lint:$LINT_RESULT" \ + "Actionlint:$ACTIONLINT_RESULT" \ "Test:$TEST_RESULT" \ "Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \ "Test (Windows):$TEST_WINDOWS_RESULT" \ 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..d059e927 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,30 +7,50 @@ 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: 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: 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 }} run: | @@ -39,11 +59,197 @@ jobs: exit 1 fi + - 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,18 +300,20 @@ 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 }} + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} - name: Post-release packaging run: ./scripts/release/post-goreleaser.sh env: DWS_PACKAGE_VERSION: ${{ github.ref_name }} + DWS_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }} DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }} - name: Remove Apple Developer ID certificate @@ -114,14 +322,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 +342,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,18 +370,45 @@ 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 + 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: 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: Restore finalized distribution files uses: actions/download-artifact@v4 @@ -184,15 +416,128 @@ jobs: name: finalized-release-dist path: dist - - name: Publish verified Draft release + - 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: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false + 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: 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, '-') }} + if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }} run: ./scripts/release/publish-homebrew-formula.sh env: DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git @@ -203,9 +548,7 @@ jobs: 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, '-') }} + if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }} run: ./scripts/release/publish-homebrew-formula.sh env: DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb @@ -217,17 +560,39 @@ jobs: 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: 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 @@ -235,34 +600,189 @@ 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: 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" + echo "advance_channel=$advance_channel" + echo "npm_tag=$npm_tag" + 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" + echo "advance_channel=false" + echo "npm_tag=$npm_tag" + 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 - # pre-sync work has an independently provable budget. + name: Mirror immutable release to Gitee if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }} - needs: - - release - - publish-release + needs: [release, publish-channels] runs-on: ubuntu-latest timeout-minutes: 120 + permissions: + contents: read steps: - - name: Check out repository + - name: Check out sealed release source uses: actions/checkout@v4 timeout-minutes: 5 @@ -281,45 +801,152 @@ jobs: 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 +954,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 be6123f1..41ea7cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance. - **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier. +### 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, Homebrew, and optional Gitee delivery with fail-closed recovery checks. + ### Fixed - **PAT organization-policy denials stop immediately** — `PAT_ORG_POLICY_DENIED` now remains terminal even if a backend also returns `flowId`, authorization URLs, or client credentials; the CLI does not mutate process credentials, open a browser, poll, or retry until an organization administrator changes the policy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7251571a..de7eff27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ git diff --check 1. Keep implementation and tests in sync. 2. Run `./scripts/dev/ci-local.sh`. -3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. +3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref ` against both the target branch and latest stable release. 4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change. 5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first). 6. Update docs and `CHANGELOG.md` for behavior/interface changes. diff --git a/Makefile b/Makefile index 17e2608c..db5b9810 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ GO ?= go +REMOTE ?= +PUBLISH ?= 0 +YES ?= 0 DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" -.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 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 release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -28,8 +31,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 +135,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 +144,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/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go new file mode 100644 index 00000000..d921bff9 --- /dev/null +++ b/cmd/interface-snapshot/main.go @@ -0,0 +1,195 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// interface-snapshot is an internal CI helper. It is intentionally a separate +// binary so it can be copied into a temporary worktree and compiled against an +// older revision's real Cobra root. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 2 + } + + switch args[0] { + case "generate": + if err := runGenerate(args[1:], stdout, stderr); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + return 0 + case "compare": + compatible, err := runCompare(args[1:], stdout, stderr) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + if !compatible { + return 1 + } + return 0 + default: + fmt.Fprintf(stderr, "unknown command %q\n", args[0]) + printUsage(stderr) + return 2 + } +} + +func runGenerate(args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("generate", flag.ContinueOnError) + flags.SetOutput(stderr) + output := flags.String("output", "-", "snapshot output path, or - for stdout") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("generate accepts no positional arguments") + } + + home, err := os.MkdirTemp("", "dws-interface-snapshot-*") + if err != nil { + return fmt.Errorf("create isolated home: %w", err) + } + defer os.RemoveAll(home) + + environment := map[string]string{ + "DWS_CONFIG_DIR": home, + "DWS_LANG": "en", + "HOME": home, + "NO_COLOR": "1", + "USERPROFILE": home, + } + type previousEnv struct { + value string + set bool + } + previous := make(map[string]previousEnv, len(environment)) + for key, value := range environment { + oldValue, wasSet := os.LookupEnv(key) + previous[key] = previousEnv{value: oldValue, set: wasSet} + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("set %s: %w", key, err) + } + } + defer func() { + for key, old := range previous { + if old.set { + _ = os.Setenv(key, old.value) + } else { + _ = os.Unsetenv(key) + } + } + }() + previousLang := i18n.Lang() + defer i18n.SetLang(previousLang) + i18n.SetLang("en") + + snapshot := interfacesnapshot.Capture(app.NewRootCommand()) + if *output == "-" { + return interfacesnapshot.Write(stdout, snapshot) + } + + file, err := os.Create(filepath.Clean(*output)) + if err != nil { + return fmt.Errorf("create snapshot %q: %w", *output, err) + } + writeErr := interfacesnapshot.Write(file, snapshot) + closeErr := file.Close() + if writeErr != nil { + return fmt.Errorf("write snapshot %q: %w", *output, writeErr) + } + if closeErr != nil { + return fmt.Errorf("close snapshot %q: %w", *output, closeErr) + } + return nil +} + +func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { + flags := flag.NewFlagSet("compare", flag.ContinueOnError) + flags.SetOutput(stderr) + currentPath := flags.String("current", "", "candidate snapshot path") + basePath := flags.String("base", "", "target main/development baseline snapshot path") + stablePath := flags.String("stable", "", "latest stable GA snapshot path") + if err := flags.Parse(args); err != nil { + return false, err + } + if flags.NArg() != 0 { + return false, fmt.Errorf("compare accepts no positional arguments") + } + if *currentPath == "" { + return false, fmt.Errorf("compare requires --current") + } + if *basePath == "" && *stablePath == "" { + return false, fmt.Errorf("compare requires --base, --stable, or both") + } + + current, err := readSnapshot(*currentPath) + if err != nil { + return false, fmt.Errorf("read current snapshot: %w", err) + } + references := make(map[string]interfacesnapshot.Snapshot, 2) + if *basePath != "" { + references["main"], err = readSnapshot(*basePath) + if err != nil { + return false, fmt.Errorf("read main/development baseline snapshot: %w", err) + } + } + if *stablePath != "" { + references["stable"], err = readSnapshot(*stablePath) + if err != nil { + return false, fmt.Errorf("read stable snapshot: %w", err) + } + } + + report := interfacesnapshot.CompareAll(current, references) + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return false, fmt.Errorf("write comparison report: %w", err) + } + return report.Compatible, nil +} + +func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.Snapshot{}, err + } + defer file.Close() + return interfacesnapshot.Read(file) +} + +func printUsage(w io.Writer) { + fmt.Fprintln(w, "usage:") + fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]") +} diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go new file mode 100644 index 00000000..1f6de835 --- /dev/null +++ b/cmd/interface-snapshot/main_test.go @@ -0,0 +1,130 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) { + var stdout, stderr bytes.Buffer + if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 { + t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String()) + } + snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes())) + if err != nil { + t.Fatalf("decode generated snapshot: %v", err) + } + + commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands)) + for _, command := range snapshot.Commands { + commands[command.Path] = command + } + for _, path := range []string{"dws", "dws chat", "dws dev app create"} { + if _, ok := commands[path]; !ok { + t.Errorf("actual root snapshot is missing %q", path) + } + } + for _, path := range []string{"dws completion", "dws help"} { + if _, ok := commands[path]; ok { + t.Errorf("framework-noise path %q leaked into snapshot", path) + } + } + + create := commands["dws dev app create"] + if !hasFlag(create.LocalFlags, "name", "string") { + t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags) + } + if !hasFlag(create.InheritedFlags, "profile", "string") { + t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags) + } +} + +func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) { + current := commandSnapshot("dws") + mergeBase := commandSnapshot("dws") + stable := commandSnapshot("dws", "dws legacy") + + dir := t.TempDir() + currentPath := writeSnapshot(t, dir, "current.json", current) + mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase) + stablePath := writeSnapshot(t, dir, "stable.json", stable) + + var stdout, stderr bytes.Buffer + exitCode := run([]string{ + "compare", + "--current", currentPath, + "--base", mergeBasePath, + "--stable", stablePath, + }, &stdout, &stderr) + if exitCode != 1 { + t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) || + !bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) || + !bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) { + t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String()) + } +} + +func commandSnapshot(paths ...string) interfacesnapshot.Snapshot { + commands := make([]interfacesnapshot.Command, 0, len(paths)) + for _, path := range paths { + commands = append(commands, interfacesnapshot.Command{ + Path: path, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + }) + } + return interfacesnapshot.Snapshot{ + SchemaVersion: interfacesnapshot.SchemaVersion, + Rules: interfacesnapshot.Rules{ + ExcludedCommandSubtrees: []string{}, + ExcludedFlags: []string{}, + }, + Commands: commands, + } +} + +func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string { + t.Helper() + path := filepath.Join(dir, name) + file, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + if err := interfacesnapshot.Write(file, snapshot); err != nil { + file.Close() + t.Fatalf("write %s: %v", path, err) + } + if err := file.Close(); err != nil { + t.Fatalf("close %s: %v", path, err) + } + return path +} + +func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { + for _, flag := range flags { + if flag.Name == name && flag.Type == flagType { + return true + } + } + return false +} 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/internal/app/root_help_test.go b/internal/app/root_help_test.go index 02d8e487..d83f31be 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -47,6 +47,25 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) { } } +func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) { + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"calendar", "event", "create", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("calendar event create --help: %v\n%s", err, out.String()) + } + + help := out.String() + if !strings.Contains(help, "--rooms string") { + t.Fatalf("calendar event create help missing string metavar for --rooms:\n%s", help) + } + if strings.Contains(help, "--rooms room search") { + t.Fatalf("calendar event create help treated description text as --rooms metavar:\n%s", help) + } +} + func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { root := NewRootCommand() listDirect := mustFindCommand(t, root, "chat", "message", "list-direct") diff --git a/internal/cli/schema_catalog.json b/internal/cli/schema_catalog.json index c6fed70d..f805cfe3 100644 --- a/internal/cli/schema_catalog.json +++ b/internal/cli/schema_catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:ad2173126ac607dc824408d96906d1d183464e052f2d367158cc067bfbefcf83", + "source_hash": "sha256:c3bc35f60d36197aa1df7126513a85b5d4217fd0db3f32ece445d9a1ef76e870", "surface_hash": "sha256:631b73b174f48cb0cca7c8449e80920739010d0db223dac2855101f4dbd0eda6", "catalog": { "agent_metadata": { @@ -141587,7 +141587,7 @@ "type": "string" }, "rooms": { - "description": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)", + "description": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自“room search”返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)", "field_provenance": { "description": { "candidates": [ @@ -141595,7 +141595,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)" + "value": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自“room search”返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)" }, { "precedence": "mcp_metadata", @@ -141613,7 +141613,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)" + "value": "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自“room search”返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)" }, "interface_type": { "candidates": [ diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index e9af5ff0..e0b3c975 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -1173,7 +1173,7 @@ func newCalendarCommand() *cobra.Command { eventCreateCmd.Flags().String("members", "", "") _ = eventCreateCmd.Flags().MarkHidden("members") eventCreateCmd.Flags().String("open-dingtalk-ids", "", "openDingTalkId 列表,逗号分隔 (与 --attendees 至少传一个)") - eventCreateCmd.Flags().String("rooms", "", "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)") + eventCreateCmd.Flags().String("rooms", "", "会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自“room search”返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)") eventCreateCmd.Flags().String("room-ids", "", "") _ = eventCreateCmd.Flags().MarkHidden("room-ids") eventCreateCmd.Flags().String("roomIds", "", "") diff --git a/internal/helpers/calendar_help_test.go b/internal/helpers/calendar_help_test.go new file mode 100644 index 00000000..07a1a045 --- /dev/null +++ b/internal/helpers/calendar_help_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "strings" + "testing" +) + +func TestCrossPlatformCoverageCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) { + cmd := newCalendarCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"event", "create", "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("calendar event create --help: %v\n%s", err, out.String()) + } + + help := out.String() + if !strings.Contains(help, "--rooms string") { + t.Fatalf("calendar event create help missing string metavar for --rooms:\n%s", help) + } + if strings.Contains(help, "--rooms room search") { + t.Fatalf("calendar event create help treated description text as --rooms metavar:\n%s", help) + } +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go new file mode 100644 index 00000000..79fcd2d3 --- /dev/null +++ b/internal/interfacesnapshot/compare.go @@ -0,0 +1,441 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package interfacesnapshot + +import ( + "fmt" + "reflect" + "sort" + "strings" +) + +// Report combines comparisons against independently supplied compatibility +// references, normally the PR merge-base and the latest stable GA release. +type Report struct { + Compatible bool `json:"compatible"` + Comparisons []Comparison `json:"comparisons"` +} + +// Comparison is the result for one reference snapshot. +type Comparison struct { + Reference string `json:"reference"` + Compatible bool `json:"compatible"` + Blocking []Change `json:"blocking"` + Additions []Change `json:"additions"` +} + +// Change describes one compatibility decision. Before and After are concise, +// human-readable values intended for CI annotations. +type Change struct { + Kind string `json:"kind"` + Path string `json:"path"` + Flag string `json:"flag,omitempty"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` +} + +// CompareAll compares current against every named reference in one pass. +// Additions are reported but remain compatible. +func CompareAll(current Snapshot, references map[string]Snapshot) Report { + labels := make([]string, 0, len(references)) + for label := range references { + labels = append(labels, label) + } + sort.Strings(labels) + + report := Report{Compatible: true, Comparisons: []Comparison{}} + for _, label := range labels { + comparison := Compare(current, references[label], label) + report.Comparisons = append(report.Comparisons, comparison) + if !comparison.Compatible { + report.Compatible = false + } + } + return report +} + +// Compare enforces the deliberately small admission policy: +// - every previously accepted command path must still resolve to a runnable, +// visible-compatible target (a rename may preserve the old path as an +// alias), +// - flags accepted at each command path may not disappear, change type, or +// become required; an existing path may not gain a new required flag, +// - new commands and flags are allowed. +// +// Comparing the effective local + inherited set is intentional. It catches a +// persistent flag whose scope is accidentally narrowed to its declaring +// command, while allowing a local flag to move to an ancestor without breaking +// the old invocation path. +func Compare(current, baseline Snapshot, reference string) Comparison { + result := Comparison{ + Reference: reference, + Compatible: true, + Blocking: []Change{}, + Additions: []Change{}, + } + + if !reflect.DeepEqual(current.Rules, baseline.Rules) { + result.Blocking = append(result.Blocking, Change{ + Kind: "snapshot_rules_changed", + Path: "dws", + Before: fmt.Sprintf("%v", baseline.Rules), + After: fmt.Sprintf("%v", current.Rules), + }) + } + result.Blocking = append(result.Blocking, aliasCollisionChanges(current)...) + + currentCommands := commandIndex(current) + baselineCommands := commandIndex(baseline) + currentAccepted := acceptedPathIndex(current) + baselineAccepted := acceptedPathIndex(baseline) + + removedPaths := make([]string, 0) + for path := range baselineAccepted { + if _, ok := currentAccepted[path]; !ok { + removedPaths = append(removedPaths, path) + } + } + for _, path := range minimalPaths(removedPaths) { + kind := "command_alias_removed" + if _, canonical := baselineCommands[path]; canonical { + kind = "command_removed" + } + result.Blocking = append(result.Blocking, Change{Kind: kind, Path: path}) + } + + mappedCurrent := make(map[string]bool) + for _, acceptedPath := range sortedAcceptedPaths(baselineAccepted) { + oldPath := baselineAccepted[acceptedPath] + oldCommand := baselineCommands[oldPath] + newPath, ok := currentAccepted[acceptedPath] + if !ok { + continue + } + newCommand, ok := currentCommands[newPath] + if !ok { + continue + } + mappedCurrent[newPath] = true + compareCommandContract(&result, acceptedPath, oldCommand, newCommand) + compareEffectiveFlags(&result, acceptedPath, oldCommand, newCommand) + } + + for _, path := range sortedCommandPaths(current.Commands) { + if mappedCurrent[path] { + continue + } + if _, existed := baselineCommands[path]; !existed { + result.Additions = append(result.Additions, Change{Kind: "command_added", Path: path}) + } + } + + sortChanges(result.Blocking) + sortChanges(result.Additions) + result.Blocking = dedupeChanges(result.Blocking) + result.Additions = dedupeChanges(result.Additions) + result.Compatible = len(result.Blocking) == 0 + return result +} + +func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + if oldCommand.Runnable && !newCommand.Runnable { + result.Blocking = append(result.Blocking, Change{ + Kind: "command_became_non_runnable", + Path: acceptedPath, + Before: "runnable", + After: "non-runnable", + }) + } + if !oldCommand.Hidden && newCommand.Hidden { + result.Blocking = append(result.Blocking, Change{ + Kind: "command_became_hidden", + Path: acceptedPath, + Before: "visible", + After: "hidden", + }) + } +} + +func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + oldFlags := effectiveFlagIndex(oldCommand) + newFlags := effectiveFlagIndex(newCommand) + + for _, name := range sortedFlagNames(oldFlags) { + oldFlag := oldFlags[name] + newFlag, ok := newFlags[name] + if !ok { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_removed", + Path: acceptedPath, + Flag: name, + }) + continue + } + if oldFlag.Type != newFlag.Type { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_type_changed", + Path: acceptedPath, + Flag: name, + Before: oldFlag.Type, + After: newFlag.Type, + }) + } + if !oldFlag.Required && newFlag.Required { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_became_required", + Path: acceptedPath, + Flag: name, + Before: "optional", + After: "required", + }) + } + if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_shorthand_changed", + Path: acceptedPath, + Flag: name, + Before: oldFlag.Shorthand, + After: newFlag.Shorthand, + }) + } + if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_no_opt_changed", + Path: acceptedPath, + Flag: name, + Before: oldFlag.NoOpt, + After: newFlag.NoOpt, + }) + } + if !oldFlag.Hidden && newFlag.Hidden { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_became_hidden", + Path: acceptedPath, + Flag: name, + Before: "visible", + After: "hidden", + }) + } + } + + for _, name := range sortedFlagNames(newFlags) { + newFlag := newFlags[name] + if _, existed := oldFlags[name]; existed { + continue + } + if newFlag.Required { + result.Blocking = append(result.Blocking, Change{ + Kind: "required_flag_added", + Path: acceptedPath, + Flag: name, + Before: "absent", + After: "required", + }) + } + } + + // Report optional additions once where they are declared. Required + // additions were handled against the effective set above because they can + // break every descendant invocation. + newLocalFlags := flagIndex(newCommand.LocalFlags) + for _, name := range sortedFlagNames(newLocalFlags) { + newFlag := newLocalFlags[name] + if _, existed := oldFlags[name]; existed || newFlag.Required { + continue + } + result.Additions = append(result.Additions, Change{Kind: "flag_added", Path: newCommand.Path, Flag: name}) + } +} + +func commandIndex(snapshot Snapshot) map[string]Command { + out := make(map[string]Command, len(snapshot.Commands)) + for _, command := range snapshot.Commands { + out[command.Path] = command + } + return out +} + +func aliasCollisionChanges(snapshot Snapshot) []Change { + acceptedSiblings := make(map[string]string) + changes := []Change{} + for _, command := range snapshot.Commands { + parent, name := splitParent(command.Path) + for _, acceptedName := range append([]string{name}, command.Aliases...) { + acceptedName = strings.TrimSpace(acceptedName) + if acceptedName == "" { + continue + } + key := parent + "\x00" + acceptedName + if previous, exists := acceptedSiblings[key]; exists && previous != command.Path { + changes = append(changes, Change{ + Kind: "command_alias_collision", + Path: strings.TrimSpace(parent + " " + acceptedName), + Before: previous, + After: command.Path, + }) + continue + } + acceptedSiblings[key] = command.Path + } + } + return changes +} + +// acceptedPathIndex expands aliases at every path segment. For example, if +// "dws chat" has alias "im", then "dws im message send" remains accepted for +// every descendant even though descendants only store their canonical paths. +func acceptedPathIndex(snapshot Snapshot) map[string]string { + commands := append([]Command(nil), snapshot.Commands...) + sort.Slice(commands, func(i, j int) bool { + leftDepth, rightDepth := pathDepth(commands[i].Path), pathDepth(commands[j].Path) + if leftDepth != rightDepth { + return leftDepth < rightDepth + } + return commands[i].Path < commands[j].Path + }) + + variants := make(map[string][]string, len(commands)) + accepted := make(map[string]string) + // Canonical paths always win over an alias collision. + for _, command := range commands { + accepted[command.Path] = command.Path + } + + for _, command := range commands { + parent, name := splitParent(command.Path) + names := compactSorted(append([]string{name}, command.Aliases...)) + parentVariants := variants[parent] + if parent == "" { + parentVariants = []string{""} + } else if len(parentVariants) == 0 { + parentVariants = []string{parent} + } + + commandVariants := make([]string, 0, len(parentVariants)*len(names)) + for _, parentVariant := range parentVariants { + for _, candidateName := range names { + path := strings.TrimSpace(parentVariant + " " + candidateName) + commandVariants = append(commandVariants, path) + if _, canonical := accepted[path]; !canonical { + accepted[path] = command.Path + } + } + } + variants[command.Path] = compactSorted(commandVariants) + } + return accepted +} + +func flagIndex(flags []Flag) map[string]Flag { + out := make(map[string]Flag, len(flags)) + for _, flag := range flags { + out[flag.Name] = flag + } + return out +} + +func effectiveFlagIndex(command Command) map[string]Flag { + out := flagIndex(command.InheritedFlags) + for _, flag := range command.LocalFlags { + // A local flag is the callable definition when it shadows an inherited + // flag with the same name. + out[flag.Name] = flag + } + return out +} + +func sortedCommandPaths(commands []Command) []string { + paths := make([]string, 0, len(commands)) + for _, command := range commands { + paths = append(paths, command.Path) + } + sort.Strings(paths) + return paths +} + +func sortedAcceptedPaths(accepted map[string]string) []string { + paths := make([]string, 0, len(accepted)) + for path := range accepted { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func sortedFlagNames(flags map[string]Flag) []string { + names := make([]string, 0, len(flags)) + for name := range flags { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func minimalPaths(paths []string) []string { + sort.Slice(paths, func(i, j int) bool { + leftDepth, rightDepth := pathDepth(paths[i]), pathDepth(paths[j]) + if leftDepth != rightDepth { + return leftDepth < rightDepth + } + return paths[i] < paths[j] + }) + minimal := make([]string, 0, len(paths)) + for _, path := range paths { + covered := false + for _, parent := range minimal { + if strings.HasPrefix(path, parent+" ") { + covered = true + break + } + } + if !covered { + minimal = append(minimal, path) + } + } + return minimal +} + +func splitParent(path string) (string, string) { + index := strings.LastIndexByte(path, ' ') + if index < 0 { + return "", path + } + return path[:index], path[index+1:] +} + +func pathDepth(path string) int { + return len(strings.Fields(path)) +} + +func sortChanges(changes []Change) { + sort.Slice(changes, func(i, j int) bool { + left := changes[i].Path + "\x00" + changes[i].Flag + "\x00" + changes[i].Kind + right := changes[j].Path + "\x00" + changes[j].Flag + "\x00" + changes[j].Kind + return left < right + }) +} + +func dedupeChanges(changes []Change) []Change { + if len(changes) < 2 { + return changes + } + out := changes[:1] + for _, change := range changes[1:] { + if change == out[len(out)-1] { + continue + } + out = append(out, change) + } + return out +} diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go new file mode 100644 index 00000000..5e5b0ba0 --- /dev/null +++ b/internal/interfacesnapshot/compare_test.go @@ -0,0 +1,367 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package interfacesnapshot + +import "testing" + +func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + + tests := []struct { + name string + current Snapshot + compatible bool + kind string + }{ + { + name: "command addition allowed", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + testCommand("dws status"), + ), + compatible: true, + }, + { + name: "flag addition allowed", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", + testFlag("limit", "int", false), + testFlag("query", "string", false), + ), + ), + compatible: true, + }, + { + name: "required flag addition blocked on existing command", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", + testFlag("query", "string", false), + testFlag("tenant", "string", true), + ), + ), + kind: "required_flag_added", + }, + { + name: "command deletion blocked", + current: testSnapshot( + testCommand("dws"), + ), + kind: "command_removed", + }, + { + name: "rename without alias blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws find", testFlag("query", "string", false)), + ), + kind: "command_removed", + }, + { + name: "flag deletion blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search"), + ), + kind: "flag_removed", + }, + { + name: "optional becoming required blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", true)), + ), + kind: "flag_became_required", + }, + { + name: "flag type change blocked", + current: testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "stringSlice", false)), + ), + kind: "flag_type_changed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + comparison := Compare(test.current, base, "base") + if comparison.Compatible != test.compatible { + t.Fatalf("Compatible = %v, want %v; blocking=%#v", comparison.Compatible, test.compatible, comparison.Blocking) + } + if test.kind == "" { + return + } + if !hasChangeKind(comparison.Blocking, test.kind) { + t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind) + } + }) + } +} + +func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws find", []string{"search"}, testFlag("query", "string", false)), + ) + + comparison := Compare(current, base, "base") + if !comparison.Compatible { + t.Fatalf("rename with compatibility alias was blocked: %#v", comparison.Blocking) + } +} + +func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search"), + ) + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_removed") { + t.Fatalf("removed alias was not blocked: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksAliasRetargetedToIncompatibleCommand(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + testCommand("dws find"), + ) + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws find", "query") { + t.Fatalf("alias retarget was not checked against its old contract: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksSnapshotRuleChanges(t *testing.T) { + base := testSnapshot(testCommand("dws")) + current := testSnapshot(testCommand("dws")) + current.Rules.ExcludedFlags = append(current.Rules.ExcludedFlags, "legacy") + + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "snapshot_rules_changed") { + t.Fatalf("snapshot rule change was not blocked: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testing.T) { + baseFlag := testFlag("format", "string", false) + baseFlag.Shorthand = "f" + baseFlag.NoOpt = "json" + base := testSnapshot( + testCommand("dws"), + testCommand("dws export", baseFlag), + ) + + tests := []struct { + name string + command Command + kind string + }{ + { + name: "command became non-runnable", + command: func() Command { + command := testCommand("dws export", baseFlag) + command.Runnable = false + return command + }(), + kind: "command_became_non_runnable", + }, + { + name: "command became hidden", + command: func() Command { + command := testCommand("dws export", baseFlag) + command.Hidden = true + return command + }(), + kind: "command_became_hidden", + }, + { + name: "flag shorthand removed", + command: func() Command { + flag := baseFlag + flag.Shorthand = "" + return testCommand("dws export", flag) + }(), + kind: "flag_shorthand_changed", + }, + { + name: "flag no-opt behavior removed", + command: func() Command { + flag := baseFlag + flag.NoOpt = "" + return testCommand("dws export", flag) + }(), + kind: "flag_no_opt_changed", + }, + { + name: "flag became hidden", + command: func() Command { + flag := baseFlag + flag.Hidden = true + return testCommand("dws export", flag) + }(), + kind: "flag_became_hidden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot(testCommand("dws"), test.command) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, test.kind) { + t.Fatalf("metadata regression %q was not blocked: %#v", test.kind, comparison) + } + }) + } +} + +func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) { + base := testSnapshot( + testCommand("dws", testFlag("profile", "string", false)), + testCommandWithFlagScopes( + "dws search", + []Flag{testFlag("query", "string", false)}, + []Flag{testFlag("profile", "string", false)}, + ), + ) + + t.Run("persistent flag scope narrowing is blocked", func(t *testing.T) { + current := testSnapshot( + testCommand("dws", testFlag("profile", "string", false)), + testCommand("dws search", testFlag("query", "string", false)), + ) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws search", "profile") { + t.Fatalf("lost inherited flag was not blocked: %#v", comparison) + } + }) + + t.Run("local flag moved to inherited remains compatible", func(t *testing.T) { + current := testSnapshot( + testCommand("dws", + testFlag("profile", "string", false), + testFlag("query", "string", false), + ), + testCommandWithFlagScopes( + "dws search", + nil, + []Flag{ + testFlag("profile", "string", false), + testFlag("query", "string", false), + }, + ), + ) + comparison := Compare(current, base, "base") + if !comparison.Compatible { + t.Fatalf("flag moved to an inherited scope was blocked: %#v", comparison.Blocking) + } + }) +} + +func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.T) { + current := testSnapshot(testCommand("dws"), testCommand("dws status")) + mergeBase := testSnapshot(testCommand("dws")) + stable := testSnapshot(testCommand("dws"), testCommand("dws legacy")) + + report := CompareAll(current, map[string]Snapshot{ + "stable": stable, + "merge-base": mergeBase, + }) + if report.Compatible { + t.Fatal("aggregate report passed even though stable comparison removed a command") + } + if len(report.Comparisons) != 2 || report.Comparisons[0].Reference != "merge-base" || report.Comparisons[1].Reference != "stable" { + t.Fatalf("comparisons are not deterministic: %#v", report.Comparisons) + } + if !report.Comparisons[0].Compatible || report.Comparisons[1].Compatible { + t.Fatalf("unexpected per-reference results: %#v", report.Comparisons) + } +} + +func testSnapshot(commands ...Command) Snapshot { + return Snapshot{ + SchemaVersion: SchemaVersion, + Rules: Rules{ + ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...), + ExcludedFlags: []string{"help"}, + }, + Commands: commands, + } +} + +func testCommand(path string, flags ...Flag) Command { + return testCommandWithAliases(path, nil, flags...) +} + +func testCommandWithAliases(path string, aliases []string, flags ...Flag) Command { + return Command{ + Path: path, + Runnable: true, + Aliases: aliases, + LocalFlags: flags, + InheritedFlags: []Flag{}, + } +} + +func testCommandWithFlagScopes(path string, local, inherited []Flag) Command { + return Command{ + Path: path, + Aliases: []string{}, + LocalFlags: local, + InheritedFlags: inherited, + } +} + +func testFlag(name, flagType string, required bool) Flag { + return Flag{Name: name, Type: flagType, Required: required} +} + +func hasChangeKind(changes []Change, kind string) bool { + for _, change := range changes { + if change.Kind == kind { + return true + } + } + return false +} + +func hasFlagChange(changes []Change, kind, path, flag string) bool { + for _, change := range changes { + if change.Kind == kind && change.Path == path && change.Flag == flag { + return true + } + } + return false +} diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go new file mode 100644 index 00000000..58b26c54 --- /dev/null +++ b/internal/interfacesnapshot/snapshot.go @@ -0,0 +1,270 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package interfacesnapshot captures and compares the public Cobra command +// surface without executing commands or contacting runtime services. +package interfacesnapshot + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const SchemaVersion = 1 + +var ( + // Framework-owned command subtrees are deliberately excluded. They are + // generated by Cobra or shell-completion plumbing rather than DWS product + // code, so changes to them are not command-surface compatibility changes. + excludedCommandSubtrees = []string{ + "dws __complete", + "dws __completeNoDesc", + "dws completion", + "dws help", + } + + // Cobra installs --help lazily on every command. Keeping it would make the + // snapshot depend on whether help happened to be rendered before capture. + excludedFlags = map[string]bool{"help": true} +) + +// Rules records the noise filtering contract in every snapshot. A future rule +// change is therefore visible instead of silently changing comparison scope. +type Rules struct { + ExcludedCommandSubtrees []string `json:"excluded_command_subtrees"` + ExcludedFlags []string `json:"excluded_flags"` +} + +// Snapshot is a deterministic representation of a Cobra command tree. +type Snapshot struct { + SchemaVersion int `json:"schema_version"` + Rules Rules `json:"rules"` + Commands []Command `json:"commands"` +} + +// Command contains compatibility-relevant command metadata. Path always +// includes the root command name (for example, "dws chat message send"). +type Command struct { + Path string `json:"path"` + Runnable bool `json:"runnable"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` + Aliases []string `json:"aliases"` + LocalFlags []Flag `json:"local_flags"` + InheritedFlags []Flag `json:"inherited_flags"` +} + +// Flag contains the stable pflag contract visible at a command node. +type Flag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default"` + NoOpt string `json:"no_opt,omitempty"` + Required bool `json:"required"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` +} + +// Capture walks root without rendering help or executing any command. Both +// hidden compatibility commands and hidden flags are retained unless they are +// covered by the explicit framework-noise rules above. +func Capture(root *cobra.Command) Snapshot { + snapshot := Snapshot{ + SchemaVersion: SchemaVersion, + Rules: Rules{ + ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...), + ExcludedFlags: []string{"help"}, + }, + Commands: []Command{}, + } + if root == nil { + return snapshot + } + + // --version is also installed lazily by Cobra, but unlike --help it is a + // real root CLI contract and should be captured. + root.InitDefaultVersionFlag() + + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + path := normalizePath(cmd.CommandPath()) + if commandExcluded(path) { + return + } + + aliases := append([]string{}, cmd.Aliases...) + for i := range aliases { + aliases[i] = strings.TrimSpace(aliases[i]) + } + aliases = compactSorted(aliases) + + snapshot.Commands = append(snapshot.Commands, Command{ + Path: path, + Runnable: cmd.Runnable(), + Hidden: cmd.Hidden, + Deprecated: strings.TrimSpace(cmd.Deprecated), + Aliases: aliases, + LocalFlags: captureFlags(cmd.LocalFlags()), + InheritedFlags: captureFlags(cmd.InheritedFlags()), + }) + + children := append([]*cobra.Command(nil), cmd.Commands()...) + sort.Slice(children, func(i, j int) bool { + return children[i].Name() < children[j].Name() + }) + for _, child := range children { + walk(child) + } + } + walk(root) + + sort.Slice(snapshot.Commands, func(i, j int) bool { + return snapshot.Commands[i].Path < snapshot.Commands[j].Path + }) + return snapshot +} + +// Write emits canonical, indented JSON with a trailing newline. +func Write(w io.Writer, snapshot Snapshot) error { + if err := snapshot.Validate(); err != nil { + return err + } + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + return encoder.Encode(snapshot) +} + +// Read decodes and validates a snapshot. Unknown fields are rejected so a +// comparison never silently ignores a newer contract it does not understand. +func Read(r io.Reader) (Snapshot, error) { + var snapshot Snapshot + decoder := json.NewDecoder(r) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + return Snapshot{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return Snapshot{}, fmt.Errorf("interface snapshot contains multiple JSON values") + } + return Snapshot{}, fmt.Errorf("read trailing interface snapshot data: %w", err) + } + if err := snapshot.Validate(); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +// Validate checks the invariants needed by the comparison algorithm. +func (s Snapshot) Validate() error { + if s.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported interface snapshot schema_version %d (want %d)", s.SchemaVersion, SchemaVersion) + } + seenCommands := make(map[string]bool, len(s.Commands)) + for _, command := range s.Commands { + if command.Path == "" { + return fmt.Errorf("interface snapshot contains an empty command path") + } + if seenCommands[command.Path] { + return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path) + } + seenCommands[command.Path] = true + if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { + return err + } + if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil { + return err + } + } + return nil +} + +func captureFlags(set *pflag.FlagSet) []Flag { + flags := []Flag{} + if set == nil { + return flags + } + set.VisitAll(func(flag *pflag.Flag) { + if flag == nil || excludedFlags[flag.Name] { + return + } + flags = append(flags, Flag{ + Name: flag.Name, + Shorthand: flag.Shorthand, + Type: flag.Value.Type(), + Default: flag.DefValue, + NoOpt: flag.NoOptDefVal, + Required: isRequired(flag), + Hidden: flag.Hidden, + Deprecated: strings.TrimSpace(flag.Deprecated), + }) + }) + sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) + return flags +} + +func isRequired(flag *pflag.Flag) bool { + for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] { + if value == "true" { + return true + } + } + return false +} + +func commandExcluded(path string) bool { + for _, prefix := range excludedCommandSubtrees { + if path == prefix || strings.HasPrefix(path, prefix+" ") { + return true + } + } + return false +} + +func normalizePath(path string) string { + return strings.Join(strings.Fields(path), " ") +} + +func compactSorted(values []string) []string { + sort.Strings(values) + out := values[:0] + for _, value := range values { + if value == "" || (len(out) > 0 && out[len(out)-1] == value) { + continue + } + out = append(out, value) + } + return out +} + +func validateFlags(path, scope string, flags []Flag) error { + seen := make(map[string]bool, len(flags)) + for _, flag := range flags { + if flag.Name == "" { + return fmt.Errorf("command %q contains an empty %s flag name", path, scope) + } + if seen[flag.Name] { + return fmt.Errorf("command %q contains duplicate %s flag %q", path, scope, flag.Name) + } + seen[flag.Name] = true + } + return nil +} diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go new file mode 100644 index 00000000..1d08dccc --- /dev/null +++ b/internal/interfacesnapshot/snapshot_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package interfacesnapshot + +import ( + "bytes" + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testing.T) { + root := &cobra.Command{Use: "dws", Version: "test"} + root.PersistentFlags().String("profile", "", "profile") + + service := &cobra.Command{ + Use: "service", + Aliases: []string{"svc", "api", "svc"}, + Hidden: true, + Deprecated: "use replacement", + } + leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}} + leaf.Flags().String("name", "", "name") + leaf.Flags().Bool("force", false, "force") + if err := leaf.MarkFlagRequired("name"); err != nil { + t.Fatalf("MarkFlagRequired: %v", err) + } + leaf.InitDefaultHelpFlag() + service.AddCommand(leaf) + + help := &cobra.Command{Use: "help"} + completion := &cobra.Command{Use: "completion"} + completion.AddCommand(&cobra.Command{Use: "zsh"}) + root.AddCommand(service, help, completion) + + snapshot := Capture(root) + wantPaths := []string{"dws", "dws service", "dws service run"} + if got := sortedCommandPaths(snapshot.Commands); !reflect.DeepEqual(got, wantPaths) { + t.Fatalf("paths = %v, want %v", got, wantPaths) + } + + serviceSnapshot := commandIndex(snapshot)["dws service"] + if !serviceSnapshot.Hidden || serviceSnapshot.Deprecated != "use replacement" { + t.Fatalf("service metadata = %#v", serviceSnapshot) + } + if want := []string{"api", "svc"}; !reflect.DeepEqual(serviceSnapshot.Aliases, want) { + t.Fatalf("aliases = %v, want %v", serviceSnapshot.Aliases, want) + } + + leafSnapshot := commandIndex(snapshot)["dws service run"] + if !leafSnapshot.Runnable { + t.Fatal("runnable leaf was not recorded as runnable") + } + local := flagIndex(leafSnapshot.LocalFlags) + if len(local) != 2 { + t.Fatalf("local flags = %#v, want two business flags and no auto help flag", leafSnapshot.LocalFlags) + } + if local["name"].Type != "string" || !local["name"].Required { + t.Fatalf("name flag = %#v, want required string", local["name"]) + } + if local["force"].Type != "bool" || local["force"].Required { + t.Fatalf("force flag = %#v, want optional bool", local["force"]) + } + inherited := flagIndex(leafSnapshot.InheritedFlags) + if inherited["profile"].Type != "string" { + t.Fatalf("inherited flags = %#v, want root --profile", leafSnapshot.InheritedFlags) + } + if _, exists := inherited["help"]; exists { + t.Fatal("auto --help leaked into inherited flags") + } + + var first, second bytes.Buffer + if err := Write(&first, snapshot); err != nil { + t.Fatalf("first Write: %v", err) + } + if err := Write(&second, Capture(root)); err != nil { + t.Fatalf("second Write: %v", err) + } + if !bytes.Equal(first.Bytes(), second.Bytes()) { + t.Fatal("capturing the same Cobra root twice produced different JSON") + } +} + +func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { + input := bytes.NewBufferString(`{ + "schema_version": 1, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true +}`) + if _, err := Read(input); err == nil { + t.Fatal("Read accepted an unknown field") + } +} + +func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) { + base := testSnapshot(testCommand("dws")) + current := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}), + testCommand("dws find"), + ) + comparison := Compare(current, base, "base") + if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_collision") { + t.Fatalf("candidate sibling alias collision was not blocked: %#v", comparison) + } +} diff --git a/scripts/README.md b/scripts/README.md index 381a2dd5..179e022b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,14 +7,16 @@ These repo-local entrypoints are the supported shell entrypoints for building, t - `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 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` - 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` +- 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/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh new file mode 100755 index 00000000..a15a69bb --- /dev/null +++ b/scripts/policy/check-command-compatibility.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eu + +# Compare the current Cobra command surface with two local Git revisions: +# the PR merge-base/main reference and the latest stable GA tag. The caller must +# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag +# governance treats that tag as proof of a successfully published GA release; +# release automation must not leave a GA tag behind when publication fails. +# +# The script never fetches refs or snapshots from the network. With checkout +# fetch-depth=0, both references are materialized in temporary worktrees. A +# reference uses its own snapshot helper when available; older revisions are +# bootstrapped with the candidate helper. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BASE_REF="" +STABLE_REF="" + +usage() { + printf '%s\n' "usage: $0 --base-ref --stable-ref " >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + --stable-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + STABLE_REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; } + +cd "$ROOT" +git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || { + printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2 + exit 2 +} +git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || { + printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2 + exit 2 +} + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")" +BASE_WORKTREE="$TMP_ROOT/base-worktree" +STABLE_WORKTREE="$TMP_ROOT/stable-worktree" + +cleanup() { + git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +ensure_snapshot_helper() { + worktree="$1" + + # Once a baseline contains the helper, use that revision's own capture + # rules. This makes a rule/filter change in the candidate visible as a + # snapshot_rules_changed failure instead of applying the new exclusion to + # both sides and accidentally hiding a removed command. Older revisions + # created before this helper existed are bootstrapped with the candidate + # implementation. + if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \ + [ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \ + [ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then + return + fi + if [ -e "$worktree/cmd/interface-snapshot" ] || \ + [ -e "$worktree/internal/interfacesnapshot" ]; then + printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2 + exit 2 + fi + + mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot" + cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/" + cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/" +} + +generate_ref_snapshot() { + ref="$1" + worktree="$2" + output="$3" + + git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null + ensure_snapshot_helper "$worktree" + ( + cd "$worktree" + go run ./cmd/interface-snapshot generate --output "$output" + ) +} + +CANDIDATE="$TMP_ROOT/candidate.json" +BASELINE="$TMP_ROOT/merge-base.json" +STABLE="$TMP_ROOT/stable.json" + +go run ./cmd/interface-snapshot generate --output "$CANDIDATE" +generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" +generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE" + +go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" 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..af2d8440 100755 --- a/scripts/release/post-goreleaser.sh +++ b/scripts/release/post-goreleaser.sh @@ -90,21 +90,9 @@ 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 ---------- diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh new file mode 100755 index 00000000..0c718fa3 --- /dev/null +++ b/scripts/release/prepare-changelog.sh @@ -0,0 +1,112 @@ +#!/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 +in_unreleased=0 +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + '## [Unreleased]') + in_unreleased=1 + ;; + '## '*) + if [ "$in_unreleased" -eq 1 ]; then + cat "$section" >> "$output" + printf '\n' >> "$output" + inserted=1 + in_unreleased=0 + fi + ;; + esac + printf '%s\n' "$line" >> "$output" +done < "$CHANGELOG" + +if [ "$in_unreleased" -eq 1 ]; then + printf '\n' >> "$output" + cat "$section" >> "$output" + inserted=1 +fi +[ "$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/reconcile-gitee-assets.sh b/scripts/release/reconcile-gitee-assets.sh index c56db597..5bf443b6 100755 --- a/scripts/release/reconcile-gitee-assets.sh +++ b/scripts/release/reconcile-gitee-assets.sh @@ -141,7 +141,8 @@ api_get() { list_assets() { api_get "$GITEE_LIST_MAX_TIME" \ - "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" \ + -H "Authorization: token ${GITEE_TOKEN}" \ + "${base}/releases/${release_id}/attach_files" \ | python3 -c 'import json,sys data=json.load(sys.stdin) rows=data if isinstance(data,list) else data.get("attach_files",[]) @@ -214,7 +215,8 @@ delete_asset() { max_time="$(bounded_max_time "$GITEE_MUTATION_MAX_TIME")" || return 1 curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ --max-time "$max_time" \ - -X DELETE "${base}/releases/${release_id}/attach_files/${asset_id}?access_token=${GITEE_TOKEN}" \ + -H "Authorization: token ${GITEE_TOKEN}" \ + -X DELETE "${base}/releases/${release_id}/attach_files/${asset_id}" \ >/dev/null } @@ -241,7 +243,7 @@ gitee_attach() { if response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ --max-time "$max_time" \ -X POST "${base}/releases/${release_id}/attach_files" \ - -F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1)"; then + -H "Authorization: token ${GITEE_TOKEN}" -F "file=@${file}" 2>&1)"; then status=0 else status=$? 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..6738deca --- /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() { + _rcfv_version="$1" + if release_is_stable_version "$_rcfv_version"; then + printf '%s\n' stable + return 0 + fi + if release_is_prerelease_version "$_rcfv_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' "$_rcfv_version" >&2 + return 1 +} + +release_validate_version_channel() { + _rvvc_expected="$1" + _rvvc_version="$2" + case "$_rvvc_expected" in + stable|prerelease) ;; + *) + printf 'invalid release channel: %s (expected prerelease or stable)\n' "$_rvvc_expected" >&2 + return 1 + ;; + esac + _rvvc_actual="$(release_channel_for_version "$_rvvc_version")" || return 1 + if [ "$_rvvc_actual" != "$_rvvc_expected" ]; then + printf 'release channel/version mismatch: channel=%s version=%s\n' "$_rvvc_expected" "$_rvvc_version" >&2 + return 1 + fi +} + +release_semver() { + printf '%s\n' "${1#v}" +} + +release_core_tag() { + _rct_version="$1" + printf '%s\n' "${_rct_version%%-beta.*}" +} + +release_beta_number() { + printf '%s\n' "${1##*.}" +} + +release_core_is_greater() { + _rcig_candidate="$(release_core_tag "$1")" + _rcig_baseline="$(release_core_tag "$2")" + _rcig_candidate="${_rcig_candidate#v}" + _rcig_baseline="${_rcig_baseline#v}" + awk -v candidate="$_rcig_candidate" -v baseline="$_rcig_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() { + _rec_changelog="$1" + _rec_semver="$2" + _rec_output="$3" + _rec_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-notes.XXXXXX")" + + set +e + awk -v wanted="$_rec_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 + } + ' "$_rec_changelog" > "$_rec_tmp" + _rec_status=$? + set -e + + case "$_rec_status" in + 0) ;; + 41) printf 'CHANGELOG must contain exactly one section: ## [%s] - YYYY-MM-DD\n' "$_rec_semver" >&2 ;; + 42) printf 'CHANGELOG section for %s has an invalid date\n' "$_rec_semver" >&2 ;; + 43) printf 'CHANGELOG section for %s must contain release notes and at least one bullet\n' "$_rec_semver" >&2 ;; + 44) printf 'CHANGELOG section for %s still contains TODO/TBD placeholders\n' "$_rec_semver" >&2 ;; + *) printf 'failed to parse CHANGELOG section for %s\n' "$_rec_semver" >&2 ;; + esac + if [ "$_rec_status" -ne 0 ]; then + rm -f "$_rec_tmp" + return "$_rec_status" + fi + + if [ "$_rec_output" = "-" ]; then + cat "$_rec_tmp" + else + mkdir -p "$(dirname "$_rec_output")" + mv "$_rec_tmp" "$_rec_output" + _rec_tmp="" + fi + [ -z "$_rec_tmp" ] || rm -f "$_rec_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-gitee-tag.sh b/scripts/release/sync-gitee-tag.sh index 02f9e2d4..d4c07d17 100755 --- a/scripts/release/sync-gitee-tag.sh +++ b/scripts/release/sync-gitee-tag.sh @@ -49,11 +49,13 @@ case "$VERSION" in *) err "VERSION must be a v-prefixed release tag: ${VERSION}" ;; esac +gitee_askpass_required=0 if [ -z "${GITEE_GIT_REMOTE:-}" ]; then [ -n "${GITEE_USER:-}" ] || err "GITEE_USER is required" [ -n "${GITEE_TOKEN:-}" ] || err "GITEE_TOKEN is required" [ -n "$GITEE_REPO" ] || err "GITEE_REPO is required" - GITEE_GIT_REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git" + GITEE_GIT_REMOTE="https://gitee.com/${GITEE_REPO}.git" + gitee_askpass_required=1 fi if [ -z "${GITEE_PUBLIC_GIT_REMOTE:-}" ]; then [ -n "$GITEE_REPO" ] || err "GITEE_REPO is required" @@ -154,6 +156,22 @@ fi tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT +if [ "$gitee_askpass_required" -eq 1 ]; then + askpass="$tmp_dir/git-askpass.sh" + cat >"$askpass" <<'EOF' +#!/bin/sh +case "$1" in + *Username*) printf '%s\n' "$DWS_GITEE_USER" ;; + *) printf '%s\n' "$DWS_GITEE_TOKEN" ;; +esac +EOF + chmod 700 "$askpass" + export DWS_GITEE_USER="$GITEE_USER" + export DWS_GITEE_TOKEN="$GITEE_TOKEN" + export GIT_ASKPASS="$askpass" + export GIT_TERMINAL_PROMPT=0 +fi + remote_tag_commit() { local refs_file="${tmp_dir}/remote-refs" if ! run_bounded git ls-remote "$GITEE_PUBLIC_GIT_REMOTE" \ diff --git a/scripts/release/sync-to-gitee.sh b/scripts/release/sync-to-gitee.sh index 7d5d4897..87c2965d 100755 --- a/scripts/release/sync-to-gitee.sh +++ b/scripts/release/sync-to-gitee.sh @@ -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 "error: 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 @@ -121,10 +125,21 @@ api_get() { --max-time "$max_time" "$@" } +release_id_from_json() { + python3 -c 'import json, sys +data = json.load(sys.stdin) +value = data.get("id") if isinstance(data, dict) else None +if isinstance(value, int) and not isinstance(value, bool) and value > 0: + print(value) +elif isinstance(value, str) and value.isdigit() and int(value) > 0: + print(value)' 2>/dev/null +} + # ── 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)" -release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)" + -H "Authorization: token ${GITEE_TOKEN}" \ + "${base}/releases/tags/${VERSION}" 2>/dev/null || true)" +release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)" if [ -z "$release_id" ]; then deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during release lookup" @@ -133,12 +148,12 @@ if [ -z "$release_id" ]; then || err "overall Gitee sync deadline exhausted before release creation" rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \ --max-time "$create_max_time" -X POST "${base}/releases" \ - -F "access_token=${GITEE_TOKEN}" \ + -H "Authorization: token ${GITEE_TOKEN}" \ -F "tag_name=${VERSION}" \ -F "name=${VERSION}" \ -F "body=Mirror of GitHub release ${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)" + release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)" 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}" diff --git a/scripts/release/sync-to-oss.sh b/scripts/release/sync-to-oss.sh index 54a7332d..ce12085c 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,33 +49,122 @@ 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() { # oss_cp + # ossutil reads OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET from the + # environment. Never copy either secret into the process argv. "$OSSUTIL" cp -f \ - --access-key-id "$OSS_ACCESS_KEY_ID" \ - --access-key-secret "$OSS_ACCESS_KEY_SECRET" \ --endpoint "$OSS_ENDPOINT" \ "$1" "oss://${OSS_BUCKET}/$2" } +oss_get() { + # oss_get + "$OSSUTIL" cp -f \ + --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 +178,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..886afbbc 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 + if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; 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 + 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 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 @@ -126,7 +166,6 @@ verify_brew() { brew_home="$TMP_ROOT/brew-home" mkdir -p "$brew_home" - seed_agent_homes "$brew_home" BREW_TAP_NAME="local/dws-package-verify-$$" say "==> verifying Homebrew formula install" @@ -134,7 +173,6 @@ verify_brew() { 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 \ brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null @@ -154,12 +192,16 @@ verify_brew() { )" [ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws" "$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" + fi need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md" } 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/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go new file mode 100644 index 00000000..a97cb649 --- /dev/null +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -0,0 +1,219 @@ +package mock_mcp_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" +) + +const mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER" + +type recordedToolCall struct { + path string + method string + authorization string + jsonrpc string + tool string + arguments map[string]any + err error +} + +// TestCLIHelperProcess runs the production CLI entrypoint with real os.Args. +// The parent test supplies only isolated temp directories, a loopback endpoint, +// and a synthetic token accepted by the local fake server. +func TestCLIHelperProcess(t *testing.T) { + if os.Getenv(mockMCPSmokeHelperEnv) != "1" { + return + } + + marker := -1 + for i, arg := range os.Args { + if arg == "--" { + marker = i + break + } + } + if marker < 0 { + fmt.Fprintln(os.Stderr, "Mock MCP smoke helper: missing -- argument marker") + os.Exit(2) + } + os.Args = append([]string{"dws"}, os.Args[marker+1:]...) + os.Exit(app.Execute()) +} + +func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) { + var requestsMu sync.Mutex + var requests []recordedToolCall + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := recordedToolCall{ + path: r.URL.Path, + method: r.Method, + authorization: r.Header.Get("Authorization"), + } + + var envelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil { + call.err = err + } else { + call.jsonrpc = envelope.JSONRPC + call.tool = envelope.Params.Name + call.arguments = envelope.Params.Arguments + if envelope.Method != "tools/call" { + call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method) + } + } + requestsMu.Lock() + requests = append(requests, call) + requestsMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": `{"success":true,"result":[{"userId":"mock-user-1","name":"Local Mock"}]}`, + }}, + }, + }) + })) + defer server.Close() + + env := isolatedCLIEnv(t, map[string]string{ + "DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact", + }) + args := []string{ + "--token", "ci-smoke-token", + "--format", "json", + "contact", "user", "get", + "--ids", "user-001,user-002", + } + stdout, stderr, err := runCLI(t, env, args...) + if err != nil { + t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout, stderr) + } + + requestsMu.Lock() + recorded := append([]recordedToolCall(nil), requests...) + requestsMu.Unlock() + if len(recorded) != 1 { + t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded) + } + call := recorded[0] + if call.err != nil { + t.Fatal(call.err) + } + if call.path != "/mcp/contact" { + t.Fatalf("request path = %q, want /mcp/contact", call.path) + } + if call.method != http.MethodPost { + t.Fatalf("HTTP method = %q, want POST", call.method) + } + if call.authorization != "Bearer ci-smoke-token" { + t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization) + } + if call.jsonrpc != "2.0" { + t.Fatalf("jsonrpc = %q, want 2.0", call.jsonrpc) + } + if call.tool != "get_user_info_by_user_ids" { + t.Fatalf("tool = %q, want get_user_info_by_user_ids", call.tool) + } + wantArgs := map[string]any{"user_id_list": []any{"user-001", "user-002"}} + if !reflect.DeepEqual(call.arguments, wantArgs) { + t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + if payload["success"] != true { + t.Fatalf("CLI success = %#v, want true; payload=%#v", payload["success"], payload) + } + result, ok := payload["result"].([]any) + if !ok || len(result) != 1 { + t.Fatalf("CLI result = %#v, want one mock user", payload["result"]) + } + user, _ := result[0].(map[string]any) + if user["userId"] != "mock-user-1" { + t.Fatalf("CLI userId = %#v, want mock-user-1; payload=%#v", user["userId"], payload) + } +} + +func isolatedCLIEnv(t *testing.T, extra map[string]string) []string { + t.Helper() + + root := t.TempDir() + controlled := map[string]string{ + "HOME": root, + "USERPROFILE": root, + "DWS_CONFIG_DIR": filepath.Join(root, "config"), + "DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"), + "DWS_DISABLE_KEYCHAIN": "1", + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "http_proxy": "http://127.0.0.1:1", + "https_proxy": "http://127.0.0.1:1", + "NO_PROXY": "127.0.0.1,localhost,::1", + "no_proxy": "127.0.0.1,localhost,::1", + mockMCPSmokeHelperEnv: "1", + "DWS_ALLOW_HTTP_ENDPOINTS": "1", + "DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1", + } + for key, value := range extra { + controlled[key] = value + } + + env := make([]string, 0, len(controlled)+8) + for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } + for key, value := range controlled { + env = append(env, key+"="+value) + } + sort.Strings(env) + return env +} + +func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) + cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String()) + } + return stdout.String(), stderr.String(), err +} diff --git a/test/scripts/gitee_sync_test.go b/test/scripts/gitee_sync_test.go index 72dbf5fe..9a0b35f5 100644 --- a/test/scripts/gitee_sync_test.go +++ b/test/scripts/gitee_sync_test.go @@ -149,6 +149,79 @@ exec %q "$@" } } +func TestSyncGiteeTagUsesAskpassWithoutEmbeddingCredentials(t *testing.T) { + scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) + root := t.TempDir() + workDir := filepath.Join(root, "work") + seedTaggedRepository(t, workDir, "v1.2.3") + expectedCommit := strings.TrimSpace(mustOutput(t, workDir, "git", "rev-parse", "v1.2.3^{commit}")) + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("LookPath(git) error = %v", err) + } + wrapperDir := t.TempDir() + logPath := filepath.Join(root, "git.log") + pushMarker := filepath.Join(root, "pushed") + wrapper := fmt.Sprintf(`#!/bin/sh +set -eu +case "$1" in + ls-remote) + printf '%%s\n' "$*" >>"$GIT_TEST_LOG" + case " $* " in *"$DWS_GITEE_TOKEN"*) exit 91 ;; esac + [ "${GIT_TERMINAL_PROMPT:-}" = "0" ] + [ -x "${GIT_ASKPASS:-}" ] + [ "$("$GIT_ASKPASS" "Username for https://gitee.com")" = "$DWS_GITEE_USER" ] + [ "$("$GIT_ASKPASS" "Password for https://gitee.com")" = "$DWS_GITEE_TOKEN" ] + if [ -f "$GIT_PUSH_MARKER" ]; then + printf '%%s\trefs/tags/v1.2.3^{}\n' "$EXPECTED_COMMIT" + fi + ;; + push) + printf '%%s\n' "$*" >>"$GIT_TEST_LOG" + case " $* " in *"$DWS_GITEE_TOKEN"*) exit 92 ;; esac + [ "$2" = "https://gitee.com/owner/repo.git" ] + : >"$GIT_PUSH_MARKER" + ;; + *) exec %q "$@" ;; +esac +`, realGit) + mustWriteFile(t, filepath.Join(wrapperDir, "git"), []byte(wrapper), 0o755) + + cmd := exec.Command("bash", scriptPath) + cmd.Dir = workDir + cmd.Env = append(os.Environ(), + "PATH="+wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "VERSION=v1.2.3", + "GITEE_USER=test-user", + "GITEE_TOKEN=sensitive-token", + "GITEE_REPO=owner/repo", + "GITEE_SOURCE_REMOTE=", + "GITEE_GIT_REMOTE=", + "GITEE_PUBLIC_GIT_REMOTE=", + "GITEE_TAG_VERIFY_ATTEMPTS=1", + "GITEE_TAG_VERIFY_DELAY=0", + "GITEE_GIT_TIMEOUT_SECONDS=10", + "GIT_TEST_LOG="+logPath, + "GIT_PUSH_MARKER="+pushMarker, + "EXPECTED_COMMIT="+expectedCommit, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("sync-gitee-tag.sh error = %v\noutput:\n%s", err, output) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("ReadFile(git log) error = %v", err) + } + if strings.Contains(string(logData), "sensitive-token") { + t.Fatalf("Gitee token leaked into git argv:\n%s", logData) + } + if !strings.Contains(string(logData), "push https://gitee.com/owner/repo.git") { + t.Fatalf("tag push did not use the credential-free remote:\n%s", logData) + } +} + func TestSyncGiteeTagAndReleasePathShareOneOverallDeadline(t *testing.T) { scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh")) root := t.TempDir() @@ -213,6 +286,16 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget( releaseLookups := 0 releaseCreates := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/repos/") { + if got := r.Header.Get("Authorization"); got != "token test-token" { + t.Errorf("Authorization header = %q, want token authentication", got) + } + if got := r.URL.Query().Get("access_token"); got != "" { + t.Errorf("Gitee token leaked in query string: %q", got) + } + } else if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("token forwarded to asset download URL: %q", got) + } switch { case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/releases/tags/v1.2.3": releaseMu.Lock() @@ -220,11 +303,17 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget( releaseMu.Unlock() http.NotFound(w, r) case r.Method == http.MethodPost && r.URL.Path == "/repos/owner/repo/releases": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("ParseMultipartForm(release create) error = %v", err) + } + if got := r.FormValue("access_token"); got != "" { + t.Errorf("Gitee token leaked in release form: %q", got) + } releaseMu.Lock() releaseCreates++ releaseMu.Unlock() w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"id": 1}) + _, _ = io.WriteString(w, `{"metadata":{"id":999},"id":1}`) default: fake.ServeHTTP(w, r) } @@ -374,15 +463,15 @@ func TestReconcileGiteeAssetsHonorsTheOverallDeadline(t *testing.T) { } } -func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { +func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T) { mirrorPath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "mirror-to-gitee.yml")) mirrorData, err := os.ReadFile(mirrorPath) if err != nil { t.Fatalf("ReadFile(%s) error = %v", mirrorPath, err) } mirror := string(mirrorData) - if !strings.Contains(mirror, `VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh`) { - t.Fatal("tag workflow must delegate immutable tag reconciliation to sync-gitee-tag.sh") + if strings.Contains(mirror, "tags:") || strings.Contains(mirror, "sync-gitee-tag.sh") { + t.Fatal("code mirror must not publish release tags outside the guarded release workflow") } for _, forbidden := range []string{ `git push --force "$REMOTE" "refs/tags/`, @@ -394,9 +483,8 @@ func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { } manualPath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "sync-release-to-gitee.yml")) - manualData, err := os.ReadFile(manualPath) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", manualPath, err) + if _, err := os.Stat(manualPath); !os.IsNotExist(err) { + t.Fatalf("manual Gitee release workflow must be removed, stat error = %v", err) } syncPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-to-gitee.sh")) @@ -417,6 +505,14 @@ func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { t.Fatalf("ReadFile(%s) error = %v", reconcilerPath, err) } reconciler := string(reconcilerData) + for _, forbidden := range []string{"?access_token=", `-F "access_token=`} { + if strings.Contains(syncScript, forbidden) || strings.Contains(reconciler, forbidden) { + t.Fatalf("Gitee API scripts still expose tokens via %q", forbidden) + } + } + if !strings.Contains(syncScript, "Authorization: token") || !strings.Contains(reconciler, "Authorization: token") { + t.Fatal("Gitee API scripts must authenticate with an Authorization header") + } perAssetSeconds := shellDefaultInt(t, reconciler, "GITEE_ASSET_TIMEOUT_SECONDS") overallSeconds := shellDefaultInt(t, reconciler, "GITEE_OVERALL_TIMEOUT_SECONDS") finalListSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME") @@ -443,27 +539,6 @@ func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { } const workflowReserveMinutes = 5 - assertWorkflowBudget( - t, - string(manualData), - "sync-gitee:", - []string{ - "name: Check out repository", - "name: Download GitHub release assets", - "name: Mirror release to Gitee (China)", - }, - workflowReserveMinutes, - ) - manualSyncStepSeconds := workflowTimeoutMinutesAfter( - t, string(manualData), "name: Mirror release to Gitee (China)", - ) * 60 - if syncSeconds+workflowReserveMinutes*60 > manualSyncStepSeconds { - t.Fatalf( - "sync deadline %ds plus reserve exceeds manual sync step %ds", - syncSeconds, manualSyncStepSeconds, - ) - } - releasePath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "release.yml")) releaseData, err := os.ReadFile(releasePath) if err != nil { @@ -474,7 +549,7 @@ func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { string(releaseData), "mirror-gitee-release:", []string{ - "name: Check out repository", + "name: Check out sealed release source", "name: Restore finalized distribution files", "name: Mirror release to Gitee (China)", }, @@ -495,8 +570,31 @@ func TestGiteeWorkflowsUseImmutableTagsAndBoundedRetryBudget(t *testing.T) { if err != nil { t.Fatalf("ReadFile(%s) error = %v", localBuildPath, err) } - if !strings.Contains(string(localBuildData), "publish-gitee-local.sh reconcile-gitee-assets.sh") { - t.Fatal("detached-tag Gitee builds must copy the shared asset reconciler with the publisher") + if !strings.Contains(string(localBuildData), "Direct Gitee release builds are disabled") { + t.Fatal("local Gitee builds must stay disabled so only immutable GitHub assets are mirrored") + } + localPublishPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "publish-gitee-local.sh")) + localPublishData, err := os.ReadFile(localPublishPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", localPublishPath, err) + } + if !strings.Contains(string(localPublishData), "Direct Gitee artifact publication is disabled") { + t.Fatal("direct local Gitee publication must stay disabled") + } + + tagSyncPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "sync-gitee-tag.sh")) + tagSyncData, err := os.ReadFile(tagSyncPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", tagSyncPath, err) + } + tagSync := string(tagSyncData) + if strings.Contains(tagSync, `https://${GITEE_USER}:${GITEE_TOKEN}@`) { + t.Fatal("Gitee git remote must not embed credentials") + } + for _, required := range []string{"GIT_ASKPASS", "GIT_TERMINAL_PROMPT", "DWS_GITEE_TOKEN"} { + if !strings.Contains(tagSync, required) { + t.Fatalf("Gitee tag sync missing askpass safeguard %q", required) + } } } @@ -671,6 +769,19 @@ func newFakeGiteeRelease(dropFirstResponse, failUploads bool) *fakeGiteeRelease func (f *fakeGiteeRelease) ServeHTTP(w http.ResponseWriter, r *http.Request) { const attachPath = "/repos/owner/repo/releases/1/attach_files" + if strings.HasPrefix(r.URL.Path, "/repos/") { + if got := r.Header.Get("Authorization"); got != "token test-token" { + http.Error(w, "missing token authorization header", http.StatusUnauthorized) + return + } + if r.URL.Query().Get("access_token") != "" { + http.Error(w, "token leaked in query string", http.StatusBadRequest) + return + } + } else if r.Header.Get("Authorization") != "" { + http.Error(w, "token forwarded to asset download URL", http.StatusBadRequest) + return + } switch { case r.Method == http.MethodGet && r.URL.Path == attachPath: f.list(w, r) @@ -706,6 +817,10 @@ func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if r.FormValue("access_token") != "" { + http.Error(w, "token leaked in upload form", http.StatusBadRequest) + return + } file, header, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index b6620246..0d488084 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -33,32 +33,6 @@ var expectedPackagedSkillTargets = []string{ ".hermes/skills/dws", } -// seedDistArtifacts creates minimal goreleaser output archives and a -// checksums.txt stub so post-goreleaser.sh can run without a real build. -// Every archive is valid so the packaging tests exercise extraction for all -// platforms; Darwin archives are additionally processed by the signing path. -func seedDistArtifacts(t *testing.T, distDir string, targets []string) { - t.Helper() - if err := os.MkdirAll(distDir, 0o755); err != nil { - t.Fatalf("MkdirAll(%s) error = %v", distDir, err) - } - - for _, target := range targets { - p := filepath.Join(distDir, target) - seedDistArchive(t, p) - } - - // Create empty checksums.txt (goreleaser creates this) - checksums := filepath.Join(distDir, "checksums.txt") - var lines []string - for _, target := range targets { - lines = append(lines, "deadbeef00000000000000000000000000000000000000000000000000000000 "+target) - } - if err := os.WriteFile(checksums, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { - t.Fatalf("WriteFile(%s) error = %v", checksums, err) - } -} - func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) @@ -103,7 +77,33 @@ func seedDistArchive(t *testing.T, path string) { } } -func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string { +// seedDistArtifacts creates minimal goreleaser output archives and a +// checksums.txt stub so post-goreleaser.sh can run without a real build. +// Every archive is valid so the packaging tests exercise extraction for all +// platforms; Darwin archives are additionally processed by the signing path. +func seedDistArtifacts(t *testing.T, distDir string, targets []string) { + t.Helper() + if err := os.MkdirAll(distDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", distDir, err) + } + + for _, target := range targets { + p := filepath.Join(distDir, target) + seedDistArchive(t, p) + } + + // Create empty checksums.txt (goreleaser creates this) + checksums := filepath.Join(distDir, "checksums.txt") + var lines []string + for _, target := range targets { + lines = append(lines, "deadbeef00000000000000000000000000000000000000000000000000000000 "+target) + } + if err := os.WriteFile(checksums, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", checksums, err) + } +} + +func postGoreleaserEnv(t *testing.T, distDir, version, releaseBaseURL string) []string { t.Helper() binDir := t.TempDir() @@ -114,7 +114,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, ) @@ -159,7 +159,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { seedDistArtifacts(t, distDir, targets) cmd := exec.Command("sh", scriptPath) - cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3") + cmd.Env = postGoreleaserEnv(t, distDir, "v1.2.3", "https://downloads.example.com/dws/releases/v1.2.3") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output)) @@ -188,6 +188,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { "class DingtalkWorkspaceCliLocal < Formula", "resource \"skills\" do", "Install locally built DingTalk workspace CLI artifacts for verification", + "Agent Skills are bundled in #{pkgshare}/skills/dws", } { if !strings.Contains(formulaText, want) { t.Fatalf("formula missing %q:\n%s", want, formulaText) @@ -203,7 +204,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) { for _, want := range []string{ "class DingtalkWorkspaceCli < Formula", `desc "Automate DingTalk workspace tasks from the terminal"`, - `version "0.0.0"`, + `version "1.2.3"`, "on_macos do", "on_linux do", "https://downloads.example.com/dws/releases/v1.2.3/dws-darwin-amd64.tar.gz", @@ -259,13 +260,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, "v1.2.3", "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) } } @@ -372,7 +380,7 @@ func TestPostGoreleaserBuildsVersionedBetaFormula(t *testing.T) { "dws-linux-amd64.tar.gz", "dws-linux-arm64.tar.gz", }) - env := postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3-beta.4") + env := postGoreleaserEnv(t, distDir, "v1.2.3-beta.4", "https://downloads.example.com/dws/releases/v1.2.3-beta.4") for i, value := range env { if strings.HasPrefix(value, "DWS_PACKAGE_VERSION=") { env[i] = "DWS_PACKAGE_VERSION=v1.2.3-beta.4" @@ -435,7 +443,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) { seedDistArtifacts(t, distDir, allArchives) cmd := exec.Command("sh", scriptPath) - cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v9.9.9") + cmd.Env = postGoreleaserEnv(t, distDir, "v9.9.9", "https://downloads.example.com/dws/releases/v9.9.9") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output)) @@ -449,7 +457,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) } @@ -528,7 +536,7 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) { seedDistArtifacts(t, distDir, targets) cmd := exec.Command("sh", scriptPath) - cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v0.0.0") + cmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output)) @@ -603,38 +611,40 @@ func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) { } workflow := string(data) + build := strings.Index(workflow, "Build release artifacts without publishing") postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh") - upload := strings.Index(workflow, "Upload finalized signed assets to release") - if postProcess == -1 || upload == -1 || upload < postProcess { - t.Fatalf("finalized asset upload must run after post-goreleaser.sh") + preserve := strings.Index(workflow, "Preserve finalized distribution files") + verifyJob := strings.Index(workflow, "verify-darwin-signatures:") + publishJob := strings.Index(workflow, "publish-release:") + if build == -1 || postProcess == -1 || preserve == -1 || verifyJob == -1 || publishJob == -1 || + !(build < postProcess && postProcess < preserve && preserve < verifyJob && verifyJob < publishJob) { + t.Fatalf("post-processed assets must be preserved, Apple-verified, and only then published") } - if !strings.Contains(workflow[upload:], "./scripts/release/finalize-github-release.sh") { - t.Fatal("release workflow must delegate atomic finalization to finalize-github-release.sh") - } - - finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh")) - if err != nil { - t.Fatalf("Abs(finalize-github-release.sh) error = %v", err) - } - finalizeData, err := os.ReadFile(finalizePath) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", finalizePath, err) - } - finalize := string(finalizeData) - + buildSection := workflow[build:verifyJob] for _, required := range []string{ - "dws-darwin-amd64.tar.gz", - "dws-darwin-arm64.tar.gz", + "--skip=publish", + "actions/upload-artifact@v4", + "finalized-release-dist", + } { + if !strings.Contains(buildSection, required) { + t.Errorf("signed build stage is missing %q", required) + } + } + + publishSection := workflow[publishJob:] + for _, required := range []string{ + "actions/download-artifact@v4", + "dist/dws-*.tar.gz", + "dist/dws-windows-*.zip", "checksums.txt", "dws-skills.zip", "gh release upload", - "gh release view", "--clobber", - "release asset digest mismatch", + "verify-release-artifacts.sh", } { - if !strings.Contains(finalize, required) { - t.Errorf("finalized asset upload is missing %q", required) + if !strings.Contains(publishSection, required) { + t.Errorf("immutable publication stage is missing %q", required) } } } @@ -653,7 +663,7 @@ func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) { workflow := string(data) prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate") - goReleaser := strings.Index(workflow, "Run GoReleaser") + goReleaser := strings.Index(workflow, "Build release artifacts without publishing") postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh") cleanup := strings.Index(workflow, "Remove Apple Developer ID certificate") if prepare == -1 || goReleaser == -1 || postProcess == -1 || cleanup == -1 || @@ -744,36 +754,35 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) { } workflow := string(data) - upload := strings.Index(workflow, "Upload finalized signed assets to release") + preserve := strings.Index(workflow, "Preserve finalized distribution files") verifyJob := strings.Index(workflow, "verify-darwin-signatures:") publishJob := strings.Index(workflow, "publish-release:") - if upload == -1 || verifyJob == -1 || publishJob == -1 || !(upload < verifyJob && verifyJob < publishJob) { - t.Fatal("finalized Draft assets must be uploaded, Apple-verified, and only then published") + if preserve == -1 || verifyJob == -1 || publishJob == -1 || !(preserve < verifyJob && verifyJob < publishJob) { + t.Fatal("finalized artifacts must be preserved, Apple-verified, and only then published") } codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4") - publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false`) + publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --draft=false`) if codesign == -1 || publish == -1 { t.Fatal("macOS codesign verification and explicit Draft publication are required") } - buildSection := workflow[upload:verifyJob] + buildSection := workflow[preserve:verifyJob] for _, required := range []string{ - `DWS_PUBLISH_RELEASE: "false"`, "actions/upload-artifact@v4", "finalized-release-dist", } { if !strings.Contains(buildSection, required) { - t.Errorf("Draft build stage is missing %q", required) + t.Errorf("signed build stage is missing %q", required) } } verifySection := workflow[verifyJob:publishJob] for _, required := range []string{ "runs-on: macos-latest", - "gh release download", - "dws-darwin-amd64.tar.gz", - "dws-darwin-arm64.tar.gz", + "actions/download-artifact@v4", + "finalized-release-dist", + `dws-darwin-${arch}.tar.gz`, "codesign --verify --strict --verbose=4", } { if !strings.Contains(verifySection, required) { @@ -785,13 +794,13 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) { for _, required := range []string{ "verify-darwin-signatures", "actions/download-artifact@v4", - "Publish verified Draft release", - "Publish stable to npm", - "Publish prerelease to npm beta", + "Publish or reuse immutable GitHub Release", + "gh release upload", + "Publish missing version to npm channel", "Open stable Homebrew formula PR", "Open beta Homebrew formula PR", "DingTalk-Real-AI/dingtalk-workspace-cli.git", - "secrets.GITHUB_TOKEN", + "secrets.HOMEBREW_PR_TOKEN", } { if !strings.Contains(publishSection, required) { t.Errorf("post-verification publication stage is missing %q", required) @@ -828,14 +837,14 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) { if start == -1 { t.Fatal("release workflow is missing the stable Homebrew PR step") } - end := strings.Index(workflow[start:], "- name: Mirror release to Gitee") + end := strings.Index(workflow[start:], "- name: Open beta Homebrew formula PR") if end == -1 { - t.Fatal("release workflow is missing the post-Homebrew Gitee step") + t.Fatal("release workflow is missing the beta Homebrew PR step after the stable step") } section := workflow[start : start+end] for _, required := range []string{ "github.repository_owner == 'DingTalk-Real-AI'", - "!contains(github.ref_name, '-')", + "needs.release-contract.outputs.channel == 'stable'", "./scripts/release/publish-homebrew-formula.sh", "secrets.HOMEBREW_PR_TOKEN", "DWS_TAP_PR_REPOSITORY", @@ -848,7 +857,7 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) { if strings.Contains(section, "secrets.GITHUB_TOKEN") { t.Error("Homebrew Formula PRs must use the dedicated token so their CI is triggered") } - stableNPM := strings.Index(workflow, "- name: Publish stable to npm") + stableNPM := strings.Index(workflow, "- name: Publish missing version to npm channel") if stableNPM == -1 || start > stableNPM { t.Fatal("Homebrew PR creation must run before npm so a failure is safely rerunnable") } @@ -871,14 +880,14 @@ func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) { if start == -1 { t.Fatal("release workflow is missing the beta Homebrew PR step") } - end := strings.Index(workflow[start:], "- name: Sync release to China OSS mirror") + end := strings.Index(workflow[start:], "- name: Reverify exact immutable npm package") if end == -1 { - t.Fatal("release workflow is missing the post-Homebrew OSS step") + t.Fatal("release workflow is missing the post-Homebrew npm verification step") } section := workflow[start : start+end] for _, required := range []string{ "github.repository_owner == 'DingTalk-Real-AI'", - "contains(github.ref_name, '-')", + "needs.release-contract.outputs.channel == 'prerelease'", "dist/homebrew/dingtalk-workspace-cli-beta.rb", "Formula/dingtalk-workspace-cli-beta.rb", "secrets.HOMEBREW_PR_TOKEN", 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..37e0959c --- /dev/null +++ b/test/scripts/release_script_test.go @@ -0,0 +1,1023 @@ +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 TestReleaseLibHelpersPreserveCallerVariables(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") + changelogPath := filepath.Join(t.TempDir(), "CHANGELOG.md") + mustWriteFile(t, changelogPath, []byte("## [1.2.3] - 2026-07-16\n\n- Test release.\n"), 0o644) + + script := ` +. "$1" +version=caller-version +expected=caller-expected +actual=caller-actual +candidate=caller-candidate +baseline=caller-baseline +changelog=caller-changelog +semver=caller-semver +output=caller-output +tmp=caller-tmp +status=caller-status + +release_channel_for_version v1.2.3 >/dev/null +release_validate_version_channel stable v1.2.3 +release_core_tag v1.2.3-beta.1 >/dev/null +release_core_is_greater v1.2.4 v1.2.3 +release_extract_changelog "$2" 1.2.3 - >/dev/null + +printf '%s\n' "$version" "$expected" "$actual" "$candidate" "$baseline" \ + "$changelog" "$semver" "$output" "$tmp" "$status" +` + output, err := exec.Command("sh", "-c", script, "sh", lib, changelogPath).CombinedOutput() + if err != nil { + t.Fatalf("release helper variable isolation error = %v\noutput:\n%s", err, output) + } + want := strings.Join([]string{ + "caller-version", + "caller-expected", + "caller-actual", + "caller-candidate", + "caller-baseline", + "caller-changelog", + "caller-semver", + "caller-output", + "caller-tmp", + "caller-status", + }, "\n") + "\n" + if string(output) != want { + t.Fatalf("release helpers changed caller variables:\ngot:\n%s\nwant:\n%s", output, want) + } +} + +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 TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(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) + mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644) + r.commitAndPush(t, "add unreleased changelog note") + + 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") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output) + } + data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(data) + positions := []int{ + strings.Index(content, "## [Unreleased]"), + strings.Index(content, "- Keep this unreleased note."), + strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"), + strings.Index(content, "## [1.0.0] - 2026-07-01"), + } + for index, position := range positions { + if position < 0 { + t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content) + } + } + for index := 1; index < len(positions); index++ { + if positions[index-1] >= positions[index] { + t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content) + } + } +} + +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") + argsLogPath := filepath.Join(root, "ossutil-args.log") + fakeOSSUtil := filepath.Join(root, "ossutil") + mustWriteFile(t, fakeOSSUtil, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$@\" >> \"$OSSUTIL_ARGS_LOG\"\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, + "OSSUTIL_ARGS_LOG="+argsLogPath, + ) + 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) + } + argsData, err := os.ReadFile(argsLogPath) + if err != nil { + t.Fatalf("ReadFile(ossutil args log) error = %v", err) + } + for _, forbidden := range []string{"test-key", "test-secret", "--access-key-id", "--access-key-secret"} { + if strings.Contains(string(argsData), forbidden) { + t.Fatalf("ossutil argv exposed %q:\n%s", forbidden, argsData) + } + } + }) + } +} + +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) +} diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go new file mode 100644 index 00000000..27f86e5c --- /dev/null +++ b/test/smoke/cli_smoke_test.go @@ -0,0 +1,212 @@ +package smoke_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/spf13/cobra" +) + +const cliSmokeHelperEnv = "DWS_CLI_SMOKE_HELPER" + +// TestCLIHelperProcess executes the real app entrypoint in a subprocess. Product +// routing inspects os.Args, so using Cobra.SetArgs would not exercise the same +// path as the dws binary. +func TestCLIHelperProcess(t *testing.T) { + if os.Getenv(cliSmokeHelperEnv) != "1" { + return + } + + marker := -1 + for i, arg := range os.Args { + if arg == "--" { + marker = i + break + } + } + if marker < 0 { + fmt.Fprintln(os.Stderr, "CLI smoke helper: missing -- argument marker") + os.Exit(2) + } + os.Args = append([]string{"dws"}, os.Args[marker+1:]...) + os.Exit(app.Execute()) +} + +func TestCLISmoke_AllPublicCommandsSupportHelp(t *testing.T) { + _ = isolatedCLIEnv(t) + + root := app.NewRootCommand() + root.InitDefaultHelpCmd() + paths := publicCommandPaths(root) + if len(paths) == 0 { + t.Fatal("public command traversal returned no commands") + } + + for _, path := range paths { + path := path + name := "root" + if len(path) > 0 { + name = strings.Join(path, "/") + } + t.Run(name, func(t *testing.T) { + args := append(append([]string(nil), path...), "--help") + // Help never executes a product handler, so SetArgs is sufficient here. + // Real product dispatch is covered below through app.Execute in a + // subprocess because that path intentionally inspects os.Args. + cmd := app.NewRootCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs(args) + err := cmd.Execute() + if err != nil { + t.Fatalf("dws %s failed: %v\noutput:\n%s", strings.Join(args, " "), err, output.String()) + } + if strings.TrimSpace(output.String()) == "" { + t.Fatalf("dws %s returned empty help", strings.Join(args, " ")) + } + }) + } + + t.Logf("validated --help for %d public Cobra command paths", len(paths)) +} + +func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) { + env := isolatedCLIEnv(t) + + tests := []struct { + name string + args []string + wantTool string + }{ + { + name: "contact search", + args: []string{"--mock", "--format", "json", "contact", "user", "search", "--query", "Ada"}, + wantTool: "search_contact_by_key_word", + }, + { + name: "calendar list", + args: []string{ + "--mock", "--format", "json", "calendar", "event", "list", + "--start", "2026-07-10T09:00:00+08:00", + "--end", "2026-07-10T10:00:00+08:00", + }, + wantTool: "list_calendar_events", + }, + { + name: "ding list", + args: []string{"--mock", "--format", "json", "ding", "message", "list", "--type", "UNREAD"}, + wantTool: "list_ding_messages", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, err := runCLI(t, env, tc.args...) + if err != nil { + t.Fatalf("dws %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("dws %s returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(tc.args, " "), err, stdout, stderr) + } + if payload["_mock"] != true { + t.Fatalf("dws %s _mock = %#v, want true; payload=%#v", strings.Join(tc.args, " "), payload["_mock"], payload) + } + if payload["_tool"] != tc.wantTool { + t.Fatalf("dws %s _tool = %#v, want %q; payload=%#v", strings.Join(tc.args, " "), payload["_tool"], tc.wantTool, payload) + } + }) + } +} + +// publicCommandPaths follows only canonical, user-visible Cobra commands. +// Hidden commands and commands marked Deprecated by Cobra are intentionally +// excluded; aliases are not separate command nodes. Parents and leaves are both +// checked, while positional placeholders in Use are never synthesized. +func publicCommandPaths(root *cobra.Command) [][]string { + var paths [][]string + var walk func(*cobra.Command, []string) + walk = func(cmd *cobra.Command, path []string) { + if cmd != root && (cmd.Hidden || cmd.Deprecated != "") { + return + } + + paths = append(paths, append([]string(nil), path...)) + for _, child := range cmd.Commands() { + walk(child, append(path, child.Name())) + } + } + walk(root, nil) + + sort.Slice(paths, func(i, j int) bool { + return strings.Join(paths[i], " ") < strings.Join(paths[j], " ") + }) + return paths +} + +func isolatedCLIEnv(t *testing.T) []string { + t.Helper() + + root := t.TempDir() + controlled := map[string]string{ + "HOME": root, + "USERPROFILE": root, + "DWS_CONFIG_DIR": filepath.Join(root, "config"), + "DWS_KEYCHAIN_DIR": filepath.Join(root, "keychain"), + "DWS_DISABLE_KEYCHAIN": "1", + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "http_proxy": "http://127.0.0.1:1", + "https_proxy": "http://127.0.0.1:1", + "NO_PROXY": "127.0.0.1,localhost,::1", + "no_proxy": "127.0.0.1,localhost,::1", + cliSmokeHelperEnv: "1", + "DWS_ALLOW_HTTP_ENDPOINTS": "1", + "DWS_TRUSTED_DOMAINS": "127.0.0.1,localhost,::1", + } + for key, value := range controlled { + t.Setenv(key, value) + } + + env := make([]string, 0, len(controlled)+8) + for _, key := range []string{"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SYSTEMROOT"} { + if value := os.Getenv(key); value != "" { + env = append(env, key+"="+value) + } + } + for key, value := range controlled { + env = append(env, key+"="+value) + } + sort.Strings(env) + return env +} + +func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) + cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("dws %s timed out: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), ctx.Err(), stdout.String(), stderr.String()) + } + return stdout.String(), stderr.String(), err +}