From 609d56305e008c968b4c67848a79ba1f18ba5112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Sun, 19 Jul 2026 12:18:04 +0800 Subject: [PATCH 01/67] fix(release): bind draft publication to release ID --- .github/workflows/release.yml | 201 +++++++++++++--- CHANGELOG.md | 1 + .../release/download-github-release-assets.sh | 87 +++++++ .../release/verify-github-release-assets.sh | 32 ++- test/scripts/package_script_test.go | 90 ++++++- test/scripts/release_script_test.go | 223 +++++++++++++++++- 6 files changed, 599 insertions(+), 35 deletions(-) create mode 100755 scripts/release/download-github-release-assets.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08adb13c..45baf027 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -929,6 +929,7 @@ jobs: "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - name: Publish or reuse immutable GitHub Release + id: publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} @@ -941,9 +942,36 @@ jobs: prerelease_flag="--prerelease" fi + release_id="" if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then - is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)" - is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)" + release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || { + echo "Existing GitHub Release has an invalid database ID: $release_id" >&2 + exit 1 + } + resolved_tag="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .tag_name + )" + test "$resolved_tag" = "$RELEASE_VERSION" || { + echo "GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2 + exit 1 + } + is_draft="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .draft + )" + is_prerelease="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .prerelease + )" if [ "$is_prerelease" != "$expected_prerelease" ]; then echo "Existing release channel does not match $RELEASE_CHANNEL." >&2 exit 1 @@ -951,14 +979,18 @@ jobs: if [ "$is_draft" = "false" ]; then is_immutable="$( gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ --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 "$RELEASE_VERSION" --json body --jq .body)" + remote_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --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 @@ -976,11 +1008,9 @@ jobs: fi published_dir="$RUNNER_TEMP/published-release" mkdir -p "$published_dir" - gh release download "$RELEASE_VERSION" \ - --dir "$published_dir" \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$published_dir" DWS_PACKAGE_DIST_DIR="$published_dir" \ ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do @@ -993,12 +1023,10 @@ jobs: DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \ ./scripts/release/stage-npm-package.sh "$RELEASE_VERSION" + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" echo "Published GitHub Release is immutable and verified; reusing its exact assets." exit 0 fi - gh release edit "$RELEASE_VERSION" \ - --title "$RELEASE_VERSION" \ - --notes-file "$RUNNER_TEMP/release-notes.md" else # shellcheck disable=SC2086 gh release create "$RELEASE_VERSION" \ @@ -1007,6 +1035,63 @@ jobs: --title "$RELEASE_VERSION" \ --notes-file "$RUNNER_TEMP/release-notes.md" \ $prerelease_flag + release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + fi + printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || { + echo "Draft GitHub Release has an invalid database ID: $release_id" >&2 + exit 1 + } + resolved_tag="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .tag_name + )" + test "$resolved_tag" = "$RELEASE_VERSION" || { + echo "Draft GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2 + exit 1 + } + gh api --method PATCH \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -f tag_name="$RELEASE_VERSION" \ + -f target_commitish="$RELEASE_COMMIT" \ + -f name="$RELEASE_VERSION" \ + -f body="$(cat "$RUNNER_TEMP/release-notes.md")" \ + -F draft=true \ + -F prerelease="$expected_prerelease" \ + >/dev/null + draft_state="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq '[.tag_name, .draft, .prerelease] | @tsv' + )" + test "$draft_state" = "$(printf '%s\ttrue\t%s' "$RELEASE_VERSION" "$expected_prerelease")" || { + echo "Draft GitHub Release identity or channel changed before upload." >&2 + exit 1 + } + draft_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + --jq .body + )" + local_body="$(cat "$RUNNER_TEMP/release-notes.md")" + test "$draft_body" = "$local_body" || { + echo "Draft GitHub Release notes differ from the sealed CHANGELOG." >&2 + exit 1 + } + if [ "$IS_RECOVERY" = "true" ]; then + recovery_marker="" + case "$draft_body" in + *"$recovery_marker"*) ;; + *) + echo "Draft GitHub Release is not bound to this exact recovery run." >&2 + exit 1 + ;; + esac fi gh release upload "$RELEASE_VERSION" \ dist/dws-*.tar.gz \ @@ -1014,15 +1099,24 @@ jobs: dist/dws-skills.zip \ dist/checksums.txt \ --clobber - ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + uploaded_release_id="$( + gh release view "$RELEASE_VERSION" \ + --json databaseId \ + --jq .databaseId + )" + test "$uploaded_release_id" = "$release_id" || { + echo "GitHub Release identity changed during upload: $release_id -> $uploaded_release_id" >&2 + exit 1 + } + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \ + "$RELEASE_VERSION" sealed_upload="$RUNNER_TEMP/sealed-upload" rm -rf "$sealed_upload" mkdir -p "$sealed_upload" - gh release download "$RELEASE_VERSION" \ - --dir "$sealed_upload" \ - --pattern 'dws-*' \ - --pattern 'checksums.txt' \ - --clobber + DWS_GITHUB_RELEASE_ID="$release_id" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$sealed_upload" DWS_PACKAGE_DIST_DIR="$sealed_upload" \ ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do @@ -1032,23 +1126,76 @@ jobs: exit 1 } done - gh release edit "$RELEASE_VERSION" --draft=false + gh api --method PATCH \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$release_id" \ + -f name="$RELEASE_VERSION" \ + -f body="$(cat "$RUNNER_TEMP/release-notes.md")" \ + -F prerelease="$expected_prerelease" \ + -F draft=false \ + >/dev/null + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" - name: Require immutable published GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_ID: ${{ steps.publish.outputs.release_id }} + RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }} run: | set -eu - immutable="$( - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \ - --jq .immutable - )" - test "$immutable" = true || { - echo "GitHub Release is not immutable; refusing downstream publication." >&2 + expected_prerelease=false + if [ "$RELEASE_CHANNEL" = "prerelease" ]; then expected_prerelease=true; fi + printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + echo "Published GitHub Release has an invalid database ID: $RELEASE_ID" >&2 exit 1 } - ./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION" + release_state="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" + test "$release_state" = "$(printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease")" || { + echo "GitHub Release ID $RELEASE_ID is not the exact public immutable $RELEASE_VERSION release." >&2 + exit 1 + } + immutable_body="$( + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq .body + )" + local_body="$(cat "$RUNNER_TEMP/release-notes.md")" + test "$immutable_body" = "$local_body" || { + echo "Immutable GitHub Release notes differ from the sealed CHANGELOG." >&2 + exit 1 + } + if [ "$IS_RECOVERY" = "true" ]; then + recovery_marker="" + case "$immutable_body" in + *"$recovery_marker"*) ;; + *) + echo "Immutable GitHub Release is not bound to this exact recovery run." >&2 + exit 1 + ;; + esac + fi + DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \ + "$RELEASE_VERSION" + immutable_dir="$RUNNER_TEMP/immutable-release" + rm -rf "$immutable_dir" + mkdir -p "$immutable_dir" + DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \ + "$RELEASE_VERSION" "$immutable_dir" + DWS_PACKAGE_DIST_DIR="$immutable_dir" \ + ./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION" + for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do + sealed_asset="dist/$(basename "$immutable_asset")" + cmp -s "$sealed_asset" "$immutable_asset" || { + echo "Immutable GitHub asset differs from this run's sealed bytes: $(basename "$immutable_asset")" >&2 + exit 1 + } + done "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c2843b..90031778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery. - **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget. ## [1.0.53-beta.4] - 2026-07-17 diff --git a/scripts/release/download-github-release-assets.sh b/scripts/release/download-github-release-assets.sh new file mode 100755 index 00000000..4f00789d --- /dev/null +++ b/scripts/release/download-github-release-assets.sh @@ -0,0 +1,87 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +TAG="${1:-}" +DEST_DIR="${2:-}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}" + +[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || { + printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh \n' >&2 + exit 2 +} +[ -n "$RELEASE_ID" ] || { + printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2 + exit 2 +} +printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2 + exit 2 +} +command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } + +DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \ + "$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG" + +tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +tab="$(printf '\t')" +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/$RELEASE_ID" \ + --jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv" +cut -f 2 "$tmp/assets.tsv" | 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 + +while IFS="$tab" read -r asset_id asset_name; do + printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2 + exit 1 + } + case "$asset_name" in + checksums.txt | \ + 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) ;; + *) + printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2 + exit 1 + ;; + esac + gh api -H 'Accept: application/octet-stream' \ + "repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name" +done < "$tmp/assets.tsv" + +mkdir -p "$DEST_DIR" +for asset_name in \ + checksums.txt \ + 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; do + [ -f "$tmp/$asset_name" ] || { + printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2 + exit 1 + } + mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name" +done + +printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG" diff --git a/scripts/release/verify-github-release-assets.sh b/scripts/release/verify-github-release-assets.sh index 3d80771d..ffa347a4 100755 --- a/scripts/release/verify-github-release-assets.sh +++ b/scripts/release/verify-github-release-assets.sh @@ -3,13 +3,32 @@ set -eu TAG="${1:-}" REPOSITORY="${GITHUB_REPOSITORY:-}" +RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}" [ -n "$TAG" ] && [ -n "$REPOSITORY" ] || { - printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh \n' >&2 + printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh \n' >&2 exit 2 } command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; } +if [ -z "$RELEASE_ID" ]; then + RELEASE_ID="$( + gh release view "$TAG" \ + --repo "$REPOSITORY" \ + --json databaseId,isDraft \ + --jq 'select(.isDraft == true) | .databaseId' 2>/dev/null || true + )" +fi +if [ -n "$RELEASE_ID" ]; then + printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || { + printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2 + exit 2 + } + release_endpoint="repos/$REPOSITORY/releases/$RELEASE_ID" +else + release_endpoint="repos/$REPOSITORY/releases/tags/$TAG" +fi + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")" trap 'rm -rf "$tmp"' EXIT HUP INT TERM cat > "$tmp/expected" <<'EOF' @@ -24,8 +43,17 @@ dws-windows-arm64.zip EOF LC_ALL=C sort "$tmp/expected" -o "$tmp/expected" +resolved_tag="$( + gh api -H 'Accept: application/vnd.github+json' \ + "$release_endpoint" \ + --jq '.tag_name' +)" +[ "$resolved_tag" = "$TAG" ] || { + printf 'GitHub Release ID/tag mismatch: expected %s, got %s\n' "$TAG" "$resolved_tag" >&2 + exit 1 +} gh api -H 'Accept: application/vnd.github+json' \ - "repos/$REPOSITORY/releases/tags/$TAG" \ + "$release_endpoint" \ --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 diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 5ff36249..7885ea93 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -799,6 +799,94 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) { } } +func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n") + publishStep := releaseWorkflowSection( + t, + publishJob, + " - name: Publish or reuse immutable GitHub Release\n", + "\n - name: Require immutable published GitHub Release\n", + ) + + for _, required := range []string{ + "id: publish", + "--json databaseId", + `"repos/$GITHUB_REPOSITORY/releases/$release_id"`, + `uploaded_release_id="$(`, + `test "$uploaded_release_id" = "$release_id"`, + "Draft GitHub Release ID $release_id targets", + "Draft GitHub Release notes differ from the sealed CHANGELOG.", + "Draft GitHub Release is not bound to this exact recovery run.", + `DWS_GITHUB_RELEASE_ID="$release_id"`, + `tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`, + `tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`, + `cmp -s "$local_asset" "$remote_asset"`, + "-F draft=false", + `echo "release_id=$release_id" >> "$GITHUB_OUTPUT"`, + } { + if !strings.Contains(publishStep, required) { + t.Errorf("Draft release lifecycle is missing %q", required) + } + } + for _, forbidden := range []string{ + `gh release download "$RELEASE_VERSION"`, + `gh release edit "$RELEASE_VERSION" --draft=false`, + `"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION"`, + } { + if strings.Contains(publishStep, forbidden) { + t.Errorf("Draft release lifecycle must not switch back from the locked release ID via %q", forbidden) + } + } + + tagGuard := strings.Index(publishStep, "Draft GitHub Release ID $release_id targets") + draftPatch := strings.Index(publishStep, "-F draft=true") + bodyVerify := strings.Index(publishStep, "Draft GitHub Release notes differ from the sealed CHANGELOG.") + markerVerify := strings.Index(publishStep, "Draft GitHub Release is not bound to this exact recovery run.") + upload := strings.Index(publishStep, `gh release upload "$RELEASE_VERSION"`) + idRecheck := strings.Index(publishStep, `test "$uploaded_release_id" = "$release_id"`) + verify := strings.Index(publishStep, "tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh") + download := strings.LastIndex(publishStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh") + byteCompare := strings.Index(publishStep, `cmp -s "$local_asset" "$remote_asset"`) + publish := strings.Index(publishStep, "-F draft=false") + if tagGuard == -1 || draftPatch == -1 || bodyVerify == -1 || markerVerify == -1 || + upload == -1 || idRecheck == -1 || verify == -1 || download == -1 || byteCompare == -1 || publish == -1 || + !(tagGuard < draftPatch && draftPatch < bodyVerify && bodyVerify < markerVerify && markerVerify < upload && + upload < idRecheck && idRecheck < verify && verify < download && download < byteCompare && byteCompare < publish) { + t.Fatal("Draft must retain one release ID through upload, exact verification, download, byte comparison, and publication") + } + + terminalStep := publishJob[strings.Index(publishJob, " - name: Require immutable published GitHub Release\n"):] + for _, required := range []string{ + `RELEASE_ID: ${{ steps.publish.outputs.release_id }}`, + `RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}`, + `"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"`, + `DWS_GITHUB_RELEASE_ID="$RELEASE_ID"`, + `tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`, + `tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`, + `[.tag_name, .draft, .prerelease, .immutable] | @tsv`, + `printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease"`, + "Immutable GitHub Release notes differ from the sealed CHANGELOG.", + "Immutable GitHub Release is not bound to this exact recovery run.", + `DWS_PACKAGE_DIST_DIR="$immutable_dir"`, + `cmp -s "$sealed_asset" "$immutable_asset"`, + } { + if !strings.Contains(terminalStep, required) { + t.Errorf("terminal immutable release gate is missing %q", required) + } + } + immutableState := strings.Index(terminalStep, `[.tag_name, .draft, .prerelease, .immutable] | @tsv`) + immutableBody := strings.Index(terminalStep, "Immutable GitHub Release notes differ from the sealed CHANGELOG.") + immutableDownload := strings.Index(terminalStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh") + immutableVerify := strings.Index(terminalStep, `DWS_PACKAGE_DIST_DIR="$immutable_dir"`) + immutableCompare := strings.Index(terminalStep, `cmp -s "$sealed_asset" "$immutable_asset"`) + if immutableState == -1 || immutableBody == -1 || immutableDownload == -1 || immutableVerify == -1 || immutableCompare == -1 || + !(immutableState < immutableBody && immutableBody < immutableDownload && immutableDownload < immutableVerify && immutableVerify < immutableCompare) { + t.Fatal("terminal gate must reverify immutable notes and exact sealed bytes on the locked release ID") + } +} + func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) { t.Parallel() scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh")) @@ -1090,7 +1178,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) { } codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4") - publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`) + publish := strings.Index(workflow[publishJob:], "-F draft=false") if codesign == -1 || publish == -1 { t.Fatal("macOS codesign verification and explicit Draft publication are required") } diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 38df9fab..fa799fa2 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -259,7 +259,25 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) { } binDir := t.TempDir() fakeGH := filepath.Join(binDir, "gh") - mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755) + mustWriteFile(t, fakeGH, []byte(`#!/bin/sh +set -eu +case "${1:-}" in + release) + [ "${2:-}" = "view" ] || exit 2 + [ "${GH_DRAFT:-false}" = "true" ] || exit 1 + printf '123\n' + ;; + api) + printf '%s\n' "$*" >> "$GH_LOG" + case "$*" in + *tag_name*) printf '%s\n' "$GH_RELEASE_TAG" ;; + *assets*name*) printf '%s\n' "$GH_ASSETS" ;; + *) exit 2 ;; + esac + ;; + *) exit 2 ;; +esac +`), 0o755) exact := strings.Join([]string{ "dws-windows-arm64.zip", "dws-linux-amd64.tar.gz", @@ -271,23 +289,218 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) { "dws-darwin-arm64.tar.gz", }, "\n") script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh") - run := func(assets string) (string, error) { + run := func(assets string, draft bool, releaseTag, releaseID string) (string, string, error) { + draftValue := "false" + if draft { + draftValue = "true" + } + logPath := filepath.Join(t.TempDir(), "gh.log") 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, + "GH_DRAFT=" + draftValue, + "GH_LOG=" + logPath, + "GH_RELEASE_TAG=" + releaseTag, + "DWS_GITHUB_RELEASE_ID=" + releaseID, } output, err := cmd.CombinedOutput() - return string(output), err + log, readErr := os.ReadFile(logPath) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("ReadFile(%s) error = %v", logPath, readErr) + } + return string(output), string(log), err } - if output, err := run(exact); err != nil { + if output, _, err := run(exact, false, "v1.2.3", ""); 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") { + if output, log, err := run(exact, true, "v1.2.3", ""); err != nil { + t.Fatalf("exact Draft GitHub assets rejected: %v\n%s", err, output) + } else if !strings.Contains(log, "repos/owner/repo/releases/123") || + strings.Contains(log, "repos/owner/repo/releases/tags/v1.2.3") { + t.Fatalf("Draft verification did not use the release ID endpoint:\n%s", log) + } + if output, _, err := run(exact+"\nmalware.exe", false, "v1.2.3", ""); err == nil || !strings.Contains(output, "exactly the supported assets") { t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output) } + if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") { + t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output) + } + if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") { + t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output) + } +} + +func TestReleaseGitHubAssetsDownloadByExactReleaseID(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 +set -eu +[ "${1:-}" = "api" ] || exit 2 +shift + +accept="" +endpoint="" +query="" +while [ "$#" -gt 0 ]; do + case "$1" in + -H) + accept="${2:-}" + shift 2 + ;; + --jq) + query="${2:-}" + shift 2 + ;; + repos/*) + endpoint="$1" + shift + ;; + *) + shift + ;; + esac +done +printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG" + +case "$accept" in + *application/octet-stream*) + case "$endpoint" in + repos/owner/repo/releases/assets/*) ;; + *) exit 31 ;; + esac + asset_id="${endpoint##*/}" + asset_name="$( + awk -F ' ' -v id="$asset_id" ' + $1 == id { print $2; found = 1 } + END { if (!found) exit 1 } + ' "$GH_ASSET_ROWS" + )" || exit 32 + printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name" + exit 0 + ;; +esac + +[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33 +case "$query" in + '.tag_name') + printf '%s\n' "$GH_RELEASE_TAG" + ;; + '.assets[].name') + cut -f 2 "$GH_ASSET_ROWS" + ;; + '.assets[] | [.id, .name] | @tsv') + cat "$GH_ASSET_ROWS" + ;; + *) + exit 34 + ;; +esac +`), 0o755) + + type releaseAsset struct { + id string + name string + } + assets := []releaseAsset{ + {id: "1008", name: "dws-windows-arm64.zip"}, + {id: "1003", name: "dws-darwin-arm64.tar.gz"}, + {id: "1001", name: "checksums.txt"}, + {id: "1005", name: "dws-linux-arm64.tar.gz"}, + {id: "1002", name: "dws-darwin-amd64.tar.gz"}, + {id: "1007", name: "dws-windows-amd64.zip"}, + {id: "1004", name: "dws-linux-amd64.tar.gz"}, + {id: "1006", name: "dws-skills.zip"}, + } + rowsFor := func(items []releaseAsset) string { + var rows []string + for _, asset := range items { + rows = append(rows, asset.id+"\t"+asset.name) + } + return strings.Join(rows, "\n") + "\n" + } + script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh") + type runResult struct { + output string + log string + dest string + err error + } + run := func(items []releaseAsset, releaseTag, releaseID string) runResult { + runDir := t.TempDir() + rowsPath := filepath.Join(runDir, "assets.tsv") + logPath := filepath.Join(runDir, "gh.log") + dest := filepath.Join(runDir, "download") + mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644) + cmd := exec.Command("sh", script, "v1.2.3", dest) + cmd.Env = []string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "GITHUB_REPOSITORY=owner/repo", + "DWS_GITHUB_RELEASE_ID=" + releaseID, + "GH_ASSET_ROWS=" + rowsPath, + "GH_EXPECTED_RELEASE_ID=456", + "GH_LOG=" + logPath, + "GH_RELEASE_TAG=" + releaseTag, + } + output, runErr := cmd.CombinedOutput() + log, readErr := os.ReadFile(logPath) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("ReadFile(%s) error = %v", logPath, readErr) + } + return runResult{ + output: string(output), + log: string(log), + dest: dest, + err: runErr, + } + } + + success := run(assets, "v1.2.3", "456") + if success.err != nil { + t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output) + } + if strings.Contains(success.log, "releases/tags/") { + t.Fatalf("download fell back to a tag endpoint:\n%s", success.log) + } + if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) { + t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log) + } + for _, asset := range assets { + wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id + if !strings.Contains(success.log, wantEndpoint) { + t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log) + } + data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name)) + if readErr != nil { + t.Errorf("ReadFile(%s) error = %v", asset.name, readErr) + continue + } + want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name) + if string(data) != want { + t.Errorf("%s bytes = %q, want %q", asset.name, data, want) + } + } + + extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"}) + if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") { + t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output) + } + if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") { + t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output) + } + if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") { + t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output) + } + if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") { + t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output) + } } func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) { From bc587ddd9169c7966e1baf3397c0cd9b2d3106be Mon Sep 17 00:00:00 2001 From: DWS Release Bot Date: Sun, 19 Jul 2026 13:21:07 +0000 Subject: [PATCH 02/67] chore: update beta formula for v1.0.53-beta.4 --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index acb98ccf..8076eb26 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.53-beta.2" + version "1.0.53-beta.4" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-arm64.tar.gz" - sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz" + sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz" - sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz" + sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-arm64.tar.gz" - sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz" + sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz" - sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz" + sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip" - sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip" + sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e" end def install From 29abdb6e7906bbf355204c148565f854b4567e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Sun, 19 Jul 2026 21:21:16 +0800 Subject: [PATCH 03/67] fix(event): serialize bus shutdown with accept loop Wait for the accept loop to stop before waiting for connection handlers, and track accepted connections before publishing handlers. This removes the WaitGroup Add/Wait race caught by PR #667 CI and follows up the event bus introduced in #589. --- internal/event/bus/coverage_edges_test.go | 6 ++++-- internal/event/bus/daemon.go | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/event/bus/coverage_edges_test.go b/internal/event/bus/coverage_edges_test.go index 24761bc2..6b4faeae 100644 --- a/internal/event/bus/coverage_edges_test.go +++ b/internal/event/bus/coverage_edges_test.go @@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) { d.conns.Store("not-a-conn", struct{}{}) d.conns.Store(&queryConn{}, struct{}{}) d.consumerWG.Add(1) - d.shutdown() + acceptDone := make(chan struct{}) + close(acceptDone) + d.shutdown(acceptDone) d.consumerWG.Done() - d.shutdown() + d.shutdown(acceptDone) pr, pw, err := os.Pipe() if err != nil { diff --git a/internal/event/bus/daemon.go b/internal/event/bus/daemon.go index d85bd46d..7d3ad911 100644 --- a/internal/event/bus/daemon.go +++ b/internal/event/bus/daemon.go @@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error { } // 7. Graceful shutdown — cancel runCtx first so all background - // goroutines wake up, then close listener / drain consumers. + // goroutines wake up, then stop accepting connections before draining + // consumers. The accept-loop barrier is required before WaitGroup.Wait: + // sync.WaitGroup forbids a positive Add racing with Wait. cancelRun() - d.shutdown() - <-acceptDone + d.shutdown(acceptDone) <-idleDone <-dropWarnDone @@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) { d.log.Warn("bus: accept error", "err", err) continue } + // Track the connection before publishing the handler goroutine. Once + // acceptLoop returns, shutdown can therefore close every accepted + // connection before waiting for handlers to drain. + d.conns.Store(conn, struct{}{}) d.consumerWG.Add(1) go func() { defer d.consumerWG.Done() @@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) { // Hello → register with Hub → spawn writer goroutine → read until EOF/Bye. // Always Unregisters and Closes on exit (plan invariant #5). func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) { - d.conns.Store(conn, struct{}{}) defer func() { d.conns.Delete(conn) conn.Close() @@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) { // 1. mark shuttingDown so acceptLoop exits cleanly // 2. broadcast Bye to all consumers // 3. close listener (interrupts pending Accept) -// 4. wait for all per-connection goroutines to drain -// 5. lock + meta cleanup via Run's defers -func (d *daemon) shutdown() { +// 4. wait for acceptLoop to return so no future consumerWG.Add can occur +// 5. close all accepted connections and wait for handlers to drain +// 6. lock + meta cleanup via Run's defers +func (d *daemon) shutdown(acceptDone <-chan struct{}) { d.shutdownMu.Lock() defer d.shutdownMu.Unlock() if !d.shuttingDown.CompareAndSwap(false, true) { @@ -488,6 +493,7 @@ func (d *daemon) shutdown() { } d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}) _ = d.listener.Close() + <-acceptDone // Force-close all open IPC connections so any reader goroutine blocked // on Read() returns with a network error and exits cleanly. Without // this the consumerWG never drains and Run hangs forever. From 6b50cc41c589f3297072cb5c64190479c8f0eb78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Fri, 17 Jul 2026 15:48:04 +0800 Subject: [PATCH 04/67] ci(release): add dispatch repair-gitee job to mirror an existing release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-triggered mirror-gitee-release job consumes the same run's finalized-release-dist artifact, so it cannot mirror a tag that was already published — including one delivered by a recovery dispatch such as v1.0.53-beta.3. Add a workflow_dispatch repair-gitee job (input mirror_gitee_version) that re-derives the asset set from the immutable GitHub Release, verifies it byte-for-byte via checksums, and runs sync-to-gitee.sh. Guarded to the official repo + default branch and gated by the existing Gitee secrets. --- .github/workflows/release.yml | 215 ++++++++++++++++++++++++++-- test/scripts/package_script_test.go | 91 +++++++++++- 2 files changed, 293 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45baf027..c2912a36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ name: Release -run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }} +run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.workflow }} on: push: @@ -11,6 +11,10 @@ on: description: "Recover npm for a public immutable release without moving latest/beta" required: false type: string + repair_gitee_version: + description: "Mirror an existing public immutable release's assets to Gitee" + required: false + type: string governance_preflight_commit: description: "Verify release governance for this exact default-branch commit without publishing" required: false @@ -51,9 +55,9 @@ on: permissions: contents: read -# Queue governance checks, tag releases, protected recoveries, and npm repairs -# behind one publication lock. The local entrypoint also refuses to tag while -# a Release run is active. +# Queue governance checks, tag releases, protected recoveries, and channel +# repairs behind one publication lock. The local entrypoint also refuses to tag +# while a Release run is active. concurrency: group: dws-release-publication queue: max @@ -70,6 +74,7 @@ jobs: id: mode env: REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} + REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }} GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }} GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }} RECOVER_VERSION: ${{ inputs.recover_release_version }} @@ -81,22 +86,26 @@ jobs: RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }} run: | set -eu - repair=0 + npm_repair=0 + gitee_repair=0 governance=0 recovery=0 - test -z "$REPAIR_NPM_VERSION" || repair=1 + test -z "$REPAIR_NPM_VERSION" || npm_repair=1 + test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1 if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \ test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \ test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \ test -n "$RECOVER_NONCE" || \ test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi - test $((repair + governance + recovery)) -eq 1 || { + test $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || { echo "workflow_dispatch must select exactly one release mode" >&2 exit 1 } - if test "$repair" -eq 1; then + if test "$npm_repair" -eq 1; then echo "mode=repair_npm" >> "$GITHUB_OUTPUT" + elif test "$gitee_repair" -eq 1; then + echo "mode=repair_gitee" >> "$GITHUB_OUTPUT" elif test "$governance" -eq 1; then if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then echo "governance preflight requires both commit and nonce" >&2 @@ -1809,6 +1818,7 @@ jobs: - publish-channels - mirror-gitee-release - repair-npm + - repair-gitee runs-on: ubuntu-latest timeout-minutes: 2 permissions: {} @@ -1828,6 +1838,7 @@ jobs: PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }} MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} + REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }} run: | set -eu require_result() { @@ -1857,6 +1868,7 @@ jobs: require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped require_publication ;; workflow_dispatch:recover_release) @@ -1864,6 +1876,7 @@ jobs: require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped require_publication ;; workflow_dispatch:governance_preflight) @@ -1877,6 +1890,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped ;; workflow_dispatch:repair_npm) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1889,9 +1903,194 @@ jobs: require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped + ;; + workflow_dispatch:repair_gitee) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result repair-gitee "$REPAIR_GITEE_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped ;; *) echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2 exit 1 ;; esac + # Mirror an existing public immutable release's assets to Gitee on demand. The + # push-triggered mirror-gitee-release job depends on the same run's finalized + # dist artifact, so a tag that was already published (including one delivered + # by a recovery dispatch) cannot reuse it. This dispatch path re-derives the + # asset set from the immutable GitHub Release itself and mirrors it. The + # release tag and asset checksums remain the sealed authority boundary. + repair-gitee: + name: Mirror an existing immutable release to Gitee + needs: dispatch-contract + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: read + contents: read + steps: + - name: Check out trusted release tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + path: tooling + persist-credentials: false + fetch-depth: 0 + + - name: Validate repair version + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version }} + run: | + set -eu + . ./scripts/release/release-lib.sh + if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then + echo "Invalid release version: $VERSION" >&2 + exit 2 + fi + + - name: Verify immutable release authority + id: authority + uses: actions/github-script@v7 + env: + VERSION: ${{ inputs.repair_gitee_version }} + with: + script: | + const { owner, repo } = context.repo; + const version = process.env.VERSION; + const ref = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if (ref.data.object.type !== "tag") { + core.setFailed(`${version} is not an annotated tag`); + return; + } + const tag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: ref.data.object.sha, + }); + if (tag.data.tag !== version || tag.data.object.type !== "commit") { + core.setFailed(`${version} does not resolve to one annotated commit tag`); + return; + } + const commitSha = tag.data.object.sha; + const defaultBranch = context.payload.repository.default_branch; + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + const comparison = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${commitSha}...${branch.data.commit.sha}`, + }); + if (!["ahead", "identical"].includes(comparison.data.status)) { + core.setFailed(`${version} is not contained in ${defaultBranch}`); + return; + } + const release = await github.rest.repos.getReleaseByTag({ + owner, + repo, + tag: version, + }); + if ( + release.data.tag_name !== version || + release.data.draft || + !release.data.immutable + ) { + core.setFailed(`${version} is not a public immutable GitHub Release`); + return; + } + const expectedPrerelease = version.includes("-beta."); + if (release.data.prerelease !== expectedPrerelease) { + core.setFailed(`${version} prerelease metadata does not match its version`); + return; + } + const expectedAssets = new Set([ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", + "dws-skills.zip", + "checksums.txt", + ]); + const assetNames = release.data.assets.map((asset) => asset.name); + if ( + assetNames.length !== expectedAssets.size || + assetNames.some((name) => !expectedAssets.has(name)) || + new Set(assetNames).size !== expectedAssets.size + ) { + core.setFailed(`${version} does not contain the exact release asset set`); + return; + } + core.setOutput("commit_sha", commitSha); + core.setOutput("tag_object", ref.data.object.sha); + + - name: Fetch and verify sealed release tag + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }} + run: | + set -eu + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' + ./scripts/release/verify-github-tag-authority.sh \ + "$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" + + - name: Require successful Release workflow delivery + working-directory: tooling + env: + VERSION: ${{ inputs.repair_gitee_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./scripts/release/verify-release-workflow-delivery.sh \ + "$VERSION" "$RELEASE_COMMIT" + + - name: Download and verify immutable GitHub Release assets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.repair_gitee_version }} + run: | + set -eu + mkdir -p dist + gh release download "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist \ + --pattern 'dws-*' \ + --pattern 'checksums.txt' \ + --clobber + DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \ + "$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION" + + - name: Mirror release to Gitee (China) + timeout-minutes: 100 + working-directory: tooling + run: | + "$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh" + env: + VERSION: ${{ inputs.repair_gitee_version }} + DIST_DIR: ${{ github.workspace }}/dist + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + DWS_REQUIRE_GITEE: "1" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 7885ea93..676313d6 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -724,6 +724,86 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) { } } +func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n") + start := strings.Index(workflow, " repair-gitee:\n") + if start == -1 { + t.Fatal("release workflow is missing the Gitee repair job") + } + repair := workflow[start:] + + for _, required := range []string{ + "repair_gitee_version:", + `format('Release Gitee repair {0}', inputs.repair_gitee_version)`, + `REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`, + "gitee_repair=0", + `test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`, + "npm_repair + gitee_repair + governance + recovery", + `echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`, + } { + if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) { + t.Errorf("Gitee repair dispatch contract is missing %q", required) + } + } + + for _, required := range []string{ + "needs: dispatch-contract", + `if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`, + `github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`, + `github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`, + "actions: read", + `ref: ${{ github.sha }}`, + "path: tooling", + "persist-credentials: false", + "release_is_stable_version", + "release_is_prerelease_version", + `ref: ` + "`tags/${version}`", + `["ahead", "identical"].includes(comparison.data.status)`, + "!release.data.immutable", + `release.data.prerelease !== expectedPrerelease`, + "assetNames.length !== expectedAssets.size", + "new Set(assetNames).size !== expectedAssets.size", + `core.setOutput("tag_object", ref.data.object.sha)`, + "verify-github-tag-authority.sh", + "verify-release-workflow-delivery.sh", + `DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`, + "verify-release-artifacts.sh", + `--repo "$GITHUB_REPOSITORY"`, + `DWS_REQUIRE_GITEE: "1"`, + "working-directory: tooling\n run: |\n " + + `"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`, + } { + if !strings.Contains(repair, required) { + t.Errorf("Gitee repair authority is missing %q", required) + } + } + for _, asset := range []string{ + "dws-darwin-amd64.tar.gz", + "dws-darwin-arm64.tar.gz", + "dws-linux-amd64.tar.gz", + "dws-linux-arm64.tar.gz", + "dws-windows-amd64.zip", + "dws-windows-arm64.zip", + "dws-skills.zip", + "checksums.txt", + } { + if strings.Count(repair, `"`+asset+`"`) != 1 { + t.Errorf("Gitee repair must require exactly one %s asset declaration", asset) + } + } + if strings.Contains(repair, "contents: write") { + t.Error("Gitee repair must not grant contents write permission") + } + if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") { + t.Error("Gitee repair must not check out a floating default branch") + } + if got := strings.Count(repair, "working-directory: tooling"); got < 4 { + t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got) + } +} + func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) @@ -978,11 +1058,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) - start := strings.Index(workflow, " release-delivery-gate:\n") - if start == -1 { - t.Fatal("release workflow is missing the terminal delivery gate") - } - gate := workflow[start:] + gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n") for _, required := range []string{ "name: Release delivery gate", @@ -997,6 +1073,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "- publish-channels", "- mirror-gitee-release", "- repair-npm", + "- repair-gitee", + `REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`, "require_publication", `require_result release-contract "$RELEASE_CONTRACT_RESULT" success`, `require_result release "$RELEASE_RESULT" success`, @@ -1006,6 +1084,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "workflow_dispatch:recover_release", "workflow_dispatch:governance_preflight", "workflow_dispatch:repair_npm", + "workflow_dispatch:repair_gitee", + `require_result repair-gitee "$REPAIR_GITEE_RESULT" success`, + `require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`, "unsupported release mode", } { if !strings.Contains(gate, required) { From 6cfa9e3afba252a636e2989b95a2c69e3bd7076f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Fri, 17 Jul 2026 15:26:57 +0800 Subject: [PATCH 05/67] ci: fast-path changelog-only pull requests --- .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/workflows/ci.yml | 133 ++++++++- .github/workflows/multi-profile-e2e.yml | 39 ++- docs/ci-pr-gates.md | 30 ++ scripts/policy/check-changelog-pr.sh | 274 +++++++++++++++++ scripts/release/release-lib.sh | 3 +- test/scripts/changelog_pr_gate_test.go | 376 ++++++++++++++++++++++++ 7 files changed, 856 insertions(+), 6 deletions(-) create mode 100755 scripts/policy/check-changelog-pr.sh create mode 100644 test/scripts/changelog_pr_gate_test.go diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cab641ce..0b2dfad6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,6 +5,13 @@ ## Verification +For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks +may be marked `N/A`, but the targeted CHANGELOG check is required. For every +other pull request, mark the targeted check `N/A` and complete the applicable +full-suite checks. + +- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`): + `./scripts/policy/check-changelog-pr.sh "$(git merge-base HEAD origin/main)" HEAD` - [ ] `make build` - [ ] `make lint` - [ ] `make test` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a81439..dc37a956 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,71 @@ concurrency: cancel-in-progress: true jobs: + scope: + name: Classify PR changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + changelog_only: ${{ steps.classify.outputs.changelog_only }} + steps: + - name: Classify exact CHANGELOG-only pull requests + id: classify + uses: actions/github-script@v7 + with: + script: | + let changelogOnly = false; + let files = []; + + if (context.eventName === 'pull_request') { + files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + changelogOnly = + files.length === 1 && + files[0].filename === 'CHANGELOG.md' && + files[0].status === 'modified' && + !files[0].previous_filename; + } + + core.setOutput('changelog_only', String(changelogOnly)); + await core.summary + .addHeading('PR change classification') + .addRaw(`- Event: \`${context.eventName}\`\n`) + .addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`) + .addRaw(`- Changed files: \`${files.length}\`\n`) + .write(); + + changelog-check: + name: Changelog Check + needs: scope + if: ${{ needs.scope.outputs.changelog_only == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out pull request head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Validate exact CHANGELOG change + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: ./scripts/policy/check-changelog-pr.sh "$PR_BASE_SHA" "$PR_HEAD_SHA" + lint: name: Lint + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -44,6 +107,8 @@ jobs: actionlint: name: Actionlint + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -60,6 +125,8 @@ jobs: test-race: name: "Test (race: ${{ matrix.shard }})" + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -116,6 +183,8 @@ jobs: test-release-scripts: name: Test (release scripts) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -136,9 +205,10 @@ jobs: test: name: Test needs: + - scope - test-race - test-release-scripts - if: ${{ always() }} + if: ${{ always() && needs.scope.result == 'success' && needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} @@ -164,6 +234,8 @@ jobs: test-darwin: name: Test (macOS auth/keychain) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: macos-latest timeout-minutes: 15 steps: @@ -180,6 +252,8 @@ jobs: test-windows: name: Test (Windows) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -202,6 +276,8 @@ jobs: coverage-darwin: name: Coverage (macOS) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: macos-latest timeout-minutes: 20 steps: @@ -244,6 +320,8 @@ jobs: coverage-windows: name: Coverage (Windows) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: windows-latest timeout-minutes: 20 steps: @@ -288,6 +366,8 @@ jobs: coverage-current: name: Coverage (current) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -321,6 +401,8 @@ jobs: coverage-supporting: name: Coverage (supporting) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -358,6 +440,8 @@ jobs: coverage-baseline: name: Coverage (baseline) + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -421,10 +505,11 @@ jobs: coverage: name: Coverage needs: + - scope - coverage-current - coverage-supporting - coverage-baseline - if: ${{ always() }} + if: ${{ always() && needs.scope.result == 'success' && needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -517,6 +602,8 @@ jobs: policy: name: Policy Check + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -536,6 +623,8 @@ jobs: interface-integrity: name: Interface Integrity + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -594,6 +683,8 @@ jobs: cli-smoke: name: CLI Smoke + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -613,6 +704,8 @@ jobs: mock-mcp-smoke: name: Mock MCP Smoke + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -629,6 +722,8 @@ jobs: edition-tests: name: Edition Contract Tests + needs: scope + if: ${{ needs.scope.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -646,6 +741,8 @@ jobs: ci-gate: name: CI Gate needs: + - scope + - changelog-check - lint - actionlint - test @@ -666,6 +763,9 @@ jobs: steps: - name: Verify required checks env: + SCOPE_RESULT: ${{ needs.scope.result }} + CHANGELOG_ONLY: ${{ needs.scope.outputs.changelog_only }} + CHANGELOG_RESULT: ${{ needs.changelog-check.result }} LINT_RESULT: ${{ needs.lint.result }} ACTIONLINT_RESULT: ${{ needs.actionlint.result }} TEST_RESULT: ${{ needs.test.result }} @@ -681,6 +781,33 @@ jobs: EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} run: | failed=0 + if [ "$SCOPE_RESULT" != "success" ]; then + printf 'Classify PR changes: %s\n' "$SCOPE_RESULT" + failed=1 + fi + + case "$CHANGELOG_ONLY" in + true) + expected_heavy_result=skipped + expected_changelog_result=success + ;; + false) + expected_heavy_result=success + expected_changelog_result=skipped + ;; + *) + printf 'invalid changelog_only classification: %s\n' "$CHANGELOG_ONLY" + failed=1 + expected_heavy_result=invalid + expected_changelog_result=invalid + ;; + esac + + printf 'Changelog Check: %s\n' "$CHANGELOG_RESULT" + if [ "$CHANGELOG_RESULT" != "$expected_changelog_result" ]; then + failed=1 + fi + for check in \ "Lint:$LINT_RESULT" \ "Actionlint:$ACTIONLINT_RESULT" \ @@ -699,7 +826,7 @@ jobs: name="${check%%:*}" result="${check#*:}" printf '%s: %s\n' "$name" "$result" - if [ "$result" != "success" ]; then + if [ "$result" != "$expected_heavy_result" ]; then failed=1 fi done diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml index 61a1d9ae..11a02a96 100644 --- a/.github/workflows/multi-profile-e2e.yml +++ b/.github/workflows/multi-profile-e2e.yml @@ -3,10 +3,13 @@ name: Multi Profile E2E on: pull_request: push: + branches: + - main workflow_dispatch: permissions: contents: read + pull-requests: read concurrency: group: multi-profile-e2e-${{ github.ref }} @@ -21,15 +24,49 @@ jobs: MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log steps: + - name: Classify pull request changes + id: changes + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + const changelogOnly = + files.length === 1 && + files[0].filename === 'CHANGELOG.md' && + files[0].status === 'modified' && + !files[0].previous_filename; + + core.setOutput('changelog_only', String(changelogOnly)); + + - name: Record changelog-only fast path + if: github.event_name == 'pull_request' && steps.changes.outputs.changelog_only == 'true' + shell: bash + run: | + { + echo "### Multi Profile E2E" + echo "- Scope: exact in-place modification of \`CHANGELOG.md\`" + echo "- Result: skipped by the changelog-only pull request fast path" + echo "- Full E2E: runs after merge on the \`main\` push" + } >> "$GITHUB_STEP_SUMMARY" + - name: Check out repository + if: steps.changes.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: steps.changes.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Run isolated multi-profile chain + if: steps.changes.outputs.changelog_only != 'true' shell: bash run: | set -o pipefail @@ -43,7 +80,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload debug artifacts - if: failure() + if: failure() && steps.changes.outputs.changelog_only != 'true' uses: actions/upload-artifact@v4 with: name: multi-profile-e2e-debug diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 0a94b945..87ad3e8e 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -42,6 +42,36 @@ The repository defines five focused checks in addition to its existing CI: evaluator writes an `AI Behavior Check` commit status to the PR head SHA so GitHub rulesets can require it. +## Exact CHANGELOG-only fast path + +A pull request qualifies for the fast path only when GitHub reports exactly +one changed file and that file is an in-place modification of +`CHANGELOG.md`. Adding, deleting, or renaming the file does not qualify, and +neither does changing any second file. + +The workflow classifies the pull request through GitHub's pull-request files +API before checking out or executing pull-request code. Qualifying changes run +the targeted CHANGELOG contract check. Because classification rejects any +second file, the validator itself is the reviewed version from the base: + +```sh +base_ref=$(git merge-base HEAD origin/main) +./scripts/policy/check-changelog-pr.sh "$base_ref" HEAD +``` + +The `CI Gate` remains present and succeeds only after that targeted check +passes. `Multi Profile E2E` also remains present as a required successful job, +but records the fast-path decision in its job summary instead of checking out +the repository, setting up Go, or running the E2E chain. The workflows use +job/step conditions rather than workflow-level `paths-ignore`, so required +checks are never left pending. + +All other pull requests run the normal full gates. After a qualifying pull +request is merged, the `main` push runs the complete CI and Multi Profile E2E +again; the fast path never applies to a push. Multi Profile E2E listens only +for pushes to `main`, avoiding a duplicate branch-push run for same-repository +pull requests. + ## Running the compatibility gates Run: diff --git a/scripts/policy/check-changelog-pr.sh b/scripts/policy/check-changelog-pr.sh new file mode 100755 index 00000000..8c956469 --- /dev/null +++ b/scripts/policy/check-changelog-pr.sh @@ -0,0 +1,274 @@ +#!/bin/sh +set -eu + +# Validate the narrow PR fast path. The diff must be exactly one in-place +# CHANGELOG.md modification, and every touched release section must still +# satisfy the release contract. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" + +usage() { + printf '%s\n' "usage: $0 BASE HEAD" >&2 +} + +if [ "$#" -ne 2 ]; then + usage + exit 2 +fi + +BASE_REF="$1" +HEAD_REF="$2" + +cd "$ROOT" + +BASE_COMMIT="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")" || { + printf 'error: CHANGELOG base ref is not an available commit: %s\n' "$BASE_REF" >&2 + exit 2 +} +HEAD_COMMIT="$(git rev-parse --verify --quiet "${HEAD_REF}^{commit}")" || { + printf 'error: CHANGELOG head ref is not an available commit: %s\n' "$HEAD_REF" >&2 + exit 2 +} + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-changelog-pr.XXXXXX")" +trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM + +if ! git merge-base --all "$BASE_COMMIT" "$HEAD_COMMIT" >"$TMP_ROOT/merge-bases"; then + printf 'error: CHANGELOG base and head commits have no merge base\n' >&2 + exit 2 +fi +merge_base_count="$(wc -l <"$TMP_ROOT/merge-bases" | tr -d '[:space:]')" +if [ "$merge_base_count" -ne 1 ]; then + printf 'error: expected exactly one merge base, found %s\n' "$merge_base_count" >&2 + exit 2 +fi +IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases" + +git diff --no-ext-diff --find-renames --name-status \ + "$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status" +printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status" +if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2 + if [ -s "$TMP_ROOT/name-status" ]; then + sed 's/^/ /' "$TMP_ROOT/name-status" >&2 + else + printf '%s\n' ' (no changed files)' >&2 + fi + exit 1 +fi + +if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then + printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2 + exit 1 +fi + +"$ROOT/scripts/policy/open-source-audit.sh" + +if ! git show "${MERGE_BASE}:CHANGELOG.md" >"$TMP_ROOT/base-changelog" || + ! git show "${HEAD_COMMIT}:CHANGELOG.md" >"$TMP_ROOT/head-changelog"; then + printf '%s\n' 'error: could not read CHANGELOG.md from both commits' >&2 + exit 2 +fi + +git diff --no-ext-diff --unified=0 \ + "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md >"$TMP_ROOT/changelog.patch" +if grep -q '^Binary files ' "$TMP_ROOT/changelog.patch"; then + printf '%s\n' 'error: CHANGELOG.md must remain a text file' >&2 + exit 1 +fi +if ! awk ' + /^\+\+\+ / { next } + /^\+/ { + line = substr($0, 2) + if (line ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) { + bad = 1 + } + } + END { exit bad } +' "$TMP_ROOT/changelog.patch"; then + printf '%s\n' 'error: changed CHANGELOG additions must not contain TODO/TBD placeholders' >&2 + exit 1 +fi + +. "$ROOT/scripts/release/release-lib.sh" + +extract_labeled_section() { + _els_file="$1" + _els_wanted="$2" + awk -v wanted="$_els_wanted" ' + /^## / { + active = 0 + if (substr($0, 1, 4) == "## [") { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) { + active = 1 + } + } + } + active { print } + ' "$_els_file" +} + +extract_unmanaged_content() { + _euc_file="$1" + awk ' + BEGIN { active = 1 } + /^## / { + active = 1 + if (substr($0, 1, 4) == "## [") { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1) { + active = 0 + } + } + } + active { print } + ' "$_euc_file" +} + +label_heading_count() { + _lhc_file="$1" + _lhc_wanted="$2" + awk -v wanted="$_lhc_wanted" ' + /^## / && substr($0, 1, 4) == "## [" { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) count++ + } + END { print count + 0 } + ' "$_lhc_file" +} + +calendar_date_is_valid() { + _cdiv_date="$1" + awk -v date="$_cdiv_date" 'BEGIN { + if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) exit 1 + split(date, part, "-") + year = part[1] + 0 + month = part[2] + 0 + day = part[3] + 0 + if (year < 1 || month < 1 || month > 12 || day < 1) exit 1 + days = 31 + if (month == 4 || month == 6 || month == 9 || month == 11) days = 30 + if (month == 2) { + days = 28 + if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) days = 29 + } + exit day > days + }' +} + +release_heading_date() { + _rhd_file="$1" + _rhd_version="$2" + awk -v wanted="$_rhd_version" ' + BEGIN { prefix = "## [" wanted "] - " } + index($0, prefix) == 1 { + print substr($0, length(prefix) + 1) + } + ' "$_rhd_file" +} + +validate_release_notes() { + _vrn_file="$1" + set +e + awk ' + NR == 1 { next } + { + if ($0 ~ /[^[:space:]]/) meaningful = 1 + if ($0 ~ /^- /) bullet = 1 + if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1 + } + END { + if (!meaningful || !bullet) exit 43 + if (placeholder) exit 44 + } + ' "$_vrn_file" + _vrn_status=$? + set -e + case "$_vrn_status" in + 0) return 0 ;; + 43) printf '%s\n' 'error: changed release section must contain notes and at least one bullet' >&2 ;; + 44) printf '%s\n' 'error: changed release section still contains TODO/TBD placeholders' >&2 ;; + *) printf '%s\n' 'error: failed to validate changed release notes' >&2 ;; + esac + return "$_vrn_status" +} + +extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged" +extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged" +if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then + printf '%s\n' 'error: fast path only permits notes inside Unreleased or versioned release sections' >&2 + exit 1 +fi + +unreleased_count="$(label_heading_count "$TMP_ROOT/head-changelog" Unreleased)" +unreleased_exact_count="$(awk '$0 == "## [Unreleased]" { count++ } END { print count + 0 }' \ + "$TMP_ROOT/head-changelog")" +if [ "$unreleased_count" -ne 1 ] || [ "$unreleased_exact_count" -ne 1 ]; then + printf 'error: CHANGELOG must contain exactly one heading: ## [Unreleased]\n' >&2 + exit 1 +fi + +extract_labeled_section "$TMP_ROOT/base-changelog" Unreleased >"$TMP_ROOT/base-unreleased" +extract_labeled_section "$TMP_ROOT/head-changelog" Unreleased >"$TMP_ROOT/head-unreleased" +unreleased_changed=0 +if ! cmp -s "$TMP_ROOT/base-unreleased" "$TMP_ROOT/head-unreleased"; then + unreleased_changed=1 +fi + +awk ' + /^## / && substr($0, 1, 4) == "## [" { + rest = substr($0, 5) + close_pos = index(rest, "]") + if (close_pos > 1) { + label = substr(rest, 1, close_pos - 1) + if (label != "Unreleased") print label + } + } +' "$TMP_ROOT/base-changelog" "$TMP_ROOT/head-changelog" | + LC_ALL=C sort -u >"$TMP_ROOT/release-labels" + +changed_release_count=0 +section_index=0 +while IFS= read -r version; do + [ -n "$version" ] || continue + section_index=$((section_index + 1)) + base_section="$TMP_ROOT/base-release-$section_index" + head_section="$TMP_ROOT/head-release-$section_index" + extract_labeled_section "$TMP_ROOT/base-changelog" "$version" >"$base_section" + extract_labeled_section "$TMP_ROOT/head-changelog" "$version" >"$head_section" + if cmp -s "$base_section" "$head_section"; then + continue + fi + + changed_release_count=$((changed_release_count + 1)) + if ! release_channel_for_version "v$version" >/dev/null 2>&1; then + printf 'error: changed CHANGELOG release heading has an invalid version: %s\n' "$version" >&2 + exit 1 + fi + if [ "$(label_heading_count "$TMP_ROOT/head-changelog" "$version")" -ne 1 ]; then + printf 'error: CHANGELOG must contain exactly one well-formed section for %s\n' \ + "$version" >&2 + exit 1 + fi + if ! validate_release_notes "$head_section"; then + exit 1 + fi + release_date="$(release_heading_date "$TMP_ROOT/head-changelog" "$version")" + if ! calendar_date_is_valid "$release_date"; then + printf 'error: CHANGELOG section for %s has an invalid calendar date: %s\n' \ + "$version" "$release_date" >&2 + exit 1 + fi +done <"$TMP_ROOT/release-labels" + +if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then + printf '%s\n' 'error: CHANGELOG modification did not change an eligible notes section' >&2 + exit 1 +fi + +printf 'CHANGELOG PR check: ok (release_sections=%s unreleased_changed=%s)\n' \ + "$changed_release_count" "$unreleased_changed" diff --git a/scripts/release/release-lib.sh b/scripts/release/release-lib.sh index 6738deca..0523a131 100755 --- a/scripts/release/release-lib.sh +++ b/scripts/release/release-lib.sh @@ -130,8 +130,7 @@ release_extract_changelog() { print if ($0 ~ /[^[:space:]]/) meaningful = 1 if ($0 ~ /^- /) bullet = 1 - lowered = tolower($0) - if (lowered ~ /todo|tbd/) placeholder = 1 + if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1 } END { if (found != 1) exit 41 diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go new file mode 100644 index 00000000..9d3b56e1 --- /dev/null +++ b/test/scripts/changelog_pr_gate_test.go @@ -0,0 +1,376 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type changelogGateRepo struct { + root string + gate string + base string +} + +const changelogGateBase = `# Changelog + +## [Unreleased] + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +` + +func newChangelogGateRepo(t *testing.T) *changelogGateRepo { + t.Helper() + + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + root := t.TempDir() + + for _, path := range []string{ + "LICENSE", + "NOTICE", + "README.md", + "CONTRIBUTING.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + ".env.example", + ".github/workflows/ci.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + "docs/architecture.md", + "scripts/README.md", + "build/README.md", + } { + changelogGateWrite(t, root, path, "fixture\n", 0o644) + } + for _, path := range []string{ + "scripts/policy/check-changelog-pr.sh", + "scripts/policy/open-source-audit.sh", + "scripts/release/release-lib.sh", + } { + data, err := os.ReadFile(filepath.Join(sourceRoot, path)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + mode := os.FileMode(0o644) + if strings.HasSuffix(path, ".sh") { + mode = 0o755 + } + changelogGateWrite(t, root, path, string(data), mode) + } + changelogGateWrite(t, root, "CHANGELOG.md", changelogGateBase, 0o644) + + changelogGateGit(t, root, "init", "-b", "main") + changelogGateGit(t, root, "config", "user.name", "Changelog Gate Test") + changelogGateGit(t, root, "config", "user.email", "changelog-gate@example.com") + changelogGateGit(t, root, "add", ".") + changelogGateGit(t, root, "commit", "-m", "seed repository") + + return &changelogGateRepo{ + root: root, + gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"), + base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")), + } +} + +func changelogGateWrite(t *testing.T, root, path, content string, mode os.FileMode) { + t.Helper() + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(full), err) + } + if err := os.WriteFile(full, []byte(content), mode); err != nil { + t.Fatalf("WriteFile(%s) error = %v", full, err) + } +} + +func changelogGateGit(t *testing.T, root string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s error = %v\noutput:\n%s", strings.Join(args, " "), err, output) + } + return string(output) +} + +func (r *changelogGateRepo) commit(t *testing.T, message string) { + t.Helper() + changelogGateGit(t, r.root, "add", "-A") + changelogGateGit(t, r.root, "commit", "-m", message) +} + +func (r *changelogGateRepo) run(t *testing.T) (string, error) { + t.Helper() + cmd := exec.Command("sh", r.gate, r.base, "HEAD") + cmd.Dir = r.root + output, err := cmd.CombinedOutput() + return string(output), err +} + +func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) { + tests := []struct { + name string + changelog string + }{ + { + name: "new release section with lowercase todo product", + changelog: `# Changelog + +## [Unreleased] + +## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Improve the lowercase todo command family without leaving a placeholder. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, + }, + { + name: "unreleased note", + changelog: `# Changelog + +## [Unreleased] + +### Changed + +- Document the next release candidate. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644) + repo.commit(t, test.name) + + output, err := repo.run(t) + if err != nil { + t.Fatalf("gate error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "CHANGELOG PR check: ok") { + t.Fatalf("gate output missing success marker:\n%s", output) + } + }) + } +} + +func TestReleaseChangelogExtractionAllowsLowercaseTodoProductName(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + changelog := filepath.Join(t.TempDir(), "CHANGELOG.md") + changelogGateWrite(t, filepath.Dir(changelog), filepath.Base(changelog), `# Changelog + +## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Improve the lowercase todo command family. +`, 0o644) + + cmd := exec.Command( + "sh", + "-c", + `. "$1"; release_extract_changelog "$2" 1.0.1-beta.1 -`, + "sh", + filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"), + changelog, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("release_extract_changelog error = %v\noutput:\n%s", err, output) + } +} + +func TestChangelogPRFastPathWorkflowContract(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + workflows := map[string][]string{ + ".github/workflows/ci.yml": { + "name: Classify PR changes", + "name: Changelog Check", + `run: ./scripts/policy/check-changelog-pr.sh "$PR_BASE_SHA" "$PR_HEAD_SHA"`, + "expected_heavy_result=skipped", + "name: CI Gate", + }, + ".github/workflows/multi-profile-e2e.yml": { + "branches:", + "- main", + "Record changelog-only fast path", + "Full E2E: runs after merge", + }, + } + classifierContract := []string{ + "files.length === 1", + "files[0].filename === 'CHANGELOG.md'", + "files[0].status === 'modified'", + "!files[0].previous_filename", + } + + for path, required := range workflows { + data, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + workflow := string(data) + for _, want := range append(classifierContract, required...) { + if !strings.Contains(workflow, want) { + t.Errorf("%s missing fast-path contract %q", path, want) + } + } + if strings.Contains(workflow, "paths-ignore:") { + t.Errorf("%s must not skip the required workflow with paths-ignore", path) + } + } +} + +func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) { + validRelease := `# Changelog + +## [Unreleased] + +## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Valid release note. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +` + tests := []struct { + name string + changelog string + mutate func(*testing.T, *changelogGateRepo) + wantOutput string + }{ + { + name: "second changed file", + changelog: validRelease, + mutate: func(t *testing.T, repo *changelogGateRepo) { + changelogGateWrite(t, repo.root, "extra.txt", "extra\n", 0o644) + }, + wantOutput: "exactly one in-place modification", + }, + { + name: "invalid calendar date", + changelog: strings.Replace( + validRelease, + "2026-07-17", + "2026-02-30", + 1, + ), + wantOutput: "invalid calendar date", + }, + { + name: "duplicate release heading", + changelog: strings.Replace( + validRelease, + "## [1.0.0] - 2026-07-01", + "## [1.0.1-beta.1] - 2026-07-17\n\n- Duplicate.\n\n## [1.0.0] - 2026-07-01", + 1, + ), + wantOutput: "exactly one well-formed section", + }, + { + name: "missing bullet", + changelog: strings.Replace( + validRelease, + "- Valid release note.", + "Valid release note.", + 1, + ), + wantOutput: "at least one bullet", + }, + { + name: "placeholder", + changelog: strings.Replace( + validRelease, + "- Valid release note.", + "- TODO: write release notes.", + 1, + ), + wantOutput: "must not contain TODO/TBD", + }, + { + name: "malformed duplicate unreleased heading", + changelog: strings.Replace( + validRelease, + "## [Unreleased]", + "## [Unreleased]\n\n## [Unreleased] junk", + 1, + ), + wantOutput: "exactly one heading", + }, + { + name: "preamble change", + changelog: strings.Replace( + validRelease, + "# Changelog", + "# Release history", + 1, + ), + wantOutput: "only permits notes inside", + }, + { + name: "rename changelog", + changelog: changelogGateBase, + mutate: func(t *testing.T, repo *changelogGateRepo) { + if err := os.Rename( + filepath.Join(repo.root, "CHANGELOG.md"), + filepath.Join(repo.root, "CHANGES.md"), + ); err != nil { + t.Fatalf("Rename CHANGELOG.md error = %v", err) + } + }, + wantOutput: "exactly one in-place modification", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644) + if test.mutate != nil { + test.mutate(t, repo) + } + repo.commit(t, test.name) + + output, err := repo.run(t) + if err == nil { + t.Fatalf("unsafe change unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, test.wantOutput) { + t.Fatalf("gate output missing %q:\n%s", test.wantOutput, output) + } + }) + } +} From 25c70aeb24a5ed3b19d7b0b4837169f202a9c056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Sun, 19 Jul 2026 23:59:48 +0800 Subject: [PATCH 06/67] ci: align admission gates and changelog fast path --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ai-behavior-check.yml | 145 ++++--- .github/workflows/ci.yml | 525 ++++++++++++++---------- .github/workflows/multi-profile-e2e.yml | 44 +- .github/workflows/notify-wukong.yml | 38 ++ .github/workflows/release.yml | 98 ++++- docs/architecture.md | 35 ++ docs/ci-pr-gates.md | 249 ++++++----- docs/releasing.md | 8 +- scripts/policy/check-changelog-pr.sh | 103 ++++- scripts/release/release.sh | 40 +- test/scripts/changelog_pr_gate_test.go | 291 +++++++++++-- test/scripts/package_script_test.go | 90 +++- 13 files changed, 1150 insertions(+), 518 deletions(-) create mode 100644 .github/workflows/notify-wukong.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0b2dfad6..4c024e92 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,7 +11,7 @@ other pull request, mark the targeted check `N/A` and complete the applicable full-suite checks. - [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`): - `./scripts/policy/check-changelog-pr.sh "$(git merge-base HEAD origin/main)" HEAD` + `./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD` - [ ] `make build` - [ ] `make lint` - [ ] `make test` diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml index 7aea1df5..304879f3 100644 --- a/.github/workflows/ai-behavior-check.yml +++ b/.github/workflows/ai-behavior-check.yml @@ -1,8 +1,11 @@ -name: AI Behavior Check +name: Code Admission — AI Behavior on: pull_request_target: types: [opened, synchronize, reopened, labeled, unlabeled] + push: + branches: + - main permissions: contents: read @@ -11,7 +14,7 @@ permissions: jobs: ai-behavior-check: - name: AI Behavior Policy Evaluator + name: AI Behavior runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -21,75 +24,107 @@ jobs: uses: actions/github-script@v7 with: script: | - const sha = context.payload.pull_request.head.sha; + const pullRequest = context.payload.pull_request; + const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha; const setStatus = (state, description) => github.rest.repos.createCommitStatus({ owner: context.repo.owner, repo: context.repo.repo, sha, state, - context: 'AI Behavior Check', + context: 'AI Behavior', description, }); await setStatus('pending', 'Evaluating AI-generated PR boundaries'); - const labels = context.payload.pull_request.labels.map(({ name }) => name); - if (!labels.includes('ai-generated')) { - await setStatus('success', 'Not labeled ai-generated'); - core.notice('Not an ai-generated PR; no AI-only policy applied.'); + if (context.eventName === 'push') { + await setStatus('success', 'Not applicable to the protected main push'); + core.notice('AI Behavior is a PR policy; the main push context is sealed.'); return; } - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 100, - }); + try { + const expectedHead = pullRequest.head.sha; + const expectedBase = pullRequest.base.sha; + const currentPull = async (phase) => { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { + throw new Error( + `Pull request revision changed during ${phase}: ` + + `expected base/head ${expectedBase}/${expectedHead}, ` + + `got ${pull.base.sha}/${pull.head.sha}` + ); + } + return pull; + }; + + const before = await currentPull('pre-policy check'); + const labels = before.labels.map(({ name }) => name); + if (!labels.includes('ai-generated')) { + await setStatus('success', 'Not labeled ai-generated'); + core.notice('Not an ai-generated PR; no AI-only policy applied.'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + await currentPull('post-policy check'); + + const maxChangedFiles = 30; + if (files.length > maxChangedFiles) { + await setStatus( + 'failure', + `Changes ${files.length} files; limit is ${maxChangedFiles}` + ); + core.setFailed( + `AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.` + ); + return; + } + + const isProtectedPath = (filename) => + typeof filename === 'string' && + ( + filename.startsWith('.github/workflows/') || + filename.startsWith('scripts/policy/') || + filename.startsWith('scripts/release/') || + filename === 'test/fixtures/cli-interface-baseline.txt' || + filename === '.goreleaser.yaml' || + filename === 'Makefile' + ); + const protectedPaths = [...new Set( + files + .flatMap(({ filename, previous_filename }) => [filename, previous_filename]) + .filter(isProtectedPath) + )]; + + if (protectedPaths.length > 0) { + await setStatus('failure', 'Modifies protected release/CI infrastructure'); + core.setFailed( + 'AI-generated PR modifies protected release/CI infrastructure:\n' + + protectedPaths.map((filename) => ` - ${filename}`).join('\n') + + '\nSplit these changes into a human-owned PR with explicit review.' + ); + return; + } - const maxChangedFiles = 30; - if (files.length > maxChangedFiles) { await setStatus( - 'failure', - `Changes ${files.length} files; limit is ${maxChangedFiles}` + 'success', + `Passed with ${files.length} changed files (limit ${maxChangedFiles})` ); - core.setFailed( - `AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.` + core.notice( + `AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).` ); - return; + } catch (error) { + await setStatus('error', 'Could not evaluate the exact pull request revision'); + throw error; } - - const isProtectedPath = (filename) => - typeof filename === 'string' && - ( - filename.startsWith('.github/workflows/') || - filename.startsWith('scripts/policy/') || - filename.startsWith('scripts/release/') || - filename === 'test/fixtures/cli-interface-baseline.txt' || - filename === '.goreleaser.yaml' || - filename === 'Makefile' - ); - const protectedPaths = [...new Set( - files - .flatMap(({ filename, previous_filename }) => [filename, previous_filename]) - .filter(isProtectedPath) - )]; - - if (protectedPaths.length > 0) { - await setStatus('failure', 'Modifies protected release/CI infrastructure'); - core.setFailed( - 'AI-generated PR modifies protected release/CI infrastructure:\n' + - protectedPaths.map((filename) => ` - ${filename}`).join('\n') + - '\nSplit these changes into a human-owned PR with explicit review.' - ); - return; - } - - await setStatus( - 'success', - `Passed with ${files.length} changed files (limit ${maxChangedFiles})` - ); - core.notice( - `AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).` - ); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc37a956..1c47102d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Code Admission — PR 合入门禁 on: push: @@ -14,119 +14,140 @@ concurrency: cancel-in-progress: true jobs: - scope: - name: Classify PR changes + lint: + name: Lint runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read pull-requests: read outputs: changelog_only: ${{ steps.classify.outputs.changelog_only }} + changelog_changed: ${{ steps.classify.outputs.changelog_changed }} + platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }} steps: - - name: Classify exact CHANGELOG-only pull requests + - name: Classify pull request scope id: classify uses: actions/github-script@v7 with: script: | let changelogOnly = false; + let changelogChanged = false; + let platformSensitive = context.eventName === 'push'; let files = []; if (context.eventName === 'pull_request') { + const expectedHead = context.payload.pull_request.head.sha; + const expectedBase = context.payload.pull_request.base.sha; + const assertCurrentRevision = async (phase) => { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { + throw new Error( + `Pull request revision changed during ${phase}: ` + + `expected base/head ${expectedBase}/${expectedHead}, ` + + `got ${pull.base.sha}/${pull.head.sha}` + ); + } + return pull; + }; + + const before = await assertCurrentRevision('pre-classification'); files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, per_page: 100, }); + const after = await assertCurrentRevision('post-classification'); + if ( + before.changed_files !== files.length || + after.changed_files !== files.length + ) { + throw new Error( + `Pull request file list is incomplete: API reports ` + + `${after.changed_files} changed files, pagination returned ${files.length}` + ); + } + changelogOnly = files.length === 1 && files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; + changelogChanged = files.some( + ({ filename, previous_filename }) => + filename === 'CHANGELOG.md' || + previous_filename === 'CHANGELOG.md' + ); + + const isPlatformSensitive = (filename) => + typeof filename === 'string' && + ( + filename.startsWith('internal/auth/') || + filename.startsWith('internal/keychain/') || + /_(darwin|windows|linux|unix)\.go$/.test(filename) || + filename.startsWith('scripts/release/') || + filename.startsWith('scripts/install') || + filename.startsWith('Formula/') || + filename.startsWith('build/npm/') || + filename === '.goreleaser.yaml' || + filename === '.github/workflows/release.yml' + ); + platformSensitive = files.some( + ({ filename, previous_filename }) => + isPlatformSensitive(filename) || + isPlatformSensitive(previous_filename) + ); } core.setOutput('changelog_only', String(changelogOnly)); + core.setOutput('changelog_changed', String(changelogChanged)); + core.setOutput('platform_sensitive', String(platformSensitive)); await core.summary - .addHeading('PR change classification') + .addHeading('Code Admission scope') .addRaw(`- Event: \`${context.eventName}\`\n`) .addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`) + .addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`) + .addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`) .addRaw(`- Changed files: \`${files.length}\`\n`) .write(); - changelog-check: - name: Changelog Check - needs: scope - if: ${{ needs.scope.outputs.changelog_only == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: read - steps: - - name: Check out pull request head - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} + - name: Record CHANGELOG-only fast path + if: steps.classify.outputs.changelog_only == 'true' + run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY" - - name: Validate exact CHANGELOG change - env: - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: ./scripts/policy/check-changelog-pr.sh "$PR_BASE_SHA" "$PR_HEAD_SHA" - - lint: - name: Lint - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - name: Check out repository + if: steps.classify.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: steps.classify.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Format Check + if: steps.classify.outputs.changelog_only != 'true' run: | unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)" test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) - name: Go Vet + if: steps.classify.outputs.changelog_only != 'true' run: go vet ./... - # golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25 - # - name: golangci-lint - # uses: golangci/golangci-lint-action@v6 - # with: - # version: v1.64.8 - # args: ./... - - actionlint: - name: Actionlint - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} - 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 + if: steps.classify.outputs.changelog_only != 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 test-race: name: "Test (race: ${{ matrix.shard }})" - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -183,8 +204,8 @@ jobs: test-release-scripts: name: Test (release scripts) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -202,26 +223,85 @@ jobs: - name: Test release scripts run: go test -v -count=1 -timeout=5m ./test/scripts + test-cross-platform: + name: Test (cross-platform compile) + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} + 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: Compile supported operating systems + shell: bash + run: | + set -eu + for target in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + goos="${target%/*}" + goarch="${target#*/}" + output="$RUNNER_TEMP/dws-${goos}-${goarch}" + if [ "$goos" = windows ]; then + output="${output}.exe" + fi + printf 'compile %s/%s\n' "$goos" "$goarch" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ + go build -o "$output" ./cmd + done + test: name: Test needs: - - scope + - lint - test-race - test-release-scripts - if: ${{ always() && needs.scope.result == 'success' && needs.scope.outputs.changelog_only != 'true' }} + - test-cross-platform + - test-darwin + - test-windows + if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} steps: - name: Verify test shards env: + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} RACE_RESULT: ${{ needs.test-race.result }} RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }} + CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }} + DARWIN_RESULT: ${{ needs.test-darwin.result }} + WINDOWS_RESULT: ${{ needs.test-windows.result }} run: | failed=0 + if [ "$CHANGELOG_ONLY" = true ]; then + for shard in \ + "race shards:$RACE_RESULT" \ + "release scripts:$RELEASE_SCRIPTS_RESULT" \ + "cross-platform compile:$CROSS_PLATFORM_RESULT" \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${shard%%:*}" + result="${shard#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != skipped ]; then + failed=1 + fi + done + test "$failed" -eq 0 + exit + fi + for shard in \ "race shards:$RACE_RESULT" \ - "release scripts:$RELEASE_SCRIPTS_RESULT" + "release scripts:$RELEASE_SCRIPTS_RESULT" \ + "cross-platform compile:$CROSS_PLATFORM_RESULT" do name="${shard%%:*}" result="${shard#*:}" @@ -230,12 +310,28 @@ jobs: failed=1 fi done + + native_expected=skipped + if [ "$PLATFORM_SENSITIVE" = true ]; then + native_expected=success + fi + for native in \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${native%%:*}" + result="${native#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "$native_expected" ]; then + failed=1 + fi + done test "$failed" -eq 0 test-darwin: name: Test (macOS auth/keychain) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: macos-latest timeout-minutes: 15 steps: @@ -252,8 +348,8 @@ jobs: test-windows: name: Test (Windows) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -276,8 +372,8 @@ jobs: coverage-darwin: name: Coverage (macOS) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: macos-latest timeout-minutes: 20 steps: @@ -320,8 +416,8 @@ jobs: coverage-windows: name: Coverage (Windows) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} runs-on: windows-latest timeout-minutes: 20 steps: @@ -366,8 +462,8 @@ jobs: coverage-current: name: Coverage (current) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -401,8 +497,8 @@ jobs: coverage-supporting: name: Coverage (supporting) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -440,8 +536,8 @@ jobs: coverage-baseline: name: Coverage (baseline) - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint + if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -505,21 +601,35 @@ jobs: coverage: name: Coverage needs: - - scope + - lint - coverage-current - coverage-supporting - coverage-baseline - if: ${{ always() && needs.scope.result == 'success' && needs.scope.outputs.changelog_only != 'true' }} + - coverage-darwin + - coverage-windows + if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Verify coverage profile jobs env: + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} CURRENT_RESULT: ${{ needs.coverage-current.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} + DARWIN_RESULT: ${{ needs.coverage-darwin.result }} + WINDOWS_RESULT: ${{ needs.coverage-windows.result }} run: | failed=0 + expected=success + native_expected=skipped + if [ "$CHANGELOG_ONLY" = true ]; then + expected=skipped + elif [ "$PLATFORM_SENSITIVE" = true ]; then + native_expected=success + fi + for profile in \ "current:$CURRENT_RESULT" \ "supporting:$SUPPORTING_RESULT" \ @@ -528,24 +638,42 @@ jobs: name="${profile%%:*}" result="${profile#*:}" printf '%s: %s\n' "$name" "$result" - if [ "$result" != "success" ]; then + if [ "$result" != "$expected" ]; then + failed=1 + fi + done + + for native in \ + "macOS native:$DARWIN_RESULT" \ + "Windows native:$WINDOWS_RESULT" + do + name="${native%%:*}" + result="${native#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$CHANGELOG_ONLY" = true ]; then + native_expected=skipped + fi + if [ "$result" != "$native_expected" ]; then failed=1 fi done test "$failed" -eq 0 - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Resolve authoritative coverage base + if: needs.lint.outputs.changelog_only != 'true' env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -563,33 +691,39 @@ jobs: echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" - name: Download current coverage profile + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-current-profile path: . - name: Download supporting coverage profiles + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-supporting-profiles path: . - name: Download baseline coverage profile + if: needs.lint.outputs.changelog_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-baseline-profile path: . - name: Enforce coverage gate + if: needs.lint.outputs.changelog_only != 'true' env: COVERAGE_TARGET: "80" COVERAGE_ENFORCE_OVERALL: "false" run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" - name: Generate coverage report + if: needs.lint.outputs.changelog_only != 'true' run: go tool cover -html=coverage.txt -o coverage.html - name: Upload coverage artifact + if: needs.lint.outputs.changelog_only != 'true' uses: actions/upload-artifact@v4 with: name: coverage-report @@ -601,48 +735,114 @@ jobs: coverage.html policy: - name: Policy Check - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + name: Policy + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Verify pull request merge revision + if: github.event_name == 'pull_request' + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -eu + test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || { + echo "checked-out merge first parent does not match event base" >&2 + exit 1 + } + test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || { + echo "checked-out merge second parent does not match event head" >&2 + exit 1 + } + + - name: Validate changed CHANGELOG content + if: github.event_name == 'pull_request' + env: + CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }} + CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + merge_changelog_changed=false + if git diff --no-ext-diff --find-renames --name-status \ + "$PR_BASE_SHA" HEAD | + awk -F '\t' ' + { + for (field = 2; field <= NF; field++) { + if ($field == "CHANGELOG.md") found = 1 + } + } + END { exit !found } + ' + then + merge_changelog_changed=true + fi + test "$merge_changelog_changed" = "$CLASSIFIED_CHANGELOG_CHANGED" || { + echo "Files API and synthetic merge tree disagree on CHANGELOG scope" >&2 + exit 1 + } + if [ "$merge_changelog_changed" != true ]; then + exit 0 + fi + + mode=--content-only + if [ "$CHANGELOG_ONLY" = true ]; then + mode=--fast-path + fi + ./scripts/policy/check-changelog-pr.sh \ + "$mode" "$PR_BASE_SHA" HEAD + + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: | + echo "Only the base-equivalent CHANGELOG validator ran; full Policy resumes on main." \ + >> "$GITHUB_STEP_SUMMARY" + - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Policy + if: needs.lint.outputs.changelog_only != 'true' run: make policy interface-integrity: name: Interface Integrity - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Resolve authoritative compatibility merge-base + if: needs.lint.outputs.changelog_only != 'true' env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -667,6 +867,7 @@ jobs: echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV" - name: Check historical commands and help compatibility + if: needs.lint.outputs.changelog_only != 'true' run: | make authoritative-interface-integrity \ BASE_REF="$COMPATIBILITY_BASE_REF" @@ -676,181 +877,89 @@ jobs: fi - name: Check complete Schema compatibility + if: needs.lint.outputs.changelog_only != 'true' run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF" - name: Check skill command references + if: needs.lint.outputs.changelog_only != 'true' run: make skill-command-integrity + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + cli-smoke: name: CLI Smoke - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build + if: needs.lint.outputs.changelog_only != 'true' run: make build - name: Check public top-level commands + if: needs.lint.outputs.changelog_only != 'true' run: make cli-smoke + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + mock-mcp-smoke: - name: Mock MCP Smoke - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + name: Mock MCP + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Check HTTP and stdio MCP transport + if: needs.lint.outputs.changelog_only != 'true' run: make mock-mcp-smoke + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" + edition-tests: - name: Edition Contract Tests - needs: scope - if: ${{ needs.scope.outputs.changelog_only != 'true' }} + name: Edition + needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository + if: needs.lint.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go + if: needs.lint.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Run edition contract tests + if: needs.lint.outputs.changelog_only != 'true' run: go test -v -count=1 ./pkg/editiontest/... - ci-gate: - name: CI Gate - needs: - - scope - - changelog-check - - lint - - actionlint - - test - - test-darwin - - test-windows - - coverage - - coverage-darwin - - coverage-windows - - policy - - interface-integrity - - cli-smoke - - mock-mcp-smoke - - edition-tests - if: ${{ always() }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: {} - steps: - - name: Verify required checks - env: - SCOPE_RESULT: ${{ needs.scope.result }} - CHANGELOG_ONLY: ${{ needs.scope.outputs.changelog_only }} - CHANGELOG_RESULT: ${{ needs.changelog-check.result }} - 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 }} - COVERAGE_RESULT: ${{ needs.coverage.result }} - COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }} - COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }} - POLICY_RESULT: ${{ needs.policy.result }} - INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }} - CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }} - MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }} - EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} - run: | - failed=0 - if [ "$SCOPE_RESULT" != "success" ]; then - printf 'Classify PR changes: %s\n' "$SCOPE_RESULT" - failed=1 - fi - - case "$CHANGELOG_ONLY" in - true) - expected_heavy_result=skipped - expected_changelog_result=success - ;; - false) - expected_heavy_result=success - expected_changelog_result=skipped - ;; - *) - printf 'invalid changelog_only classification: %s\n' "$CHANGELOG_ONLY" - failed=1 - expected_heavy_result=invalid - expected_changelog_result=invalid - ;; - esac - - printf 'Changelog Check: %s\n' "$CHANGELOG_RESULT" - if [ "$CHANGELOG_RESULT" != "$expected_changelog_result" ]; then - failed=1 - fi - - for check in \ - "Lint:$LINT_RESULT" \ - "Actionlint:$ACTIONLINT_RESULT" \ - "Test:$TEST_RESULT" \ - "Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \ - "Test (Windows):$TEST_WINDOWS_RESULT" \ - "Coverage:$COVERAGE_RESULT" \ - "Coverage (macOS):$COVERAGE_DARWIN_RESULT" \ - "Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \ - "Policy Check:$POLICY_RESULT" \ - "Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \ - "CLI Smoke:$CLI_SMOKE_RESULT" \ - "Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \ - "Edition Contract Tests:$EDITION_TESTS_RESULT" - do - name="${check%%:*}" - result="${check#*:}" - printf '%s: %s\n' "$name" "$result" - if [ "$result" != "$expected_heavy_result" ]; then - failed=1 - fi - done - test "$failed" -eq 0 - - notify-downstream: - name: Notify Wukong Overlay - needs: [ci-gate] - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - permissions: {} - steps: - - name: Trigger downstream CI - run: | - # Trigger internal GitLab CI pipeline via webhook. - # WUKONG_TRIGGER_TOKEN is a repository secret. - if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then - curl --fail --silent --show-error \ - -X POST \ - -F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \ - -F "ref=main" \ - -F "variables[UPSTREAM_SHA]=${{ github.sha }}" \ - "${{ secrets.WUKONG_TRIGGER_URL }}" - echo "Downstream CI triggered." - else - echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." - fi + - name: Record CHANGELOG-only fast path + if: needs.lint.outputs.changelog_only == 'true' + run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml index 11a02a96..6695d4a1 100644 --- a/.github/workflows/multi-profile-e2e.yml +++ b/.github/workflows/multi-profile-e2e.yml @@ -1,7 +1,6 @@ -name: Multi Profile E2E +name: Main Integration — 主干集成 on: - pull_request: push: branches: - main @@ -9,7 +8,6 @@ on: permissions: contents: read - pull-requests: read concurrency: group: multi-profile-e2e-${{ github.ref }} @@ -17,70 +15,36 @@ concurrency: jobs: multi-profile-e2e: - name: Multi Profile E2E + name: Multi-profile E2E runs-on: ubuntu-latest timeout-minutes: 15 env: MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log steps: - - name: Classify pull request changes - id: changes - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 100, - }); - const changelogOnly = - files.length === 1 && - files[0].filename === 'CHANGELOG.md' && - files[0].status === 'modified' && - !files[0].previous_filename; - - core.setOutput('changelog_only', String(changelogOnly)); - - - name: Record changelog-only fast path - if: github.event_name == 'pull_request' && steps.changes.outputs.changelog_only == 'true' - shell: bash - run: | - { - echo "### Multi Profile E2E" - echo "- Scope: exact in-place modification of \`CHANGELOG.md\`" - echo "- Result: skipped by the changelog-only pull request fast path" - echo "- Full E2E: runs after merge on the \`main\` push" - } >> "$GITHUB_STEP_SUMMARY" - - name: Check out repository - if: steps.changes.outputs.changelog_only != 'true' uses: actions/checkout@v4 - name: Set up Go - if: steps.changes.outputs.changelog_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Run isolated multi-profile chain - if: steps.changes.outputs.changelog_only != 'true' shell: bash run: | set -o pipefail mkdir -p .tmp-bin bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG" { - echo "### Multi Profile E2E" + echo "### Multi-profile E2E" echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`" echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration" echo "- Result: passed" } >> "$GITHUB_STEP_SUMMARY" - name: Upload debug artifacts - if: failure() && steps.changes.outputs.changelog_only != 'true' + if: failure() uses: actions/upload-artifact@v4 with: name: multi-profile-e2e-debug diff --git a/.github/workflows/notify-wukong.yml b/.github/workflows/notify-wukong.yml new file mode 100644 index 00000000..3b04d509 --- /dev/null +++ b/.github/workflows/notify-wukong.yml @@ -0,0 +1,38 @@ +name: Main Integration — Wukong Overlay + +on: + workflow_run: + workflows: + - Code Admission — PR 合入门禁 + types: + - completed + +permissions: {} + +jobs: + notify-downstream: + name: Notify Wukong Overlay + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Trigger downstream CI + env: + UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }} + WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }} + WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }} + run: | + if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then + curl --fail --silent --show-error \ + -X POST \ + -F "token=$WUKONG_TRIGGER_TOKEN" \ + -F "ref=main" \ + -F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \ + "$WUKONG_TRIGGER_URL" + echo "Downstream CI triggered." + else + echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2912a36..73f1c569 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,26 +235,56 @@ jobs: exit 1 } - - name: Require successful CI Gate on the preflight commit + - name: Require successful Code Admission contexts on the preflight commit uses: actions/github-script@v7 env: PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} with: script: | const { owner, repo } = context.repo; - const { data } = await github.rest.checks.listForRef({ + const sha = process.env.PREFLIGHT_COMMIT; + const requiredContexts = [ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "AI Behavior", + "CLI Smoke", + "Mock MCP", + ]; + const runs = await github.paginate(github.rest.checks.listForRef, { owner, repo, - ref: process.env.PREFLIGHT_COMMIT, - check_name: "CI Gate", + ref: sha, filter: "latest", per_page: 100, }); - const passed = data.check_runs.some( - (run) => run.name === "CI Gate" && run.conclusion === "success", - ); - if (!passed) { - core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`); + const latestByName = new Map(); + for (const run of runs) { + if (run.head_sha !== sha || !requiredContexts.includes(run.name)) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const missing = requiredContexts.filter((name) => !latestByName.has(name)); + const nonSuccess = requiredContexts.flatMap((name) => { + const run = latestByName.get(name); + if (!run || run.conclusion === "success") { + return []; + } + return [`${name}=${run.conclusion || run.status || "unknown"}`]; + }); + if (missing.length > 0 || nonSuccess.length > 0) { + core.setFailed( + `Code Admission contexts are not all successful for ${sha}; ` + + `missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` + + `non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`, + ); } - name: Check out trusted preflight tooling @@ -502,26 +532,56 @@ jobs: "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" - - name: Require successful CI Gate on the sealed commit + - name: Require successful Code Admission contexts on the sealed commit uses: actions/github-script@v7 env: RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }} with: script: | const { owner, repo } = context.repo; - const { data } = await github.rest.checks.listForRef({ + const sha = process.env.RELEASE_COMMIT; + const requiredContexts = [ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "AI Behavior", + "CLI Smoke", + "Mock MCP", + ]; + const runs = await github.paginate(github.rest.checks.listForRef, { owner, repo, - ref: process.env.RELEASE_COMMIT, - check_name: "CI Gate", + ref: sha, filter: "latest", per_page: 100, }); - const passed = data.check_runs.some( - (run) => run.name === "CI Gate" && run.conclusion === "success", - ); - if (!passed) { - core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`); + const latestByName = new Map(); + for (const run of runs) { + if (run.head_sha !== sha || !requiredContexts.includes(run.name)) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const missing = requiredContexts.filter((name) => !latestByName.has(name)); + const nonSuccess = requiredContexts.flatMap((name) => { + const run = latestByName.get(name); + if (!run || run.conclusion === "success") { + return []; + } + return [`${name}=${run.conclusion || run.status || "unknown"}`]; + }); + if (missing.length > 0 || nonSuccess.length > 0) { + core.setFailed( + `Code Admission contexts are not all successful for ${sha}; ` + + `missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` + + `non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`, + ); } - name: Require delivered previous stable baseline @@ -721,7 +781,7 @@ jobs: - name: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Multi Profile E2E + - name: Multi-profile E2E run: bash scripts/dev/test-multi-profile-e2e.sh - name: Install rcodesign (sign darwin binaries from Linux) diff --git a/docs/architecture.md b/docs/architecture.md index 30dff954..3dc70019 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,3 +43,38 @@ - `skills/`: bundled agent skills (mono/ and multi/ layouts) - `test/`: CLI, integration, contract, unit, and skill E2E tests - `scripts/`: install scripts, policy checks, and CI helpers + +## Quality Pipeline + +Quality enforcement is layered so a pull request receives fast, deterministic +admission feedback without pretending that downstream integration has already +run. + +```mermaid +flowchart TB + PR["Pull request"] --> CA["Code Admission — PR 合入门禁"] + subgraph CA_CHECKS["Nine required contexts"] + L["Lint"] + T["Test"] + C["Coverage"] + P["Policy"] + E["Edition"] + I["Interface Integrity"] + A["AI Behavior"] + S["CLI Smoke"] + M["Mock MCP"] + end + CA --> CA_CHECKS + CA_CHECKS --> MAIN["Protected main"] + MAIN --> MP["Main Integration — 主干集成
Multi-profile E2E"] + MAIN --> PLATFORM["Risk-selected / release native platform validation"] + MP --> RELEASE["Release delivery"] + PLATFORM --> RELEASE +``` + +Complete Multi-profile E2E and the ordinary full native-platform matrix are +downstream of PR admission. PRs still run primary-environment assurance and +fast cross-platform compilation; auth, keychain, OS-specific, installer, and +release changes additionally select native platform tests before merge. See +[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset +contract. diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 87ad3e8e..83aa5de7 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -1,163 +1,152 @@ -# Pull request quality gates +# Code Admission — PR 合入门禁 -The repository defines five focused checks in addition to its existing CI: +The pull-request admission layer has exactly nine required external contexts: -- **Interface Integrity** enforces backwards compatibility. Every historical - command path and alias must still resolve, every historical command must - still render `-h`, and historical flags must keep their type and shorthand. - New commands, aliases, and flags are allowed. The same job compares the full - complete `dws schema --all` contract with the PR merge-base, blocking removed - products/tools/parameters, incompatible parameter or interface mappings, - constraint drift, and safety-semantic drift. It also checks that executable - `dws ...` references in `skills/**/*.md` resolve to real commands. - Help compatibility covers command/alias/flag spelling, flag type and - shorthand; descriptive prose may evolve without breaking the gate. -- **Coverage** runs unit tests on every pull request and prints both overall and - changed-code statement coverage. During the migration to the 80% repository - target, overall coverage may not regress from a profile generated from the - merge-base with the same test command, while changed production Go - statements must meet 80%. Linux, Windows, and macOS each generate a native - coverage profile for changed packages and enforce the threshold against - changed files buildable on that platform, so build-tagged source cannot be - hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement - variance to avoid failing unchanged code on test-path noise. Set - `COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make - the overall target fail closed as well. - CI generates the candidate, supporting, and merge-base profiles on three - independent runners, then downloads all profiles into the aggregate - `Coverage` job and applies the same fail-closed gate. The split changes only - scheduling: the tested packages, profile contents, merge-base comparison, - and final coverage thresholds remain unchanged. -- **CLI Smoke** builds the release binary, reads the root command list from the - structured Interface contract, and renders offline help for every public - top-level command. It rejects Cobra's unknown-command root-help fallback and - fails when the checked-in development fixture is stale. -- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests - (`Initialize -> ListTools -> CallTool`). -- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It - limits the change to 30 files and blocks release/CI infrastructure changes, - including policy implementations and the checked-in Interface fixture. - It uses `pull_request_target` without checking out PR code, so the policy - cannot be bypassed by changing the workflow in the same pull request. The - evaluator writes an `AI Behavior Check` commit status to the PR head SHA so - GitHub rulesets can require it. +| Required context | Contract | +|---|---| +| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint | +| `Test` | Race/unit/release-script tests plus fast cross-platform compilation | +| `Coverage` | Overall non-regression and changed-code coverage | +| `Policy` | Repository policy and the fail-closed CHANGELOG contract | +| `Edition` | Edition contract tests | +| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility | +| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` | +| `CLI Smoke` | Offline help for every public top-level command | +| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests | + +The workflow display name is `Code Admission — PR 合入门禁`. Parallel helper +jobs may implement `Test` and `Coverage`, but they are not ruleset contexts. +Do not require an aggregate alias or a downstream integration check in place of +the nine contracts above. + +`AI Behavior` is evaluated by a `pull_request_target` workflow that never +checks out or executes PR code. It writes the exact `AI Behavior` status to the +current PR head. Its Files API read is bracketed by base/head revision checks, +so a synchronize race fails closed. The same workflow supplies a successful +`AI Behavior` check run on protected `main` pushes for release governance. ## Exact CHANGELOG-only fast path -A pull request qualifies for the fast path only when GitHub reports exactly -one changed file and that file is an in-place modification of -`CHANGELOG.md`. Adding, deleting, or renaming the file does not qualify, and -neither does changing any second file. +A pull request qualifies only when GitHub reports exactly one changed file, +that file is an in-place modification of `CHANGELOG.md`, and the base and head +both retain it as a regular non-executable `100644` blob. Add, delete, rename, +symlink, executable-mode, and second-file changes do not qualify. -The workflow classifies the pull request through GitHub's pull-request files -API before checking out or executing pull-request code. Qualifying changes run -the targeted CHANGELOG contract check. Because classification rejects any -second file, the validator itself is the reviewed version from the base: +`Lint` classifies the Files API result only after verifying that the API's base +and head equal the event revision both before and after pagination. `Policy` +checks out GitHub's PR merge ref and verifies its parents: -```sh -base_ref=$(git merge-base HEAD origin/main) -./scripts/policy/check-changelog-pr.sh "$base_ref" HEAD +```text +HEAD^1 = pull_request.base.sha +HEAD^2 = pull_request.head.sha ``` -The `CI Gate` remains present and succeeds only after that targeted check -passes. `Multi Profile E2E` also remains present as a required successful job, -but records the fast-path decision in its job summary instead of checking out -the repository, setting up Go, or running the E2E chain. The workflows use -job/step conditions rather than workflow-level `paths-ignore`, so required -checks are never left pending. +It then runs: -All other pull requests run the normal full gates. After a qualifying pull -request is merged, the `main` push runs the complete CI and Multi Profile E2E -again; the fast path never applies to a push. Multi Profile E2E listens only -for pushes to `main`, avoiding a duplicate branch-push run for same-repository -pull requests. +```sh +./scripts/policy/check-changelog-pr.sh \ + --fast-path "$PR_BASE_SHA" HEAD +``` -## Running the compatibility gates +Because the verified PR diff contains only `CHANGELOG.md`, the validator and +its policy dependencies in that merge tree are byte-for-byte the current base +versions. Validation targets the synthetic merge tree, not the feature-branch +tree, so a stale branch cannot supply an older validator or combine with newer +base notes into an invalid final CHANGELOG. -Run: +All nine admission contexts are still emitted and must succeed. Expensive +implementation helpers are skipped; the named contexts record that their code +surface is unaffected. After merge, the protected `main` push executes the +full admission suite. + +Any PR that touches `CHANGELOG.md` but also changes another file runs the same +content contract in `Policy` with `--content-only`. That mode permits the +second file but still rejects invalid dates or versions, missing bullets, +placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes. +Adding a second file therefore cannot bypass CHANGELOG validation. + +## Platform and downstream boundaries + +Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile +checks. Full native macOS/Windows tests and platform coverage run on a PR only +when its diff touches auth, keychain, OS-specific Go files, installers, +packaging, Formulae, or release automation. Protected `main` pushes run the +complete native matrix. + +Complete `Multi-profile E2E` is not a PR admission context. It belongs to the +`Main Integration — 主干集成` workflow and runs only after a push to `main` (or +an explicit manual dispatch). A failing downstream run remains a real +regression and must be repaired, but it must not be represented by a synthetic +successful PR check. + +```mermaid +flowchart TB + PR["Pull request"] --> ADMISSION["Code Admission — PR 合入门禁"] + ADMISSION --> L["Lint"] + ADMISSION --> T["Test"] + ADMISSION --> C["Coverage"] + ADMISSION --> P["Policy"] + ADMISSION --> E["Edition"] + ADMISSION --> I["Interface Integrity"] + ADMISSION --> A["AI Behavior"] + ADMISSION --> S["CLI Smoke"] + ADMISSION --> M["Mock MCP"] + ADMISSION --> MAIN["Protected main"] + MAIN --> NATIVE["Full native platform matrix"] + MAIN --> E2E["Multi-profile E2E"] + MAIN --> RELEASE["Release delivery"] +``` + +## Running focused gates locally + +Run the contracts relevant to the change: ```sh make build +make policy make interface-integrity make authoritative-interface-integrity BASE_REF= make schema-compatibility BASE_REF= make skill-command-integrity make cli-smoke -# Run on the corresponding native runner with its generated profile: -make coverage-gate-platform BASE_REF= PROFILE= +make mock-mcp-smoke +go test -v -count=1 ./pkg/editiontest/... ``` -`make coverage-gate` is the enforcement step, not a profile generator. It -expects the candidate, policy, shortcut, and merge-base profiles -(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and -`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local -checkout can reproduce the Linux/overall CI gate sequentially with: +For an exact CHANGELOG-only branch: ```sh base_ref=$(git merge-base HEAD origin/main) -root=$(pwd) -base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX") -rmdir "$base_worktree" -cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; } -trap cleanup EXIT HUP INT TERM - -go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... -go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \ - ./pkg/... ./scripts/policy/... -go test -count=1 \ - -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ - -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ - -coverprofile=coverage-shortcut.txt \ - -covermode=atomic \ - ./internal/app ./internal/helpers ./internal/shortcut/... -git worktree add --detach "$base_worktree" "$base_ref" -( - cd "$base_worktree" - go test -count=1 -p 1 \ - -coverprofile="$root/coverage-base.txt" -covermode=atomic \ - ./ ./cmd/... ./internal/... ./skills/... -) -COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \ - make coverage-gate BASE_REF="$base_ref" +./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD ``` -The native-platform target likewise expects `PROFILE` to have already been -generated on that operating system. CI owns those generation steps; copying -only either enforcement command into a clean checkout is intentionally an -incomplete invocation. +`make coverage-gate` is an enforcement step, not a profile generator. CI +generates the candidate, supporting, merge-base, and (when risk-selected) +native profiles before the aggregate `Coverage` context evaluates them. -CI derives the authoritative Interface snapshots from both the PR merge-base -and the latest reachable stable release tag. The complete Schema snapshot comes -from the PR merge-base, which contains the registry-first Schema introduced on -`main`. The candidate branch cannot bless a breaking change by editing a -fixture. Schema additions are allowed; historical products, tools, parameters, -parameter mappings, positional execution fields, constraints, and safety -semantics remain protected. Positional descriptions are documentation and may -change without breaking compatibility. - -`make update-interface-baseline` still extends the local checked-in Interface -fixture used by `make interface-integrity`. Updates are monotonic: they add new -commands and flags without removing history. - -For an intentional compatibility reset at a major-version boundary, run -`make reset-interface-baseline`. This replaces all CLI compatibility history -with the current command tree and must receive explicit human review. +Compatibility checks derive authoritative Interface snapshots from the PR +merge-base and the latest reachable stable release. The candidate cannot bless +a breaking change by editing a fixture. Schema additions are allowed; +historical products, tools, parameters, mappings, positional execution fields, +constraints, and safety semantics remain protected. ## Required GitHub repository settings -Create a ruleset for `main` that requires pull requests and code-owner review, -then mark these aggregate status checks as required: +The `main` quality ruleset must enable strict required-status-check policy +(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever +`main` advances. It must require these exact contexts and no legacy aliases: -- `CI Gate` -- `Multi Profile E2E` -- `AI Behavior Check` +- `Lint` +- `Test` +- `Coverage` +- `Policy` +- `Edition` +- `Interface Integrity` +- `AI Behavior` +- `CLI Smoke` +- `Mock MCP` -`CI Gate` fails closed unless every first-layer CI job succeeds, including -lint, tests, native Linux/Windows/macOS coverage, policy, -Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate -check keeps repository rules stable when an internal job is renamed or split. - -The `ai-generated` label must be applied by the PR-creation automation or by a -maintainer; GitHub cannot infer reliably whether a human-authored PR contains -AI-generated code. +Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission +alias. Update ruleset contexts only after the new names have appeared on the +protected branch, so a rename cannot silently remove enforcement or leave an +unproducible required context. diff --git a/docs/releasing.md b/docs/releasing.md index 5a4b4125..fdee0cac 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,7 +2,7 @@ 发布只走一条链路:本地脚本负责封板、验证并推送 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 仍需仓库管理员预先配置并由操作人确认。 +发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `Code Admission — PR 合入门禁` 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 ## 日常只用一个入口 @@ -45,7 +45,7 @@ dws-release v1.2.3-beta.1 dws-release v1.2.3-beta.1 ``` -预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`: +预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`: ```bash dws-release v1.2.3-beta.1 --publish @@ -125,10 +125,10 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac 仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则: -- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。 +- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。 - 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。 - 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。 - 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。 - 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。 -immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 +immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/policy/check-changelog-pr.sh b/scripts/policy/check-changelog-pr.sh index 8c956469..b31af5cf 100755 --- a/scripts/policy/check-changelog-pr.sh +++ b/scripts/policy/check-changelog-pr.sh @@ -1,23 +1,37 @@ #!/bin/sh set -eu -# Validate the narrow PR fast path. The diff must be exactly one in-place -# CHANGELOG.md modification, and every touched release section must still -# satisfy the release contract. +# Validate CHANGELOG.md changes against the release contract. +# +# --fast-path additionally requires the complete PR diff to be exactly one +# in-place CHANGELOG.md modification. --content-only validates CHANGELOG.md +# while allowing other files to change in the same PR. ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" usage() { - printf '%s\n' "usage: $0 BASE HEAD" >&2 + printf '%s\n' "usage: $0 (--fast-path|--content-only) BASE HEAD" >&2 } -if [ "$#" -ne 2 ]; then +if [ "$#" -ne 3 ]; then usage exit 2 fi -BASE_REF="$1" -HEAD_REF="$2" +case "$1" in +--fast-path) + VALIDATION_MODE="fast-path" + ;; +--content-only) + VALIDATION_MODE="content-only" + ;; +*) + usage + exit 2 + ;; +esac +BASE_REF="$2" +HEAD_REF="$3" cd "$ROOT" @@ -44,18 +58,69 @@ if [ "$merge_base_count" -ne 1 ]; then fi IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases" +require_regular_changelog() { + _rrc_label="$1" + _rrc_commit="$2" + if ! git ls-tree "$_rrc_commit" -- CHANGELOG.md | + awk ' + NR == 1 && + $1 == "100644" && + $2 == "blob" && + $4 == "CHANGELOG.md" { + valid = 1 + } + END { + exit !(NR == 1 && valid) + } + '; then + printf 'error: CHANGELOG.md must be a regular 100644 blob at %s\n' \ + "$_rrc_label" >&2 + exit 1 + fi +} + +# The merge base is the effective base of the PR diff. Also validate the +# caller-provided base commit so a stale or malformed target ref fails closed. +require_regular_changelog "base" "$BASE_COMMIT" +if [ "$MERGE_BASE" != "$BASE_COMMIT" ]; then + require_regular_changelog "merge base" "$MERGE_BASE" +fi +require_regular_changelog "head" "$HEAD_COMMIT" + git diff --no-ext-diff --find-renames --name-status \ "$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status" -printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status" -if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then - printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2 - if [ -s "$TMP_ROOT/name-status" ]; then - sed 's/^/ /' "$TMP_ROOT/name-status" >&2 - else - printf '%s\n' ' (no changed files)' >&2 +case "$VALIDATION_MODE" in +fast-path) + printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status" + if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2 + if [ -s "$TMP_ROOT/name-status" ]; then + sed 's/^/ /' "$TMP_ROOT/name-status" >&2 + else + printf '%s\n' ' (no changed files)' >&2 + fi + exit 1 fi - exit 1 -fi + ;; +content-only) + if ! awk -F ' ' ' + $1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { + count++ + } + END { + exit count != 1 + } + ' "$TMP_ROOT/name-status"; then + printf '%s\n' 'error: content-only validation requires an in-place modification: CHANGELOG.md' >&2 + if [ -s "$TMP_ROOT/name-status" ]; then + sed 's/^/ /' "$TMP_ROOT/name-status" >&2 + else + printf '%s\n' ' (no changed files)' >&2 + fi + exit 1 + fi + ;; +esac if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2 @@ -200,7 +265,7 @@ validate_release_notes() { extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged" extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged" if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then - printf '%s\n' 'error: fast path only permits notes inside Unreleased or versioned release sections' >&2 + printf '%s\n' 'error: CHANGELOG validation only permits notes inside Unreleased or versioned release sections' >&2 exit 1 fi @@ -270,5 +335,5 @@ if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then exit 1 fi -printf 'CHANGELOG PR check: ok (release_sections=%s unreleased_changed=%s)\n' \ - "$changed_release_count" "$unreleased_changed" +printf 'CHANGELOG PR check: ok (mode=%s release_sections=%s unreleased_changed=%s)\n' \ + "$VALIDATION_MODE" "$changed_release_count" "$unreleased_changed" diff --git a/scripts/release/release.sh b/scripts/release/release.sh index b664f98b..db88045a 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -134,16 +134,44 @@ require_github_publication_authority() { } sealed_commit="$(git rev-parse HEAD)" - passed_gate="$( + admission_check_runs="$( 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' + "repos/$github_repository/commits/$sealed_commit/check-runs?filter=latest&per_page=100" \ + --jq '.check_runs | group_by(.name) | map(max_by(.id)) | .[] | [.name, (.conclusion // .status // "unknown")] | @tsv' )" || { - printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2 + printf 'could not query Code Admission contexts 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 + + missing_contexts="" + non_success_contexts="" + while IFS= read -r required_context; do + context_state="$( + printf '%s\n' "$admission_check_runs" | + awk -F '\t' -v required="$required_context" '$1 == required { state = $2 } END { print state }' + )" + if [ -z "$context_state" ]; then + missing_contexts="${missing_contexts}${missing_contexts:+, }$required_context" + elif [ "$context_state" != "success" ]; then + non_success_contexts="${non_success_contexts}${non_success_contexts:+, }$required_context=$context_state" + fi + done <<'ADMISSION_CONTEXTS' +Lint +Test +Coverage +Policy +Edition +Interface Integrity +AI Behavior +CLI Smoke +Mock MCP +ADMISSION_CONTEXTS + [ -z "$missing_contexts" ] && [ -z "$non_success_contexts" ] || { + printf 'Code Admission contexts are not all successful for sealed commit %s in %s; missing: %s; non-success: %s\n' \ + "$sealed_commit" \ + "$github_repository" \ + "${missing_contexts:-none}" \ + "${non_success_contexts:-none}" >&2 return 1 } diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 9d3b56e1..b411804d 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -25,6 +25,23 @@ const changelogGateBase = `# Changelog - Initial release. ` +const changelogGateValidRelease = `# Changelog + +## [Unreleased] + +## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Valid release note. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +` + func newChangelogGateRepo(t *testing.T) *changelogGateRepo { t.Helper() @@ -110,7 +127,17 @@ func (r *changelogGateRepo) commit(t *testing.T, message string) { func (r *changelogGateRepo) run(t *testing.T) (string, error) { t.Helper() - cmd := exec.Command("sh", r.gate, r.base, "HEAD") + return r.runMode(t, "--fast-path") +} + +func (r *changelogGateRepo) runMode(t *testing.T, mode string) (string, error) { + t.Helper() + return r.runRefs(t, mode, r.base, "HEAD") +} + +func (r *changelogGateRepo) runRefs(t *testing.T, mode, base, head string) (string, error) { + t.Helper() + cmd := exec.Command("sh", r.gate, mode, base, head) cmd.Dir = r.root output, err := cmd.CombinedOutput() return string(output), err @@ -176,6 +203,158 @@ func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) { } } +func TestChangelogPRContentOnlyAllowsOtherFiles(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644) + changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644) + repo.commit(t, "change code with release notes") + + output, err := repo.runMode(t, "--content-only") + if err != nil { + t.Fatalf("content-only gate error = %v\noutput:\n%s", err, output) + } + if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") { + t.Fatalf("content-only gate output missing success marker:\n%s", output) + } +} + +func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T) { + tests := []struct { + name string + changelog string + wantOutput string + }{ + { + name: "invalid calendar date", + changelog: strings.Replace( + changelogGateValidRelease, + "2026-07-17", + "2026-02-30", + 1, + ), + wantOutput: "invalid calendar date", + }, + { + name: "placeholder", + changelog: strings.Replace( + changelogGateValidRelease, + "- Valid release note.", + "- TODO: write release notes.", + 1, + ), + wantOutput: "must not contain TODO/TBD", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644) + changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644) + repo.commit(t, test.name) + + output, err := repo.runMode(t, "--content-only") + if err == nil { + t.Fatalf("unsafe content-only change unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, test.wantOutput) { + t.Fatalf("content-only gate output missing %q:\n%s", test.wantOutput, output) + } + }) + } +} + +func TestChangelogPRGateValidatesSyntheticMergeTree(t *testing.T) { + repo := newChangelogGateRepo(t) + commonChangelog := `# Changelog + +## [Unreleased] + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. + +## [0.9.0] - 2026-06-01 + +### Added + +- Earlier release. +` + changelogGateWrite(t, repo.root, "CHANGELOG.md", commonChangelog, 0o644) + repo.commit(t, "expand changelog history") + common := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + repo.base = common + + changelogGateGit(t, repo.root, "switch", "-c", "feature") + featureChangelog := strings.Replace( + commonChangelog, + "## [1.0.0] - 2026-07-01", + `## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Feature branch release note. + +## [1.0.0] - 2026-07-01`, + 1, + ) + changelogGateWrite(t, repo.root, "CHANGELOG.md", featureChangelog, 0o644) + repo.commit(t, "add feature release note") + featureHead := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + if output, err := repo.runRefs(t, "--fast-path", common, featureHead); err != nil { + t.Fatalf("feature head should be valid before merging: %v\n%s", err, output) + } + + changelogGateGit(t, repo.root, "switch", "main") + mainChangelog := strings.Replace( + commonChangelog, + "## [0.9.0] - 2026-06-01", + `## [1.0.1-beta.1] - 2026-07-17 + +### Changed + +- Main branch release note. + +## [0.9.0] - 2026-06-01`, + 1, + ) + changelogGateWrite(t, repo.root, "CHANGELOG.md", mainChangelog, 0o644) + repo.commit(t, "add main release note") + mergeBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + changelogGateGit(t, repo.root, "merge", "--no-ff", "feature", "-m", "merge feature") + mergeHead := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + + output, err := repo.runRefs(t, "--fast-path", mergeBase, mergeHead) + if err == nil { + t.Fatalf("synthetic merge with duplicate release heading unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, "exactly one well-formed section") { + t.Fatalf("synthetic merge rejection missing duplicate-section evidence:\n%s", output) + } +} + +func TestChangelogPRGateRejectsExecutableModeInBothModes(t *testing.T) { + for _, mode := range []string{"--fast-path", "--content-only"} { + t.Run(strings.TrimPrefix(mode, "--"), func(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644) + changelogGateGit(t, repo.root, "add", "CHANGELOG.md") + changelogGateGit(t, repo.root, "update-index", "--chmod=+x", "CHANGELOG.md") + changelogGateGit(t, repo.root, "commit", "-m", "make changelog executable") + + output, err := repo.runMode(t, mode) + if err == nil { + t.Fatalf("executable CHANGELOG unexpectedly passed:\n%s", output) + } + if !strings.Contains(output, "regular 100644 blob at head") { + t.Fatalf("gate output missing regular-file rejection:\n%s", output) + } + }) + } +} + func TestReleaseChangelogExtractionAllowsLowercaseTodoProductName(t *testing.T) { sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { @@ -210,43 +389,97 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { if err != nil { t.Fatalf("Abs(repo root) error = %v", err) } - workflows := map[string][]string{ - ".github/workflows/ci.yml": { - "name: Classify PR changes", - "name: Changelog Check", - `run: ./scripts/policy/check-changelog-pr.sh "$PR_BASE_SHA" "$PR_HEAD_SHA"`, - "expected_heavy_result=skipped", - "name: CI Gate", - }, - ".github/workflows/multi-profile-e2e.yml": { - "branches:", - "- main", - "Record changelog-only fast path", - "Full E2E: runs after merge", - }, + + readWorkflow := func(path string) string { + t.Helper() + data, readErr := os.ReadFile(filepath.Join(root, path)) + if readErr != nil { + t.Fatalf("ReadFile(%s) error = %v", path, readErr) + } + return string(data) } - classifierContract := []string{ + + admission := readWorkflow(".github/workflows/ci.yml") + for _, want := range []string{ + "name: Code Admission — PR 合入门禁", "files.length === 1", "files[0].filename === 'CHANGELOG.md'", "files[0].status === 'modified'", "!files[0].previous_filename", + "pre-classification", + "post-classification", + "before.changed_files !== files.length", + "after.changed_files !== files.length", + `test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`, + `test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`, + "Files API and synthetic merge tree disagree on CHANGELOG scope", + "mode=--content-only", + "mode=--fast-path", + `"$mode" "$PR_BASE_SHA" HEAD`, + "needs.lint.outputs.platform_sensitive == 'true'", + } { + if !strings.Contains(admission, want) { + t.Errorf("Code Admission workflow missing contract %q", want) + } + } + for _, context := range []string{ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "CLI Smoke", + "Mock MCP", + } { + if !strings.Contains(admission, "\n name: "+context+"\n") { + t.Errorf("Code Admission workflow missing exact context %q", context) + } + } + for _, forbidden := range []string{ + "name: CI Gate", + "name: Changelog Check", + "name: Policy Check", + "name: Edition Contract Tests", + "name: Mock MCP Smoke", + } { + if strings.Contains(admission, forbidden) { + t.Errorf("Code Admission workflow retains legacy context %q", forbidden) + } + } + if strings.Contains(admission, "paths-ignore:") { + t.Error("Code Admission must not suppress required contexts with paths-ignore") } - for path, required := range workflows { - data, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatalf("ReadFile(%s) error = %v", path, err) + aiBehavior := readWorkflow(".github/workflows/ai-behavior-check.yml") + for _, want := range []string{ + "name: Code Admission — AI Behavior", + "\n name: AI Behavior\n", + "context: 'AI Behavior'", + "pull_request_target:", + "pull.head.sha !== expectedHead", + "pull.base.sha !== expectedBase", + } { + if !strings.Contains(aiBehavior, want) { + t.Errorf("AI Behavior workflow missing contract %q", want) } - workflow := string(data) - for _, want := range append(classifierContract, required...) { - if !strings.Contains(workflow, want) { - t.Errorf("%s missing fast-path contract %q", path, want) - } - } - if strings.Contains(workflow, "paths-ignore:") { - t.Errorf("%s must not skip the required workflow with paths-ignore", path) + } + + integration := readWorkflow(".github/workflows/multi-profile-e2e.yml") + for _, want := range []string{ + "name: Main Integration — 主干集成", + "\n name: Multi-profile E2E\n", + "branches:", + "- main", + "workflow_dispatch:", + } { + if !strings.Contains(integration, want) { + t.Errorf("main integration workflow missing contract %q", want) } } + if strings.Contains(integration, "pull_request:") { + t.Error("complete Multi-profile E2E must not run as a pull-request admission context") + } } func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) { @@ -351,7 +584,7 @@ func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) { t.Fatalf("Rename CHANGELOG.md error = %v", err) } }, - wantOutput: "exactly one in-place modification", + wantOutput: "regular 100644 blob at head", }, } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 676313d6..03303efc 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -33,6 +33,18 @@ var expectedPackagedSkillTargets = []string{ ".hermes/skills/dws", } +var expectedReleaseAdmissionContexts = []string{ + "Lint", + "Test", + "Coverage", + "Policy", + "Edition", + "Interface Integrity", + "AI Behavior", + "CLI Smoke", + "Mock MCP", +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) @@ -630,12 +642,16 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { const ( checksCall = "github.rest.checks.listForRef" + paginatedCall = "github.paginate(github.rest.checks.listForRef" immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"` governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}` ) if got := strings.Count(workflow, checksCall); got != 2 { t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got) } + if got := strings.Count(workflow, paginatedCall); got != 2 { + t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check and one preflight check", got) + } if got := strings.Count(workflow, immutableCall); got != 2 { t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got) } @@ -651,6 +667,12 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { for _, required := range []string{ "checks: read", checksCall, + paginatedCall, + "run.head_sha !== sha", + "const missing = requiredContexts.filter", + "const nonSuccess = requiredContexts.flatMap", + "missing:", + "non-success:", immutableCall, governanceID, "RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required", @@ -659,6 +681,14 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { t.Errorf("%s governance path is missing %q", name, required) } } + for _, context := range expectedReleaseAdmissionContexts { + if !strings.Contains(section, fmt.Sprintf("%q", context)) { + t.Errorf("%s governance path is missing exact Code Admission context %q", name, context) + } + } + if strings.Contains(section, "check_name:") { + t.Errorf("%s governance path must fetch all check runs in one exact-SHA query", name) + } if strings.Contains(section, "contents: write") { t.Errorf("%s governance path must not grant contents write permission", name) } @@ -666,6 +696,51 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) { t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name) } } + if strings.Contains(workflow, "CI"+" Gate") { + t.Error("release workflow must not retain the retired aggregate gate name") + } +} + +func TestReleaseScriptRequiresExactCodeAdmissionContexts(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release.sh")) + if err != nil { + t.Fatalf("Abs(release.sh) error = %v", err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptPath, err) + } + script := string(data) + + const checksQuery = `commits/$sealed_commit/check-runs?filter=latest&per_page=100` + if got := strings.Count(script, checksQuery); got != 1 { + t.Fatalf("release script exact-SHA Checks API query count = %d, want 1", got) + } + for _, required := range []string{ + `group_by(.name) | map(max_by(.id))`, + `missing_contexts=""`, + `non_success_contexts=""`, + `"$context_state" != "success"`, + "Code Admission contexts are not all successful for sealed commit", + "missing: %s; non-success: %s", + } { + if !strings.Contains(script, required) { + t.Errorf("release script Code Admission gate is missing %q", required) + } + } + for _, context := range expectedReleaseAdmissionContexts { + if !strings.Contains(script, "\n"+context+"\n") { + t.Errorf("release script is missing exact Code Admission context %q", context) + } + } + if strings.Contains(script, "check_name=") { + t.Error("release script must fetch all check runs in one exact-SHA query") + } + if strings.Contains(script, "CI"+" Gate") { + t.Error("release script must not retain the retired aggregate gate name") + } } func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) { @@ -703,10 +778,10 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) { t.Errorf("governance preflight must not contain publishing behavior %q", forbidden) } } - ciGate := strings.Index(preflight, "Require successful CI Gate on the preflight commit") + admission := strings.Index(preflight, "Require successful Code Admission contexts on the preflight commit") homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission") - if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary { - t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials") + if admission == -1 || homebrewCanary == -1 || admission > homebrewCanary { + t.Error("governance preflight must validate all exact Code Admission contexts before exposing Homebrew credentials") } mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n") @@ -839,7 +914,8 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) { "dws-release-recovery run=%s tag-object=%s commit=%s", "Public release is not bound to this exact recovery run.", "Public recovery asset differs from this run's sealed artifact", - `ref: process.env.RELEASE_COMMIT`, + `const sha = process.env.RELEASE_COMMIT`, + `ref: sha`, `path: tmp/trusted-release-tooling`, `ref: ${{ github.sha }}`, `step.name === "Require immutable published GitHub Release"`, @@ -1335,10 +1411,10 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) { t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got) } tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n") - tagCI := strings.Index(tagContract, "Require successful CI Gate on the sealed commit") + tagAdmission := strings.Index(tagContract, "Require successful Code Admission contexts on the sealed commit") tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission") - if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew { - t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials") + if tagAdmission == -1 || tagHomebrew == -1 || tagAdmission > tagHomebrew { + t.Error("tag contract must validate all exact Code Admission contexts before exposing Homebrew credentials") } start := strings.Index(workflow, "- name: Open stable Homebrew formula PR") From 883d416d83f106d048e4721073f5ac9ccf9988bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Mon, 20 Jul 2026 07:29:24 +0800 Subject: [PATCH 07/67] test(auth): isolate OAuth coverage fixtures --- internal/auth/oauth_provider_coverage_test.go | 284 +++++++++++------- 1 file changed, 168 insertions(+), 116 deletions(-) diff --git a/internal/auth/oauth_provider_coverage_test.go b/internal/auth/oauth_provider_coverage_test.go index 1be87470..6c9269b8 100644 --- a/internal/auth/oauth_provider_coverage_test.go +++ b/internal/auth/oauth_provider_coverage_test.go @@ -18,6 +18,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) type oauthLoginFixture struct { @@ -33,8 +35,79 @@ type oauthLoginFixture struct { exchangeError atomic.Bool } +type oauthLoginResult struct { + token *TokenData + err error +} + +type oauthHTTPResult struct { + status int + body string + err error +} + +const oauthTestWaitTimeout = 5 * time.Second + +func isolateOAuthPersistence(t *testing.T) { + t.Helper() + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) +} + +func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult { + t.Helper() + ctx, cancel := context.WithCancel(parent) + done := make(chan oauthLoginResult, 1) + finished := make(chan struct{}) + go func() { + defer close(finished) + token, err := f.provider.Login(ctx, true) + done <- oauthLoginResult{token: token, err: err} + }() + t.Cleanup(func() { + cancel() + select { + case <-finished: + case <-time.After(oauthTestWaitTimeout): + t.Errorf("OAuth Login goroutine did not stop after cancellation") + } + }) + return done +} + +func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(oauthTestWaitTimeout): + t.Fatal("timed out waiting for OAuth Login") + return oauthLoginResult{} + } +} + +func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) { + t.Helper() + select { + case <-signal: + case result := <-done: + t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err) + case <-time.After(oauthTestWaitTimeout): + t.Fatalf("timed out waiting for OAuth %s", name) + } +} + +func closeOAuthRelease(ch chan struct{}) { + select { + case <-ch: + default: + close(ch) + } +} + func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture { t.Helper() + isolateOAuthPersistence(t) SetClientID("") SetClientSecret("") resetClientIDFromMCP() @@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth } })) t.Cleanup(f.server.Close) + t.Cleanup(func() { + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + }) f.configDir = setupMCPConfigDir(t, f.server.URL) oldClient := oauthHTTPClient @@ -118,17 +195,25 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth func httpGetBody(t *testing.T, rawURL string) (int, string) { t.Helper() + result := getHTTPBody(rawURL) + if result.err != nil { + t.Fatalf("GET %s: %v", rawURL, result.err) + } + return result.status, result.body +} + +func getHTTPBody(rawURL string) oauthHTTPResult { client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(rawURL) if err != nil { - t.Fatalf("GET %s: %v", rawURL, err) + return oauthHTTPResult{err: err} } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - t.Fatal(err) + return oauthHTTPResult{err: err} } - return resp.StatusCode, string(data) + return oauthHTTPResult{status: resp.StatusCode, body: string(data)} } func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { @@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}} }) - loginDone := make(chan struct { - token *TokenData - err error - }, 1) - go func() { - token, err := f.provider.Login(context.Background(), true) - loginDone <- struct { - token *TokenData - err error - }{token, err} - }() + loginDone := startOAuthLogin(t, context.Background(), f) for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} { _, body := httpGetBody(t, f.callbackBase+path) @@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) { t.Fatalf("success page = %q", body) } - callbackDone := make(chan string, 1) + callbackDone := make(chan oauthHTTPResult, 1) go func() { - _, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good") - callbackDone <- callbackBody + callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good") }() - <-f.exchangeEntered + waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange") _, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good") if !strings.Contains(body, "正在处理授权") { t.Fatalf("concurrent callback = %q", body) } - close(f.exchangeRelease) - <-f.statusEntered + closeOAuthRelease(f.exchangeRelease) + waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check") _, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good") if !strings.Contains(body, " 1}} }) - close(f.exchangeRelease) - close(f.statusRelease) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(context.Background(), true) - done <- err - }() + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + done := startOAuthLogin(t, context.Background(), f) _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending") - if err := <-done; err != nil { - t.Fatalf("poll-enabled login failed: %v", err) + if result := awaitOAuthLogin(t, done); result.err != nil { + t.Fatalf("poll-enabled login failed: %v", result.err) } }) @@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) { f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}} }) - close(f.exchangeRelease) - close(f.statusRelease) + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(ctx, true) - done <- err - }() + done := startOAuthLogin(t, ctx, f) _, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending") cancel() - if err := <-done; err == nil { + if result := awaitOAuthLogin(t, done); result.err == nil { t.Fatal("canceled pending login succeeded") } }) @@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) { return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}} }) f.exchangeError.Store(true) - close(f.exchangeRelease) - close(f.statusRelease) - done := make(chan error, 1) - go func() { - _, err := f.provider.Login(context.Background(), true) - done <- err - }() + closeOAuthRelease(f.exchangeRelease) + closeOAuthRelease(f.statusRelease) + done := startOAuthLogin(t, context.Background(), f) _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad") if !strings.Contains(body, "failed") { t.Fatalf("exchange failure page = %q", body) } - if err := <-done; err == nil { + if result := awaitOAuthLogin(t, done); result.err == nil { t.Fatal("exchange failure login succeeded") } } func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) { + isolateOAuthPersistence(t) t.Setenv("DWS_CLIENT_ID", "") t.Setenv("DWS_CLIENT_SECRET", "") dir := t.TempDir() @@ -640,6 +695,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) { } func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) { + isolateOAuthPersistence(t) oldLoad := oauthLoadToken oldLoadLocked := oauthLoadTokenLocked oldAcquire := oauthAcquireLock @@ -800,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) { oauthSuccessPause = 0 oauthSleep = func(time.Duration) {} - type loginResult struct { - token *TokenData - err error - } - startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult { + finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string { t.Helper() - done := make(chan loginResult, 1) + bodyCh := make(chan oauthHTTPResult, 1) go func() { - token, err := f.provider.Login(ctx, true) - done <- loginResult{token: token, err: err} + bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code)) }() - return done - } - finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string { - t.Helper() - bodyCh := make(chan string, 1) - go func() { - _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code)) - bodyCh <- body - }() - <-f.exchangeEntered - close(f.exchangeRelease) - return <-bodyCh + waitOAuthSignal(t, f.exchangeEntered, done, "token exchange") + closeOAuthRelease(f.exchangeRelease) + select { + case result := <-bodyCh: + if result.err != nil { + t.Fatalf("OAuth callback failed: %v", result.err) + } + return result.body + case <-time.After(oauthTestWaitTimeout): + t.Fatal("timed out waiting for OAuth callback") + return "" + } } t.Run("switch organization and cached disabled pages", func(t *testing.T) { @@ -835,8 +886,8 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) { return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil } f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} }) - done := startLogin(context.Background(), f) - if body := finishExchange(t, f, "first"); !strings.Contains(body, " Date: Mon, 20 Jul 2026 11:35:32 +0800 Subject: [PATCH 08/67] fix(release): recover immutable mirror channels safely --- .github/workflows/release.yml | 103 +++++-- docs/automation.md | 16 ++ docs/releasing.md | 11 +- .../verify-release-workflow-delivery.sh | 224 +++++++++++++++- test/scripts/package_script_test.go | 75 ++++-- test/scripts/release_script_test.go | 251 ++++++++++++++++++ 6 files changed, 637 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73f1c569..d0398057 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ name: Release -run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.workflow }} +run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }} on: push: @@ -15,6 +15,10 @@ on: description: "Mirror an existing public immutable release's assets to Gitee" required: false type: string + repair_oss_version: + description: "Mirror an existing public immutable release's assets to OSS" + required: false + type: string governance_preflight_commit: description: "Verify release governance for this exact default-branch commit without publishing" required: false @@ -75,6 +79,7 @@ jobs: env: REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }} + REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }} GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }} GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }} RECOVER_VERSION: ${{ inputs.recover_release_version }} @@ -88,17 +93,19 @@ jobs: set -eu npm_repair=0 gitee_repair=0 + oss_repair=0 governance=0 recovery=0 test -z "$REPAIR_NPM_VERSION" || npm_repair=1 test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1 + test -z "$REPAIR_OSS_VERSION" || oss_repair=1 if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \ test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \ test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \ test -n "$RECOVER_NONCE" || \ test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi - test $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || { + test $((npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { echo "workflow_dispatch must select exactly one release mode" >&2 exit 1 } @@ -106,6 +113,8 @@ jobs: echo "mode=repair_npm" >> "$GITHUB_OUTPUT" elif test "$gitee_repair" -eq 1; then echo "mode=repair_gitee" >> "$GITHUB_OUTPUT" + elif test "$oss_repair" -eq 1; then + echo "mode=repair_oss" >> "$GITHUB_OUTPUT" elif test "$governance" -eq 1; then if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then echo "governance preflight requires both commit and nonce" >&2 @@ -1878,7 +1887,7 @@ jobs: - publish-channels - mirror-gitee-release - repair-npm - - repair-gitee + - repair-channel runs-on: ubuntu-latest timeout-minutes: 2 permissions: {} @@ -1898,7 +1907,7 @@ jobs: PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }} MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} - REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }} + REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} run: | set -eu require_result() { @@ -1928,7 +1937,7 @@ jobs: require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped - require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped require_publication ;; workflow_dispatch:recover_release) @@ -1936,7 +1945,7 @@ jobs: require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped - require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped require_publication ;; workflow_dispatch:governance_preflight) @@ -1950,7 +1959,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped - require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped ;; workflow_dispatch:repair_npm) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1963,11 +1972,24 @@ jobs: require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped - require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped ;; workflow_dispatch:repair_gitee) require_result dispatch-contract "$DISPATCH_RESULT" success - require_result repair-gitee "$REPAIR_GITEE_RESULT" success + require_result repair-channel "$REPAIR_CHANNEL_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + ;; + workflow_dispatch:repair_oss) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result repair-channel "$REPAIR_CHANNEL_RESULT" success require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped @@ -1983,16 +2005,16 @@ jobs: exit 1 ;; esac - # Mirror an existing public immutable release's assets to Gitee on demand. The - # push-triggered mirror-gitee-release job depends on the same run's finalized - # dist artifact, so a tag that was already published (including one delivered - # by a recovery dispatch) cannot reuse it. This dispatch path re-derives the - # asset set from the immutable GitHub Release itself and mirrors it. The - # release tag and asset checksums remain the sealed authority boundary. - repair-gitee: - name: Mirror an existing immutable release to Gitee + # Repair one downstream channel from an existing public immutable release on + # demand. The push-triggered channel jobs depend on the original run's + # finalized dist artifact, so a tag that was already published cannot reuse + # it. This dispatch path re-derives the asset set from the immutable GitHub + # Release itself. The release tag, delivery provenance, and asset checksums + # remain the sealed authority boundary. + repair-channel: + name: Repair an existing immutable release channel needs: dispatch-contract - if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} runs-on: ubuntu-latest timeout-minutes: 120 permissions: @@ -2010,7 +2032,7 @@ jobs: - name: Validate repair version working-directory: tooling env: - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} run: | set -eu . ./scripts/release/release-lib.sh @@ -2023,7 +2045,7 @@ jobs: id: authority uses: actions/github-script@v7 env: - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} with: script: | const { owner, repo } = context.repo; @@ -2102,12 +2124,20 @@ jobs: core.setOutput("commit_sha", commitSha); core.setOutput("tag_object", ref.data.object.sha); + - name: Check out sealed release source + uses: actions/checkout@v4 + with: + ref: ${{ steps.authority.outputs.commit_sha }} + path: release-source + persist-credentials: false + - name: Fetch and verify sealed release tag working-directory: tooling env: - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -eu git fetch --force --no-tags \ @@ -2119,17 +2149,23 @@ jobs: - name: Require successful Release workflow delivery working-directory: tooling env: - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }} DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + case "$REPAIR_MODE" in + repair_gitee) target=gitee ;; + repair_oss) target=oss ;; + *) echo "Unsupported channel repair mode: $REPAIR_MODE" >&2; exit 2 ;; + esac ./scripts/release/verify-release-workflow-delivery.sh \ - "$VERSION" "$RELEASE_COMMIT" + --channel-repair "$target" "$VERSION" "$RELEASE_COMMIT" - name: Download and verify immutable GitHub Release assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} run: | set -eu mkdir -p dist @@ -2143,14 +2179,31 @@ jobs: "$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION" - name: Mirror release to Gitee (China) + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }} timeout-minutes: 100 working-directory: tooling run: | "$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh" env: - VERSION: ${{ inputs.repair_gitee_version }} + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} DIST_DIR: ${{ github.workspace }}/dist GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} GITEE_USER: ${{ secrets.GITEE_USER }} GITEE_REPO: ${{ secrets.GITEE_REPO }} DWS_REQUIRE_GITEE: "1" + + - name: Mirror release to OSS (China) + if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }} + timeout-minutes: 30 + working-directory: release-source + run: | + "$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh" + env: + VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }} + DIST_DIR: ${{ github.workspace }}/dist + 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: "1" diff --git a/docs/automation.md b/docs/automation.md index 8c8a3742..fe107c56 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -108,6 +108,22 @@ commit, and failed tag-push run all match; it then reuses the normal release jobs. Do not put publication secrets in temporary branches or create ad-hoc recovery workflows. +If the immutable GitHub Release and npm package were delivered but a downstream +China mirror failed, dispatch the normal `Release` workflow from the protected +default branch with exactly one of `repair_gitee_version` or +`repair_oss_version`. Channel repair accepts a failed exact-tag run only when +its latest attempt completed the release contract, build, Apple signature, +immutable GitHub publication, and npm delivery checks for the exact tagged +commit. It then downloads and re-verifies the immutable assets before invoking +only the selected mirror. An OSS repair requires the OSS step itself to be the +recorded failure. A Gitee repair accepts either a failed Gitee job or a Gitee +job that was skipped behind that OSS failure; the latter is an explicit Gitee +backfill and does not claim that OSS has been repaired. Gitee repair requires +`GITEE_TOKEN`, `GITEE_USER`, and `GITEE_REPO`; OSS repair requires +`OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET` +(with optional `OSS_PREFIX`) as Actions secrets. Missing credentials fail the +selected repair closed. + ## Handoff Checklist Before handoff, include: diff --git a/docs/releasing.md b/docs/releasing.md index fdee0cac..2c9ca532 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -97,7 +97,16 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish 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/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发 +Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel +repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、 +immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败, +随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step; +Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功, +不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或 +stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧 +attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release +workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。 ## 既有 tag 的紧急恢复 diff --git a/scripts/release/verify-release-workflow-delivery.sh b/scripts/release/verify-release-workflow-delivery.sh index f06397ee..40f19ca0 100755 --- a/scripts/release/verify-release-workflow-delivery.sh +++ b/scripts/release/verify-release-workflow-delivery.sh @@ -4,12 +4,28 @@ set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" . "$SCRIPT_DIR/release-lib.sh" +MODE="strict" +CHANNEL_REPAIR_TARGET="" +if [ "${1:-}" = "--channel-repair" ]; then + MODE="channel-repair" + shift + CHANNEL_REPAIR_TARGET="${1:-}" + shift + case "$CHANNEL_REPAIR_TARGET" in + oss|gitee) ;; + *) + printf 'channel repair target must be oss or gitee\n' >&2 + exit 2 + ;; + esac +fi + TAG="${1:-}" EXPECTED_COMMIT="${2:-}" REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" [ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || { - printf 'usage: verify-release-workflow-delivery.sh \n' >&2 + printf 'usage: verify-release-workflow-delivery.sh [--channel-repair ] \n' >&2 exit 2 } if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then @@ -65,6 +81,200 @@ for run in runs: done } +find_failed_push_delivery() { + matches="" + page=1 + while :; do + page_result="$( + github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \ + | python3 -c 'import json,sys +tag,commit,repository=sys.argv[1:] +runs=json.load(sys.stdin).get("workflow_runs", []) +print(len(runs)) +for run in runs: + if (run.get("head_sha") == commit and run.get("head_branch") == tag + and run.get("event") == "push" and run.get("status") == "completed" + and run.get("conclusion") == "failure" + and run.get("path") == ".github/workflows/release.yml" + and run.get("repository", {}).get("full_name") == repository): + run_id=run.get("id", "") + attempt=run.get("run_attempt", "") + if isinstance(run_id, int) and run_id > 0 and isinstance(attempt, int) and attempt > 0: + print(f"{run_id}\t{attempt}")' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY" + )" || return 1 + page_count="$(printf '%s\n' "$page_result" | sed -n '1p')" + page_matches="$(printf '%s\n' "$page_result" | sed '1d')" + matches="$(printf '%s\n%s\n' "$matches" "$page_matches" | sed '/^$/d')" + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + match_count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')" + [ "$match_count" -eq 1 ] || { + printf 'expected exactly one failed exact-tag push run for channel repair, found %s\n' \ + "$match_count" >&2 + return 1 + } + printf '%s\n' "$matches" +} + +verify_channel_repair_delivery() { + run_id="$1" + run_attempt="$2" + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-channel-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + if ! page_count="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' "$jobs_page")"; then + rm -rf "$jobs_dir" + return 1 + fi + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$CHANNEL_REPAIR_TARGET" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +commit, run_id, run_attempt, tag, target, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +def fail(message): + print( + f"failed Release run {run_id} attempt {run_attempt} is not safe " + f"channel-repair authority for {tag}: {message}", + file=sys.stderr, + ) + raise SystemExit(1) + +def one_job(name): + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one latest-attempt job {name!r}, found {len(matches)}") + job = matches[0] + if job.get("head_sha") != commit: + fail(f"job {name!r} is not bound to {commit}") + if job.get("status") != "completed": + fail(f"job {name!r} is not completed") + return job + +for name in ( + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", +): + if one_job(name).get("conclusion") != "success": + fail(f"required job {name!r} did not succeed") + +publish_release = one_job("Publish immutable GitHub Release") +immutable_steps = [ + step for step in publish_release.get("steps", []) + if step.get("name") == "Require immutable published GitHub Release" +] +if len(immutable_steps) != 1: + fail("expected exactly one immutable GitHub Release verification step") +if ( + immutable_steps[0].get("status") != "completed" + or immutable_steps[0].get("conclusion") != "success" +): + fail("immutable GitHub Release verification did not succeed") + +channels = one_job("Publish npm and mirrors") +if channels.get("conclusion") not in {"success", "failure"}: + fail("channel publication job was not completed with a conclusive result") + +steps = channels.get("steps", []) +for name in ( + "Download and verify immutable GitHub Release", + "Verify immutable npm package without publication credentials", + "Inspect npm channel state", + "Verify npm channel delivery", +): + matches = [step for step in steps if step.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one channel step {name!r}, found {len(matches)}") + step = matches[0] + if step.get("status") != "completed" or step.get("conclusion") != "success": + fail(f"required channel step {name!r} did not succeed") + +failed_channel_steps = [ + step.get("name", "") + for step in steps + if step.get("conclusion") == "failure" +] +if channels.get("conclusion") == "failure": + if failed_channel_steps != ["Sync release artifacts to China OSS mirror"]: + fail( + "failed channel publication must have exactly one failed OSS mirror step, " + f"got {failed_channel_steps!r}" + ) +elif failed_channel_steps: + fail(f"successful channel publication contains failed steps {failed_channel_steps!r}") + +gitee = one_job("Mirror immutable release to Gitee") +if gitee.get("conclusion") not in {"success", "skipped", "failure"}: + fail("Gitee mirror job did not complete with an allowed channel result") + +delivery_gate = one_job("Release delivery gate") +if delivery_gate.get("conclusion") != "failure": + fail("failed channel-repair run must end in a failed delivery gate") + +allowed_failures = { + "Publish npm and mirrors", + "Mirror immutable release to Gitee", + "Release delivery gate", +} +for job in jobs: + if job.get("status") != "completed": + fail(f"job {job.get('name', '')!r} is not completed") + conclusion = job.get("conclusion") + if conclusion not in {"success", "skipped", "failure"}: + fail(f"job {job.get('name', '')!r} has disallowed conclusion {conclusion!r}") + if conclusion == "failure" and job.get("name") not in allowed_failures: + fail(f"unrelated job {job.get('name', '')!r} failed") + +business_failures = [ + job.get("name") + for job in (channels, gitee) + if job.get("conclusion") == "failure" +] +if len(business_failures) != 1: + fail(f"expected exactly one failed downstream channel job, got {business_failures!r}") +if target == "oss": + if business_failures != ["Publish npm and mirrors"] or gitee.get("conclusion") != "skipped": + fail( + "OSS repair requires the OSS mirror step to be the only failed " + "downstream channel and Gitee to be skipped" + ) +elif target == "gitee": + if gitee.get("conclusion") == "failure": + if business_failures != ["Mirror immutable release to Gitee"]: + fail("Gitee repair evidence contains a different failed downstream channel") + elif gitee.get("conclusion") == "skipped": + if business_failures != ["Publish npm and mirrors"]: + fail( + "skipped Gitee backfill requires the upstream OSS mirror to be " + "the only failed downstream channel" + ) + else: + fail("Gitee repair requires its mirror job to be failed or skipped") +else: + fail(f"unsupported channel repair target {target!r}") +PY + rm -rf "$jobs_dir" + return "$result" +} + push_delivery="$(find_push_delivery || true)" if [ -n "$push_delivery" ]; then printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \ @@ -72,6 +282,18 @@ if [ -n "$push_delivery" ]; then exit 0 fi +if [ "$MODE" = "channel-repair" ]; then + failed_push_identity="$(find_failed_push_delivery || true)" + failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)" + failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)" + if [ -n "$failed_push_delivery" ] && + verify_channel_repair_delivery "$failed_push_delivery" "$failed_push_attempt"; then + printf 'Release %s channel-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \ + "$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi +fi + find_recovery_identity() { page=1 while :; do diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 03303efc..7ea64d35 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -799,36 +799,49 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) { } } -func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) { +func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n") - start := strings.Index(workflow, " repair-gitee:\n") + start := strings.Index(workflow, " repair-channel:\n") if start == -1 { - t.Fatal("release workflow is missing the Gitee repair job") + t.Fatal("release workflow is missing the channel repair job") } repair := workflow[start:] + tagAuthority := releaseWorkflowSection( + t, + repair, + " - name: Fetch and verify sealed release tag\n", + "\n - name: Require successful Release workflow delivery\n", + ) for _, required := range []string{ "repair_gitee_version:", `format('Release Gitee repair {0}', inputs.repair_gitee_version)`, `REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`, + "repair_oss_version:", + `format('Release OSS repair {0}', inputs.repair_oss_version)`, + `REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }}`, "gitee_repair=0", + "oss_repair=0", `test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`, - "npm_repair + gitee_repair + governance + recovery", + `test -z "$REPAIR_OSS_VERSION" || oss_repair=1`, + "npm_repair + gitee_repair + oss_repair + governance + recovery", `echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`, + `echo "mode=repair_oss" >> "$GITHUB_OUTPUT"`, } { if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) { - t.Errorf("Gitee repair dispatch contract is missing %q", required) + t.Errorf("channel repair dispatch contract is missing %q", required) } } for _, required := range []string{ "needs: dispatch-contract", - `if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`, + `if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}`, `github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`, `github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`, "actions: read", + "contents: read", `ref: ${{ github.sha }}`, "path: tooling", "persist-credentials: false", @@ -841,17 +854,34 @@ func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) { "assetNames.length !== expectedAssets.size", "new Set(assetNames).size !== expectedAssets.size", `core.setOutput("tag_object", ref.data.object.sha)`, + `ref: ${{ steps.authority.outputs.commit_sha }}`, + "path: release-source", "verify-github-tag-authority.sh", + `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`, "verify-release-workflow-delivery.sh", + `REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }}`, + "repair_gitee) target=gitee", + "repair_oss) target=oss", + `--channel-repair "$target"`, `DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`, "verify-release-artifacts.sh", `--repo "$GITHUB_REPOSITORY"`, + `if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}`, `DWS_REQUIRE_GITEE: "1"`, + `if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}`, + `DWS_REQUIRE_OSS: "1"`, + `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 }}`, + "working-directory: release-source", "working-directory: tooling\n run: |\n " + `"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`, + `"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh"`, } { if !strings.Contains(repair, required) { - t.Errorf("Gitee repair authority is missing %q", required) + t.Errorf("channel repair authority is missing %q", required) } } for _, asset := range []string{ @@ -865,17 +895,29 @@ func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) { "checksums.txt", } { if strings.Count(repair, `"`+asset+`"`) != 1 { - t.Errorf("Gitee repair must require exactly one %s asset declaration", asset) + t.Errorf("channel repair must require exactly one %s asset declaration", asset) } } if strings.Contains(repair, "contents: write") { - t.Error("Gitee repair must not grant contents write permission") + t.Error("channel repair must not grant contents write permission") + } + for _, forbidden := range []string{ + "RELEASE_GOVERNANCE_TOKEN", + "HOMEBREW_PR_TOKEN", + "NPM_TOKEN", + } { + if strings.Contains(repair, forbidden) { + t.Errorf("channel repair must not expose unrelated credential %s", forbidden) + } } if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") { - t.Error("Gitee repair must not check out a floating default branch") + t.Error("channel repair must not check out a floating default branch") + } + if !strings.Contains(tagAuthority, `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`) { + t.Error("remote annotated-tag authority step must receive the read-only GitHub token") } if got := strings.Count(repair, "working-directory: tooling"); got < 4 { - t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got) + t.Errorf("channel repair trusted tooling working-directory count = %d, want at least 4", got) } } @@ -1134,7 +1176,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { t.Parallel() workflow := readReleaseWorkflow(t) - gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n") + gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-channel:\n") for _, required := range []string{ "name: Release delivery gate", @@ -1149,8 +1191,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "- publish-channels", "- mirror-gitee-release", "- repair-npm", - "- repair-gitee", - `REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`, + "- repair-channel", + `REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`, "require_publication", `require_result release-contract "$RELEASE_CONTRACT_RESULT" success`, `require_result release "$RELEASE_RESULT" success`, @@ -1161,8 +1203,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "workflow_dispatch:governance_preflight", "workflow_dispatch:repair_npm", "workflow_dispatch:repair_gitee", - `require_result repair-gitee "$REPAIR_GITEE_RESULT" success`, - `require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`, + "workflow_dispatch:repair_oss", + `require_result repair-channel "$REPAIR_CHANNEL_RESULT" success`, + `require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped`, "unsupported release mode", } { if !strings.Contains(gate, required) { diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index fa799fa2..175ee3c0 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -674,6 +674,257 @@ esac } } +func TestReleaseWorkflowDeliveryChannelRepairRequiresLatestAttemptCoreDelivery(t *testing.T) { + sourceRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + binDir := t.TempDir() + fakeCurl := filepath.Join(binDir, "curl") + mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh +set -eu +for argument in "$@"; do endpoint="$argument"; done +case "$endpoint" in + *event=push*) + python3 - <<'PY' +import json +import os +run = { + "id": 77, + "event": "push", + "status": "completed", + "conclusion": "failure", + "head_branch": os.environ["TAG"], + "head_sha": os.environ["RELEASE_COMMIT"], + "path": os.environ.get("RUN_PATH", ".github/workflows/release.yml"), + "repository": {"full_name": os.environ.get("RUN_REPOSITORY", "owner/repo")}, + "run_attempt": int(os.environ.get("RUN_ATTEMPT", "2")), +} +runs = [run] +if os.environ.get("DUPLICATE_RUN") == "1": + duplicate = dict(run) + duplicate["id"] = 78 + runs.append(duplicate) +print(json.dumps({"workflow_runs": runs})) +PY + ;; + *event=workflow_dispatch*) + printf '{"workflow_runs":[]}\n' + ;; + */actions/runs/77/attempts/2/jobs*) + python3 - <<'PY' +import json +import os + +commit = os.environ.get("JOB_SHA", os.environ["RELEASE_COMMIT"]) +core = [ + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", +] +jobs = [{ + "name": name, + "status": "completed", + "conclusion": ( + os.environ.get("CORE_CONCLUSION", "success") + if name == os.environ.get("CORE_JOB", "Build signed release artifacts") + else "success" + ), + "head_sha": commit, + "steps": ( + [{ + "name": "Require immutable published GitHub Release", + "status": "completed", + "conclusion": os.environ.get("IMMUTABLE_STEP_CONCLUSION", "success"), + }] + if name == "Publish immutable GitHub Release" + else [] + ), +} for name in core] +if os.environ.get("DUPLICATE_CORE") == "1": + jobs.append(dict(jobs[1])) +required_steps = [ + "Download and verify immutable GitHub Release", + "Verify immutable npm package without publication credentials", + "Inspect npm channel state", + "Verify npm channel delivery", +] +jobs.append({ + "name": "Publish npm and mirrors", + "status": "completed", + "conclusion": os.environ.get("CHANNEL_JOB_CONCLUSION", "failure"), + "head_sha": commit, + "steps": [{ + "name": name, + "status": "completed", + "conclusion": ( + os.environ.get("CHANNEL_STEP_CONCLUSION", "success") + if name == os.environ.get("CHANNEL_STEP", "Verify npm channel delivery") + else "success" + ), + } for name in required_steps] + [{ + "name": "Sync release artifacts to China OSS mirror", + "status": "completed", + "conclusion": os.environ.get("OSS_STEP_CONCLUSION", "failure"), + }], +}) +jobs.extend([ + { + "name": "Mirror immutable release to Gitee", + "status": "completed", + "conclusion": os.environ.get("GITEE_CONCLUSION", "skipped"), + "head_sha": commit, + "steps": [], + }, + { + "name": "Release delivery gate", + "status": "completed", + "conclusion": os.environ.get("DELIVERY_GATE_CONCLUSION", "failure"), + "head_sha": commit, + "steps": [], + }, +]) +if os.environ.get("UNRELATED_FAILURE") == "1": + jobs.append({ + "name": "Unrelated release job", + "status": "completed", + "conclusion": "failure", + "head_sha": commit, + "steps": [], + }) +print(json.dumps({"jobs": jobs})) +PY + ;; + */actions/runs/77/*/jobs*) + echo "channel repair must inspect the exact latest run attempt" >&2 + exit 91 + ;; + *) exit 1 ;; +esac +`), 0o755) + script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh") + tag := "v1.2.3-beta.1" + commit := strings.Repeat("a", 40) + run := func(args []string, overrides ...string) (string, error) { + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Env = append([]string{ + "PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + t.TempDir(), + "DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo", + "TAG=" + tag, + "RELEASE_COMMIT=" + commit, + }, overrides...) + output, err := cmd.CombinedOutput() + return string(output), err + } + repairArgs := func(target string) []string { + return []string{"--channel-repair", target, tag, commit} + } + + if output, err := run([]string{tag, commit}); err == nil || + !strings.Contains(output, "did not deliver") { + t.Fatalf("strict delivery accepted a failed tag run: err=%v\noutput:\n%s", err, output) + } + if output, err := run(repairArgs("oss")); err != nil || + !strings.Contains(output, "failed exact-tag push run 77") { + t.Fatalf("safe channel repair delivery was rejected: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "CORE_CONCLUSION=failure", + ); err == nil || !strings.Contains(output, "required job 'Build signed release artifacts' did not succeed") { + t.Fatalf("failed core release job passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "CHANNEL_STEP_CONCLUSION=failure", + ); err == nil || !strings.Contains(output, "required channel step 'Verify npm channel delivery' did not succeed") { + t.Fatalf("failed npm delivery proof passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "DUPLICATE_CORE=1", + ); err == nil || !strings.Contains(output, "expected exactly one latest-attempt job 'Build signed release artifacts'") { + t.Fatalf("duplicate latest-attempt core job passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "JOB_SHA="+strings.Repeat("b", 40), + ); err == nil || !strings.Contains(output, "is not bound to "+commit) { + t.Fatalf("wrong-sha release jobs passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "UNRELATED_FAILURE=1", + ); err == nil || !strings.Contains(output, "unrelated job 'Unrelated release job' failed") { + t.Fatalf("unrelated failed job passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "IMMUTABLE_STEP_CONCLUSION=failure", + ); err == nil || !strings.Contains(output, "immutable GitHub Release verification did not succeed") { + t.Fatalf("failed immutable release verification passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("gitee"), + "CHANNEL_JOB_CONCLUSION=success", + "OSS_STEP_CONCLUSION=success", + "GITEE_CONCLUSION=failure", + ); err != nil || !strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("single failed Gitee mirror was rejected: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "CHANNEL_JOB_CONCLUSION=success", + "OSS_STEP_CONCLUSION=success", + "GITEE_CONCLUSION=failure", + ); err == nil || !strings.Contains(output, "OSS repair requires") { + t.Fatalf("Gitee-only failure was accepted as OSS evidence: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("gitee"), + ); err != nil || !strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("skipped Gitee backfill was rejected after upstream OSS failure: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "GITEE_CONCLUSION=failure", + ); err == nil || !strings.Contains(output, "expected exactly one failed downstream channel job") { + t.Fatalf("two failed downstream channels passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "DELIVERY_GATE_CONCLUSION=success", + ); err == nil || !strings.Contains(output, "must end in a failed delivery gate") { + t.Fatalf("successful terminal gate on a failed run passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "RUN_ATTEMPT=1", + ); err == nil || strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("non-latest run attempt passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "RUN_REPOSITORY=other/repo", + ); err == nil || strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("wrong-repository release run passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "RUN_PATH=.github/workflows/other.yml", + ); err == nil || strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("wrong-workflow release run passed: err=%v\noutput:\n%s", err, output) + } + if output, err := run( + repairArgs("oss"), + "DUPLICATE_RUN=1", + ); err == nil || strings.Contains(output, "channel-repair authority verified") { + t.Fatalf("ambiguous failed release runs passed: err=%v\noutput:\n%s", err, output) + } +} + func releaseChangelog(sections ...string) string { text := "# Changelog\n\n## [Unreleased]\n\n" for _, section := range sections { From 22905fc41e9866312c265c7330ece21b2a936636 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 12:16:11 +0800 Subject: [PATCH 09/67] fix(auth): unify access token resolution --- CHANGELOG.md | 1 + internal/app/access_token_manager_test.go | 223 +++++++++++++ internal/app/access_token_resolve.go | 292 +++++++++++++++--- internal/app/app_small_coverage_test.go | 6 +- internal/app/coverage_edges_test.go | 4 +- internal/app/coverage_more_test.go | 3 +- internal/app/event_command.go | 16 +- .../app/event_command_full_coverage_test.go | 16 +- internal/app/event_personal_command.go | 24 +- internal/app/pat_auth_retry.go | 40 +-- .../app/pat_auth_retry_full_coverage_test.go | 36 ++- internal/app/recovery_command.go | 6 +- internal/app/runner.go | 92 +++--- internal/app/runner_full_coverage_test.go | 16 +- internal/app/skill_command.go | 56 ++-- .../app/skill_command_full_coverage_test.go | 32 +- internal/app/skill_command_test.go | 22 +- internal/auth/access_token_snapshot_test.go | 70 +++++ internal/auth/manager.go | 6 +- internal/auth/oauth_provider.go | 38 ++- internal/auth/token.go | 26 ++ internal/event/personal/client.go | 37 ++- .../event/personal/token_provider_test.go | 56 ++++ internal/event/source/personal.go | 54 +++- internal/event/source/portal_ticket.go | 27 +- internal/event/source/token_provider_test.go | 66 ++++ 26 files changed, 996 insertions(+), 269 deletions(-) create mode 100644 internal/app/access_token_manager_test.go create mode 100644 internal/auth/access_token_snapshot_test.go create mode 100644 internal/event/personal/token_provider_test.go create mode 100644 internal/event/source/token_provider_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 90031778..5a636547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.” - **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery. - **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget. diff --git a/internal/app/access_token_manager_test.go b/internal/app/access_token_manager_test.go new file mode 100644 index 00000000..e996b7be --- /dev/null +++ b/internal/app/access_token_manager_test.go @@ -0,0 +1,223 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type tokenManagerSnapshotProvider struct { + load func() (*authpkg.TokenData, error) +} + +func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) { + data, err := p.load() + if err != nil || data == nil { + return "", err + } + return data.AccessToken, nil +} + +func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) { + return p.load() +} + +type tokenManagerLegacyGetter struct { + token string + err error +} + +func (g tokenManagerLegacyGetter) GetToken() (string, string, error) { + return g.token, "file", g.err +} + +func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) { + t.Helper() + oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager + oldEdition := edition.Get() + edition.Override(&edition.Hooks{}) + newAccessTokenProvider = func(string) accessTokenGetter { + return tokenManagerSnapshotProvider{load: load} + } + newLegacyTokenManager = func(string) legacyTokenGetter { + return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound} + } + t.Cleanup(func() { + newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy + edition.Override(oldEdition) + }) +} + +func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + token := "token-a" + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + + manager := NewTokenManager() + first, err := manager.Get(context.Background(), configDir, "") + if err != nil || first.AccessToken != "token-a" { + t.Fatalf("first token = %#v, %v", first, err) + } + second, err := manager.Get(context.Background(), configDir, "") + if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 { + t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load()) + } + + token = "token-b" + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + rotated, err := manager.Get(context.Background(), configDir, "") + if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 { + t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) { + configDir := t.TempDir() + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token"}, nil + }) + manager := NewTokenManager() + for range 2 { + if _, err := manager.Get(context.Background(), configDir, ""); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + manager := NewTokenManager() + for range 2 { + if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" { + t.Fatalf("snapshot = %#v, error = %v", snapshot, err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + edition.Override(&edition.Hooks{ + LoadToken: func(string) ([]byte, error) { return nil, nil }, + TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) { + return fallback() + }, + }) + manager := NewTokenManager() + for range 2 { + if _, err := manager.Get(context.Background(), configDir, ""); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 2 { + t.Fatalf("provider calls = %d, want 2", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) { + configDir := t.TempDir() + if err := authpkg.WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + release := make(chan struct{}) + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { + calls.Add(1) + <-release + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + }) + manager := NewTokenManager() + const workers = 8 + var wg sync.WaitGroup + wg.Add(workers) + errs := make(chan error, workers) + for range workers { + go func() { + defer wg.Done() + _, err := manager.Get(context.Background(), configDir, "") + errs <- err + }() + } + for calls.Load() == 0 { + time.Sleep(time.Millisecond) + } + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatalf("provider calls = %d, want 1", calls.Load()) + } +} + +func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) { + configDir := t.TempDir() + want := errors.New("keychain permission denied") + installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want }) + _, err := NewTokenManager().Get(context.Background(), configDir, "") + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } + if errors.Is(err, authpkg.ErrTokenDataNotFound) { + t.Fatalf("provider failure was misclassified as missing credentials: %v", err) + } +} + +func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) { + missing := tokenResolutionError(authpkg.ErrTokenDataNotFound) + var typed interface{ Unwrap() error } + if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) { + t.Fatalf("missing error = %v", missing) + } + want := errors.New("decrypt failed") + if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) { + t.Fatalf("storage error = %v", got) + } + if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) { + t.Fatalf("cancellation = %v", got) + } +} diff --git a/internal/app/access_token_resolve.go b/internal/app/access_token_resolve.go index e9a24e84..c5c969d9 100644 --- a/internal/app/access_token_resolve.go +++ b/internal/app/access_token_resolve.go @@ -21,19 +21,61 @@ import ( "log/slog" "path/filepath" "strings" + "sync" + "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +const accessTokenRefreshWindow = 5 * time.Minute + type legacyTokenGetter interface { GetToken() (string, string, error) } +type accessTokenSnapshotGetter interface { + GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) +} + +// AccessTokenSnapshot is the minimal bearer view needed by the process cache. +// Refresh-token material never leaves the auth package. +type AccessTokenSnapshot struct { + AccessToken string + ExpiresAt time.Time + Source string +} + +type tokenManagerKey struct { + configDir string + profile string +} + +type tokenManagerEntry struct { + mu sync.Mutex + snapshot AccessTokenSnapshot + revision string +} + +// TokenManager is the only process cache for user access tokens. Cache entries +// are isolated by config directory and profile, expiry-aware, and invalidated +// by the credential publication marker written by auth storage. +type TokenManager struct { + mu sync.Mutex + entries map[tokenManagerKey]*tokenManagerEntry + now func() time.Time +} + +func NewTokenManager() *TokenManager { + return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now} +} + +var runtimeTokenManager = NewTokenManager() + var ( newAccessTokenProvider = func(configDir string) accessTokenGetter { - disc := slog.New(slog.NewTextHandler(io.Discard, nil)) - provider := authpkg.NewOAuthProvider(configDir, disc) + discard := slog.New(slog.NewTextHandler(io.Discard, nil)) + provider := authpkg.NewOAuthProvider(configDir, discard) configureOAuthProviderCompatibility(provider, configDir) return provider } @@ -44,64 +86,218 @@ var ( } ) -// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying -// the same host compatibility hooks as MCP. It mirrors the former body of -// getCachedRuntimeToken (excluding process-level cache and timing). -func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) { - provider := newAccessTokenProvider(configDir) - token, tokenErr := provider.GetAccessToken(ctx) - if tokenErr == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token), nil - } - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - return "", tokenErr - } - if strings.TrimSpace(authpkg.RuntimeProfile()) != "" { - if tokenErr != nil { - return "", tokenErr - } - return "", nil - } - manager := newLegacyTokenManager(configDir) - if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" { - return strings.TrimSpace(leg), nil - } - if tokenErr != nil { - return "", tokenErr - } - return "", nil -} - -// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should -// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches -// the active edition config directory, the same process-cached path as MCP is used. -// Otherwise tokens are loaded from configDir with host compatibility hooks applied. -func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) { - if t := strings.TrimSpace(explicitToken); t != "" { - return t, nil +// Get resolves an access token for the active runtime profile. +func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) { + if token := strings.TrimSpace(explicitToken); token != "" { + return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil } if strings.TrimSpace(configDir) == "" { - return "", fmt.Errorf("config directory is empty") + return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty") } - if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) { - if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" { - return tok, nil + key := tokenManagerKey{ + configDir: canonicalTokenConfigDir(configDir), + profile: strings.TrimSpace(authpkg.RuntimeProfile()), + } + entry := m.entry(key) + entry.mu.Lock() + defer entry.mu.Unlock() + + now := time.Now() + if m != nil && m.now != nil { + now = m.now() + } + revision, present, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err + } + if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision { + return entry.snapshot, nil + } + + // Treat the marker and credential as one optimistic snapshot. A concurrent + // login/refresh between the reads causes a retry instead of caching stale A + // under the publication marker for B. + for attempt := 0; attempt < 4; attempt++ { + beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err } - return "", noCredentialsError() + snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile) + if err != nil { + return AccessTokenSnapshot{}, err + } + afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir) + if err != nil { + return AccessTokenSnapshot{}, err + } + if beforePresent != afterPresent || beforeRevision != afterRevision { + continue + } + if strings.TrimSpace(snapshot.AccessToken) == "" { + return AccessTokenSnapshot{}, noCredentialsError() + } + if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" { + entry.snapshot = snapshot + entry.revision = afterRevision + } else { + entry.snapshot = AccessTokenSnapshot{} + entry.revision = "" + } + return snapshot, nil } - tok, err := resolveAccessTokenFromDir(ctx, configDir) + return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials") +} + +func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry { + m.mu.Lock() + defer m.mu.Unlock() + if m.entries == nil { + m.entries = make(map[tokenManagerKey]*tokenManagerEntry) + } + entry := m.entries[key] + if entry == nil { + entry = &tokenManagerEntry{} + m.entries[key] = entry + } + return entry +} + +func (m *TokenManager) Invalidate() { + if m == nil { + return + } + m.mu.Lock() + m.entries = make(map[tokenManagerKey]*tokenManagerEntry) + m.mu.Unlock() +} + +func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) { + hooks := edition.Get() + opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil + provider := hooks.TokenProvider + if provider == nil { + snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile) + if err != nil { + return AccessTokenSnapshot{}, err + } + // Opaque edition storage hooks have no publication-revision contract. + // Resolve them on every logical request instead of caching a token that + // may be replaced outside the default auth store. + if opaqueStorage { + snapshot.ExpiresAt = time.Time{} + } + return snapshot, nil + } + var fallbackSnapshot AccessTokenSnapshot + var fallbackCalled bool + token, err := provider(ctx, func() (string, error) { + fallbackCalled = true + var fallbackErr error + fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile) + if fallbackErr != nil { + return "", fallbackErr + } + return fallbackSnapshot.AccessToken, nil + }) + if err != nil { + return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err) + } + token = strings.TrimSpace(token) + if token == "" { + return AccessTokenSnapshot{}, noCredentialsError() + } + if fallbackCalled && token == fallbackSnapshot.AccessToken { + if opaqueStorage { + fallbackSnapshot.ExpiresAt = time.Time{} + } + return fallbackSnapshot, nil + } + // Edition providers expose no lifetime metadata, so resolve them on every + // logical request instead of recreating a process-lifetime string cache. + return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil +} + +func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) { + provider := newAccessTokenProvider(configDir) + if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok { + data, err := snapshotProvider.GetTokenSnapshot(ctx) + if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" { + return AccessTokenSnapshot{ + AccessToken: strings.TrimSpace(data.AccessToken), + ExpiresAt: data.ExpiresAt, + Source: "oauth", + }, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if strings.TrimSpace(profile) != "" { + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound + } + return resolveLegacyToken(configDir, err) + } + + token, err := provider.GetAccessToken(ctx) + if err == nil && strings.TrimSpace(token) != "" { + return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if strings.TrimSpace(profile) != "" { + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound + } + return resolveLegacyToken(configDir, err) +} + +func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) { + token, source, err := newLegacyTokenManager(configDir).GetToken() + if err == nil && strings.TrimSpace(token) != "" { + return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil + } + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return AccessTokenSnapshot{}, err + } + if oauthErr != nil { + return AccessTokenSnapshot{}, oauthErr + } + return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound +} + +func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) { + snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile()) if err != nil { return "", err } - if tok != "" { - return tok, nil + return snapshot.AccessToken, nil +} + +// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through +// the same TokenManager used by MCP tool calls. +func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) { + snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken) + if err != nil { + return "", err } - return "", noCredentialsError() + return snapshot.AccessToken, nil +} + +func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool { + return strings.TrimSpace(snapshot.AccessToken) != "" && + !snapshot.ExpiresAt.IsZero() && + now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow)) +} + +func canonicalTokenConfigDir(configDir string) string { + if absolute, err := filepath.Abs(configDir); err == nil { + return filepath.Clean(absolute) + } + return filepath.Clean(configDir) } func noCredentialsError() error { if edition.Get().IsEmbedded { - return fmt.Errorf("认证信息已失效,请重新认证") + return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound) } - return fmt.Errorf("no credentials found, run: dws auth login") + return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound) } diff --git a/internal/app/app_small_coverage_test.go b/internal/app/app_small_coverage_test.go index ee6e313e..b46c4b7f 100644 --- a/internal/app/app_small_coverage_test.go +++ b/internal/app/app_small_coverage_test.go @@ -212,14 +212,16 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) { if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) { t.Fatalf("decryption error = %v", err) } - newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} } + newAccessTokenProvider = func(string) accessTokenGetter { + return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound} + } newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} } if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" { t.Fatalf("legacy token = %q, %v", got, err) } authpkg.SetRuntimeProfile("corp:user") t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) - if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" { + if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) { t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err) } authpkg.SetRuntimeProfile("") diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index b35c52da..2ea62f75 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -554,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) { defer server.Close() SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}}) t.Cleanup(func() { SetDynamicServers(nil) }) - runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())} + runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}} got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"}) if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 { t.Fatalf("recovery search = %#v %v", got, err) @@ -1650,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T } func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) { + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { authpkg.SetRuntimeProfile("") }) configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour), CorpID: "corp", UserID: "user", ClientID: "client", diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go index 5e314abf..78f4c485 100644 --- a/internal/app/coverage_more_test.go +++ b/internal/app/coverage_more_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) { } newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} } missing := t.TempDir() - if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" { + if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) { t.Fatalf("missing access token = %q, %v", got, err) } if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil { diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 0eff56d1..3e5434c1 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -413,7 +413,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string { return "portal-ticket-normal:" + sourceID } -func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { +func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { if !streamOpts.enabled() { return eventNewDingtalkSource(source.Config{ ClientID: clientID, @@ -421,14 +421,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin }) } - token, err := eventResolveAccessToken(ctx, configDir, "") - if err != nil { - return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err) - } - if strings.TrimSpace(token) == "" { - return nil, errors.New("event stream ticket: empty user token") - } - portalClientID := clientID portalClientSecret := clientSecret if streamOpts.usesPortalNormalMode() { @@ -440,8 +432,10 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin ClientID: portalClientID, ClientSecret: portalClientSecret, PortalTicket: &source.PortalTicketConfig{ - TicketURL: eventStreamTicketURL(streamOpts.TicketURL), - AccessToken: token, + TicketURL: eventStreamTicketURL(streamOpts.TicketURL), + AccessTokenProvider: func(ctx context.Context) (string, error) { + return eventResolveAccessToken(ctx, configDir, "") + }, SourceID: eventStreamSourceID(streamOpts.SourceID), Mode: streamOpts.Mode, ClientID: portalClientID, diff --git a/internal/app/event_command_full_coverage_test.go b/internal/app/event_command_full_coverage_test.go index 5dc63ce8..ef271889 100644 --- a/internal/app/event_command_full_coverage_test.go +++ b/internal/app/event_command_full_coverage_test.go @@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) { if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil { t.Fatal(err) } - eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail } stream := eventStreamTicketOptions{Mode: "custom"} - if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) { - t.Fatalf("stream token error = %v", err) + var captured source.Config + eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) { + captured = cfg + return &source.DingtalkSource{}, nil } - eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil } - if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil { - t.Fatal("empty stream token succeeded") + eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail } + if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil { + t.Fatalf("stream source construction = %v", err) + } + if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) { + t.Fatalf("stream token provider error = %v", err) } eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil } for _, mode := range []string{"custom", "normal"} { diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 7ef4caf0..2d39c840 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -259,7 +259,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro return personalConsumeRun(ctx, cfg) } - client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts) if err != nil { return fmt.Errorf("event consume --as user: %w", err) @@ -498,7 +498,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error if status == "" || status == "all" { status = "" } - subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{ + subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{ Status: status, EventKey: opts.EventKey, SubscribeID: opts.SubscribeID, @@ -613,7 +613,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { if err != nil { return fmt.Errorf("event stop --as user: %w", err) } - client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) for _, id := range subscribeIDs { if err := personalDeleteSubscription(client, ctx, id); err != nil { return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err) @@ -723,7 +723,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI if err != nil { return personal.Identity{}, err } - tokenData, _ := personalLoadTokenData(configDir) + tokenData, err := personalLoadTokenData(configDir) + if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { + return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err) + } var corpID, userID, clientID, refreshToken string if tokenData != nil { corpID = tokenData.CorpID @@ -769,6 +772,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI }, nil } +func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client { + identity.AccessToken = "" + client := personal.NewClient(baseURL, identity) + client.AccessTokenProvider = func(ctx context.Context) (string, error) { + return personalResolveAuxiliaryAccessToken(ctx, configDir, "") + } + return client +} + func personalTokenSubject(kind, token string) string { token = strings.TrimSpace(token) if token == "" { @@ -817,7 +829,9 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio } _ = ctx return source.NewPersonal(source.PersonalConfig{ - AccessToken: opts.Identity.AccessToken, + AccessTokenProvider: func(ctx context.Context) (string, error) { + return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "") + }, ClientID: clientID, ClientSecret: clientSecret, SourceID: opts.Identity.SourceID, diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index dd3cffad..cff16e16 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser var ( patAuthorizationTimeout = PatAuthRetryTimeout patAuthorizationPollInterval = PatAuthPollInterval - patLoadTokenData = authpkg.LoadTokenData + patResolveAccessToken = ResolveAuxiliaryAccessToken patWaitForAuthorization = WaitForPatAuthorization patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval patSaveAppConfig = authpkg.SaveAppConfig @@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string { // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. -func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { +func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) { timeout := patAuthorizationTimeout deadline := time.Now().Add(timeout) pollTicker := time.NewTicker(patAuthorizationPollInterval) @@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr select { case <-ctx.Done(): fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error")) - return false + return false, ctx.Err() case <-time.After(time.Until(deadline)): fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout) fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ")) - return false + return false, nil case <-pollTicker.C: pollCount++ elapsed := time.Since(start).Truncate(time.Second) remaining := time.Until(deadline).Truncate(time.Second) - // Check if token is now valid - tokenData, err := patLoadTokenData(configDir) - if err == nil && tokenData != nil { - if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { - fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", - tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) - fmt.Fprintln(output) - return true - } + // Check the same resolver used by every outbound bearer request. + if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil { + fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", + tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) + fmt.Fprintln(output) + return true, nil + } else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) { + return false, fmt.Errorf("check authorization token: %w", err) } // Show polling status @@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati PrintPatAuthError(output, scopeErr) // Wait for user to complete authorization - authorized := patWaitForAuthorization(ctx, configDir, output) + authorized, waitErr := patWaitForAuthorization(ctx, configDir, output) + if waitErr != nil { + return executor.Result{}, waitErr + } if !authorized { return executor.Result{}, apperrors.NewAuth( "等待用户授权超时", @@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir pollURL := fmt.Sprintf("%s%s?flowId=%s", authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID)) - // Load user access token for the poll request header. - var accessToken string - if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil { - accessToken = tokenData.AccessToken - } - // Use a client that does NOT follow redirects, so we can detect SSO 302. noRedirectClient := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir slog.Debug("PAT poll: failed to create request", "error", err) continue } + accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "") + if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) { + return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr) + } if accessToken != "" { req.Header.Set("x-user-access-token", accessToken) } diff --git a/internal/app/pat_auth_retry_full_coverage_test.go b/internal/app/pat_auth_retry_full_coverage_test.go index 6023e89e..0fbb89d3 100644 --- a/internal/app/pat_auth_retry_full_coverage_test.go +++ b/internal/app/pat_auth_retry_full_coverage_test.go @@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T) oldTimeout := patAuthorizationTimeout oldInterval := patAuthorizationPollInterval - oldLoad := patLoadTokenData + oldResolve := patResolveAccessToken t.Cleanup(func() { patAuthorizationTimeout = oldTimeout patAuthorizationPollInterval = oldInterval - patLoadTokenData = oldLoad + patResolveAccessToken = oldResolve }) patAuthorizationTimeout = 50 * time.Millisecond patAuthorizationPollInterval = time.Millisecond - patLoadTokenData = func(string) (*authpkg.TokenData, error) { - return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + patResolveAccessToken = func(context.Context, string, string) (string, error) { + return "token", nil } out.Reset() - if !WaitForPatAuthorization(context.Background(), "", &out) { + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok { t.Fatal("valid token did not authorize") } ctx, cancel := context.WithCancel(context.Background()) cancel() out.Reset() - if WaitForPatAuthorization(ctx, "", &out) { - t.Fatal("cancelled authorization succeeded") + if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled authorization = %v, %v", ok, err) } patAuthorizationTimeout = time.Millisecond patAuthorizationPollInterval = time.Hour out.Reset() - if WaitForPatAuthorization(context.Background(), "", &out) { - t.Fatal("timed out authorization succeeded") + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok { + t.Fatalf("timed out authorization = %v, %v", ok, err) } patAuthorizationTimeout = 5 * time.Millisecond patAuthorizationPollInterval = time.Millisecond - patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } + patResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } out.Reset() - if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") { - t.Fatalf("invalid-token polling output = %q", out.String()) + if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") { + t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String()) } } @@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing. t.Setenv("DWS_CONFIG_DIR", t.TempDir()) scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"} - patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false } + patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil } if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil { t.Fatal("PAT retry timeout succeeded") } wantErr := errors.New("runner failed") - patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true } + patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil } if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) { t.Fatalf("authorized retry = %v", err) } @@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string { func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) { oldDo := patPollHTTPDo oldRequest := patPollNewRequest - oldLoad := patLoadTokenData + oldResolve := patResolveAccessToken oldBrowser := patBrowserOpenCommand t.Cleanup(func() { patPollHTTPDo = oldDo patPollNewRequest = oldRequest - patLoadTokenData = oldLoad + patResolveAccessToken = oldResolve patBrowserOpenCommand = oldBrowser }) - patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil } + patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil } cancelled, cancelNow := context.WithCancel(context.Background()) cancelNow() if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled { diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 4f1f2989..b67d07e0 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName if err != nil { return nil, err } - tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders()) + authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)) + if err != nil { + return nil, tokenResolutionError(err) + } + tc := r.transport.WithAuth(authToken, resolveIdentityHeaders()) result, err := tc.CallTool(ctx, endpoint, toolName, args) if err != nil { return nil, err diff --git a/internal/app/runner.go b/internal/app/runner.go index 5437e642..b09bf90e 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -235,7 +235,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc // ~70ms on macOS; starting it here lets the load overlap with endpoint // resolution and catalog loading below. if prefetchToken { - go runnerGetCachedRuntimeToken(ctx) + go func() { + _, _ = runnerGetCachedRuntimeToken(ctx) + }() } if shouldUseDirectRuntime(invocation) { @@ -534,8 +536,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, authToken := "" if hasPluginAuth { authToken = pluginAuth.Token - } else { - authToken = r.resolveAuthToken(ctx) + } else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) { + var tokenErr error + authToken, tokenErr = r.resolveAuthToken(ctx) + if tokenErr != nil { + return executor.Result{}, tokenResolutionError(tokenErr) + } } var timeoutSec int @@ -796,67 +802,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e }, nil } -func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string { +func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) { explicitToken := "" if r != nil && r.globalFlags != nil { explicitToken = r.globalFlags.Token } - if token := strings.TrimSpace(explicitToken); token != "" { - return token - } - if tp := edition.Get().TokenProvider; tp != nil { - token, _ := tp(ctx, func() (string, error) { - return resolveAccessTokenFromDir(ctx, defaultConfigDir()) - }) - return token - } - return getCachedRuntimeToken(ctx) + return resolveRuntimeAuthToken(ctx, explicitToken) } -func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string { - if token := strings.TrimSpace(explicitToken); token != "" { - return token +func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) { + snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken) + if err != nil { + return "", err } - // Use cached token to avoid repeated Keychain access (~70ms per call) - return getCachedRuntimeToken(ctx) + return snapshot.AccessToken, nil } -// Cached token state for process lifetime -var ( - cachedRuntimeTokenMu sync.Mutex - cachedRuntimeTokens = map[string]string{} -) - -// getCachedRuntimeToken returns a cached access token, loading it only once per process. -// This avoids repeated Keychain access which takes ~70ms each time. -func getCachedRuntimeToken(ctx context.Context) string { - cacheKey := strings.TrimSpace(authpkg.RuntimeProfile()) - if cacheKey == "" { - cacheKey = "__default__" - } - cachedRuntimeTokenMu.Lock() - if token := cachedRuntimeTokens[cacheKey]; token != "" { - cachedRuntimeTokenMu.Unlock() - return token - } - cachedRuntimeTokenMu.Unlock() - +// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The +// cache itself lives exclusively in TokenManager. +func getCachedRuntimeToken(ctx context.Context) (string, error) { loadStart := time.Now() defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }() + return resolveRuntimeAuthToken(ctx, "") +} - configDir := defaultConfigDir() - token, tokenErr := resolveAccessTokenFromDir(ctx, configDir) - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - slog.Error(tokenErr.Error()) - return "" +func tokenResolutionError(err error) error { + if err == nil { + return nil } - if token == "" { - return "" + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err } - cachedRuntimeTokenMu.Lock() - cachedRuntimeTokens[cacheKey] = token - cachedRuntimeTokenMu.Unlock() - return token + if errors.Is(err, authpkg.ErrTokenDataNotFound) { + return apperrors.NewAuth( + "未登录,请先执行 dws auth login", + apperrors.WithReason("not_authenticated"), + apperrors.WithHint("运行 'dws auth login' 完成登录后重试"), + apperrors.WithActions("dws auth login"), + apperrors.WithCause(err), + ) + } + // Keychain, parse, permission, lock, and refresh failures are real local or + // network errors. Preserve their cause instead of disguising them as logout. + return fmt.Errorf("resolve access token: %w", err) } // generateExecutionID returns a random 16-char hex string used to correlate @@ -871,9 +859,7 @@ func generateExecutionID() string { // ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. // This should be called after login/logout operations. func ResetRuntimeTokenCache() { - cachedRuntimeTokenMu.Lock() - defer cachedRuntimeTokenMu.Unlock() - cachedRuntimeTokens = map[string]string{} + runtimeTokenManager.Invalidate() } func newRuntimeContentScanner() safety.Scanner { diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 550ec4a5..632ac05f 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) { inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"} prefetched := make(chan struct{}, 1) - runnerGetCachedRuntimeToken = func(context.Context) string { + runnerGetCachedRuntimeToken = func(context.Context) (string, error) { prefetched <- struct{}{} - return "" + return "", nil } r := &runtimeRunner{ loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr), @@ -329,19 +329,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test } r.globalFlags.Token = " explicit " - if got := r.resolveAuthToken(context.Background()); got != "explicit" { - t.Fatalf("explicit auth token = %q", got) + if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" { + t.Fatalf("explicit auth token = %q, %v", got, err) } edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) { _, _ = fallback() return "provided", nil }}) r.globalFlags.Token = "" - if got := r.resolveAuthToken(context.Background()); got != "provided" { - t.Fatalf("provided auth token = %q", got) + if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" { + t.Fatalf("provided auth token = %q, %v", got, err) } - if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" { - t.Fatalf("runtime explicit token = %q", got) + if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" { + t.Fatalf("runtime explicit token = %q, %v", got, err) } t.Setenv(envDWSChannel, "channel") diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index c4d63a21..81a39d50 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -17,6 +17,7 @@ import ( "archive/zip" "context" "encoding/json" + "errors" "fmt" "io" "mime" @@ -36,25 +37,25 @@ import ( ) var ( - skillLoadAccessToken = loadSkillAccessToken - skillDownloadToTmp = downloadSkillToTmpDir - skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } - skillNewRequest = http.NewRequestWithContext - skillLoadTokenData = authpkg.LoadTokenData - skillResolveTargetPath = resolveSkillTargetPath - skillFetchDownloadInfo = fetchSkillDownloadInfo - skillDownloadFile = downloadSkillFile - skillExtractZip = extractSkillZip - skillUserHomeDir = os.UserHomeDir - skillMkdirTemp = os.MkdirTemp - skillCreate = os.Create - skillCreateTemp = os.CreateTemp - skillRemoveAll = os.RemoveAll - skillRemove = os.Remove - skillMkdirAll = os.MkdirAll - skillOpenFile = os.OpenFile - skillCopy = io.Copy - skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() } + skillLoadAccessToken = loadSkillAccessToken + skillDownloadToTmp = downloadSkillToTmpDir + skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } + skillNewRequest = http.NewRequestWithContext + skillResolveAccessToken = ResolveAuxiliaryAccessToken + skillResolveTargetPath = resolveSkillTargetPath + skillFetchDownloadInfo = fetchSkillDownloadInfo + skillDownloadFile = downloadSkillFile + skillExtractZip = extractSkillZip + skillUserHomeDir = os.UserHomeDir + skillMkdirTemp = os.MkdirTemp + skillCreate = os.Create + skillCreateTemp = os.CreateTemp + skillRemoveAll = os.RemoveAll + skillRemove = os.Remove + skillMkdirAll = os.MkdirAll + skillOpenFile = os.OpenFile + skillCopy = io.Copy + skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() } ) func init() { @@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command { func runSkillGet(cmd *cobra.Command, args []string) error { skillID, _ := cmd.Flags().GetString("skill-id") - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error { if source == "" { source, _ = cmd.Flags().GetString("scopes") } - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets())) } - accessToken, err := skillLoadAccessToken() + accessToken, err := skillLoadAccessToken(cmd.Context()) if err != nil { return err } @@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { return nil } -func loadSkillAccessToken() (string, error) { +func loadSkillAccessToken(ctx context.Context) (string, error) { configDir := defaultConfigDir() - tokenData, err := skillLoadTokenData(configDir) - if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() { + token, err := skillResolveAccessToken(ctx, configDir, "") + if errors.Is(err, authpkg.ErrTokenDataNotFound) { return "", skillAuthError() } - return tokenData.AccessToken, nil + if err != nil { + return "", fmt.Errorf("resolve skill access token: %w", err) + } + return token, nil } func skillAuthError() error { diff --git a/internal/app/skill_command_full_coverage_test.go b/internal/app/skill_command_full_coverage_test.go index d2331c3d..e686867b 100644 --- a/internal/app/skill_command_full_coverage_test.go +++ b/internal/app/skill_command_full_coverage_test.go @@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. }) fail := errors.New("failure") cmd := skillCoverageCommand() - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillGet(cmd, nil); !errors.Is(err, fail) { t.Fatalf("skill get auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } if err := runSkillFind(cmd, nil); err == nil { t.Fatal("skill find request failure should propagate") @@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. t.Fatalf("skill get download error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillFind(cmd, nil); !errors.Is(err, fail) { t.Fatalf("skill find auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } if err := runSkillFind(cmd, nil); err == nil { t.Fatal("skill find network failure should propagate") @@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. t.Fatal("invalid skill target should fail") } skillResolveTargetPath = func(string) (string, error) { return "dest", nil } - skillLoadAccessToken = func() (string, error) { return "", fail } + skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { t.Fatalf("skill add auth error = %v", err) } - skillLoadAccessToken = func() (string, error) { return "token", nil } + skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil } skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail } if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { t.Fatalf("skill info error = %v", err) @@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) { oldHTTP := skillHTTPDo - oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData + oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken oldHome := skillUserHomeDir oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile t.Cleanup(func() { skillHTTPDo = oldHTTP - skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken + skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken skillUserHomeDir = oldHome skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen }) fail := errors.New("failure") - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } - if _, err := loadSkillAccessToken(); err == nil { + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } + if _, err := loadSkillAccessToken(context.Background()); err == nil { t.Fatal("invalid skill access token succeeded") } - skillLoadTokenData = oldLoadToken + canceled, cancel := context.WithCancel(context.Background()) + cancel() + skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) { + return "", ctx.Err() + } + if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("skill token cancellation = %v", err) + } + skillResolveAccessToken = oldResolveToken skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { t.Fatal("download-info request failure should propagate") diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 7caa94b3..1fd5094e 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -18,7 +18,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "os" @@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) { configDir := filepath.Join(tempDir, "config") t.Setenv("DWS_CONFIG_DIR", configDir) t.Cleanup(CloseFileLogger) - originalLoadToken := skillLoadTokenData - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") } - t.Cleanup(func() { skillLoadTokenData = originalLoadToken }) + originalResolveToken := skillResolveAccessToken + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + } + t.Cleanup(func() { skillResolveAccessToken = originalResolveToken }) // Ensure the config directory exists but has no token if err := os.MkdirAll(configDir, 0755); err != nil { @@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) { configDir := filepath.Join(t.TempDir(), "config") t.Setenv("DWS_CONFIG_DIR", configDir) t.Cleanup(CloseFileLogger) - originalLoadToken := skillLoadTokenData - skillLoadTokenData = func(string) (*authpkg.TokenData, error) { - return &authpkg.TokenData{ - AccessToken: "test-token", - RefreshToken: "refresh-token", - ExpiresAt: time.Now().Add(time.Hour), - RefreshExpAt: time.Now().Add(24 * time.Hour), - }, nil + originalResolveToken := skillResolveAccessToken + skillResolveAccessToken = func(context.Context, string, string) (string, error) { + return "test-token", nil } - t.Cleanup(func() { skillLoadTokenData = originalLoadToken }) + t.Cleanup(func() { skillResolveAccessToken = originalResolveToken }) var gotSources []string var gotScopes []string diff --git a/internal/auth/access_token_snapshot_test.go b/internal/auth/access_token_snapshot_test.go new file mode 100644 index 00000000..4db27780 --- /dev/null +++ b/internal/auth/access_token_snapshot_test.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) { + oldLoad := oauthLoadToken + want := errors.New("keychain permission denied") + oauthLoadToken = func(string) (*TokenData, error) { return nil, want } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + _, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background()) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } + if errors.Is(err, ErrTokenDataNotFound) { + t.Fatalf("load failure was misclassified as missing credentials: %v", err) + } +} + +func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) { + oldLoad := oauthLoadToken + want := errors.New("keychain permission denied") + oauthLoadToken = func(string) (*TokenData, error) { return nil, want } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + _, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want cause %v", err, want) + } +} + +func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) { + oldLoad := oauthLoadToken + expiresAt := time.Now().Add(time.Hour) + oauthLoadToken = func(string) (*TokenData, error) { + return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil + } + t.Cleanup(func() { oauthLoadToken = oldLoad }) + + snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background()) + if err != nil { + t.Fatal(err) + } + if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) { + configDir := t.TempDir() + if err := WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + first, present, err := ReadTokenMarkerRevision(configDir) + if err != nil || !present || first == "" { + t.Fatalf("first marker = %q, %v, %v", first, present, err) + } + if err := WriteTokenMarker(configDir); err != nil { + t.Fatal(err) + } + second, present, err := ReadTokenMarkerRevision(configDir) + if err != nil || !present || second == "" || second == first { + t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first) + } +} diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 41c6298b..4249bbcf 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) { } return token, "file", nil } - - return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login")) + if err != nil && !os.IsNotExist(err) { + return "", "", fmt.Errorf("load legacy token: %w", err) + } + return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound) } func (m *Manager) GetMCPURL() (string, error) { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index a9d070d2..4b0f778d 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -115,6 +115,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // Smart degradation: try silent refresh before opening browser. if !force { data, err := oauthLoadToken(p.configDir) + if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) { + if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr) + } + return nil, fmt.Errorf("load existing access token: %w", err) + } if err == nil { // Case 1: access_token still valid — no action needed. if data.IsAccessTokenValid() { @@ -636,36 +642,50 @@ continueLogin: return tokenData, nil } -// GetAccessToken returns a valid access token, auto-refreshing if needed. -// Uses a file lock with double-check pattern to prevent concurrent refresh -// from multiple CLI processes. -func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { +// GetTokenSnapshot returns a valid token together with its expiry metadata. +// Storage and refresh failures retain their original cause; only a confirmed +// missing credential is reported as ErrTokenDataNotFound. +func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) { data, err := oauthLoadToken(p.configDir) if err != nil { - return "", errors.New(i18n.T("未登录,请运行 dws auth login")) + if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) { + return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound) + } + return nil, fmt.Errorf("load access token: %w", err) } // Fast path: access_token still valid — no lock needed. if data.IsAccessTokenValid() { - return data.AccessToken, nil + return data, nil } // Slow path: token expired — try locked refresh. if data.IsRefreshTokenValid() { refreshed, rErr := p.lockedRefresh(ctx) if rErr == nil { - return refreshed.AccessToken, nil + return refreshed, nil } _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } - return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) + return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr) } else { _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) } - return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录")) + return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound) +} + +// GetAccessToken returns a valid access token, auto-refreshing if needed. +// Uses a file lock with double-check pattern to prevent concurrent refresh +// from multiple CLI processes. +func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { + data, err := p.GetTokenSnapshot(ctx) + if err != nil { + return "", err + } + return strings.TrimSpace(data.AccessToken), nil } // lockedRefresh attempts to refresh the token while holding dual-layer locks. diff --git a/internal/auth/token.go b/internal/auth/token.go index 94a24fb9..03c02171 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -127,6 +127,10 @@ const tokenJSONFile = "token.json" type TokenMarker struct { UpdatedAt string `json:"updated_at"` ManualToken bool `json:"manual_token,omitempty"` + // Revision changes on every credential publication. Runtime token caches + // use it as a cheap cross-process invalidation signal without reading the + // platform keychain on every request. + Revision string `json:"revision,omitempty"` } // WriteTokenMarker writes a token.json marker containing only an updated_at @@ -147,6 +151,7 @@ func writeTokenMarker(configDir string, manual bool) error { marker := TokenMarker{ UpdatedAt: time.Now().Format(time.RFC3339), ManualToken: manual, + Revision: uuid.NewString(), } data, _ := tokenJSONMarshalIndent(marker, "", " ") if err := tokenMkdirAll(configDir, 0o700); err != nil { @@ -159,6 +164,27 @@ func writeTokenMarker(configDir string, manual bool) error { return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile)) } +// ReadTokenMarkerRevision returns the current credential publication revision. +// Existing markers without a revision remain readable, but callers must avoid +// caching them because they cannot prove that the credential is unchanged. +func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) { + data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("read token marker: %w", err) + } + var marker TokenMarker + if err := json.Unmarshal(data, &marker); err != nil { + // The marker is only a cache-coherency hint. A malformed historical or + // externally modified marker must disable caching, not make an otherwise + // valid credential unusable. + return "", true, nil + } + return strings.TrimSpace(marker.Revision), true, nil +} + func manualTokenMarkerActive(configDir string) (bool, error) { data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) if err != nil { diff --git a/internal/event/personal/client.go b/internal/event/personal/client.go index 5f973f22..5c7ab042 100644 --- a/internal/event/personal/client.go +++ b/internal/event/personal/client.go @@ -63,9 +63,10 @@ func (i Identity) Key() string { } type Client struct { - BaseURL string - HTTPClient *http.Client - Identity Identity + BaseURL string + HTTPClient *http.Client + Identity Identity + AccessTokenProvider func(context.Context) (string, error) } type CreateSubscriptionRequest struct { @@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body if c == nil { return errors.New("personal event: nil client") } - if c.Identity.AccessToken == "" { - return errors.New("personal event: access token is required") + accessToken, err := c.resolveAccessToken(ctx) + if err != nil { + return err } u := strings.TrimRight(c.BaseURL, "/") + path if len(q) > 0 { @@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body if err != nil { return fmt.Errorf("personal event: create request: %w", err) } - c.decorate(req) + c.decorate(req, accessToken) if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body return json.Unmarshal(data, out) } -func (c *Client) decorate(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken) - req.Header.Set("x-user-access-token", c.Identity.AccessToken) +func (c *Client) resolveAccessToken(ctx context.Context) (string, error) { + if c.AccessTokenProvider != nil { + token, err := c.AccessTokenProvider(ctx) + if err != nil { + return "", fmt.Errorf("personal event: resolve access token: %w", err) + } + if token = strings.TrimSpace(token); token != "" { + return token, nil + } + return "", errors.New("personal event: access token provider returned empty token") + } + if token := strings.TrimSpace(c.Identity.AccessToken); token != "" { + return token, nil + } + return "", errors.New("personal event: access token is required") +} + +func (c *Client) decorate(req *http.Request, accessToken string) { + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("x-user-access-token", accessToken) req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID) req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID) if c.Identity.CorpID != "" { diff --git a/internal/event/personal/token_provider_test.go b/internal/event/personal/token_provider_test.go new file mode 100644 index 00000000..25667ba4 --- /dev/null +++ b/internal/event/personal/token_provider_test.go @@ -0,0 +1,56 @@ +package personal + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type accessTokenRoundTripper func(*http.Request) (*http.Response, error) + +func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) { + tokens := []string{"token-a", "token-b"} + calls := 0 + client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"}) + client.AccessTokenProvider = func(context.Context) (string, error) { + token := tokens[calls] + calls++ + return token, nil + } + client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) { + want := tokens[calls-1] + if got := req.Header.Get("Authorization"); got != "Bearer "+want { + t.Fatalf("Authorization = %q, want Bearer %s", got, want) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil + })} + for range 2 { + if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil { + t.Fatal(err) + } + } + if calls != 2 { + t.Fatalf("provider calls = %d, want 2", calls) + } +} + +func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) { + want := errors.New("keychain failed") + client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"}) + client.AccessTokenProvider = func(context.Context) (string, error) { return "", want } + client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) { + t.Fatal("HTTP must not run after token provider failure") + return nil, nil + })} + _, err := client.ListSubscriptions(context.Background(), ListOptions{}) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go index 1d4233a6..ec7e1a60 100644 --- a/internal/event/source/personal.go +++ b/internal/event/source/personal.go @@ -41,19 +41,22 @@ const ( ) type PersonalConfig struct { - AccessToken string - ClientID string - ClientSecret string - SourceID string - TicketURL string - TicketMode string - HTTPClient *http.Client - WebSocketDialer *websocket.Dialer - Now func() time.Time - ReconnectMin time.Duration - ReconnectMax time.Duration + AccessToken string + AccessTokenProvider AccessTokenProvider + ClientID string + ClientSecret string + SourceID string + TicketURL string + TicketMode string + HTTPClient *http.Client + WebSocketDialer *websocket.Dialer + Now func() time.Time + ReconnectMin time.Duration + ReconnectMax time.Duration } +type AccessTokenProvider func(context.Context) (string, error) + type PersonalSource struct { cfg PersonalConfig machine *Machine @@ -73,8 +76,8 @@ type ticketResponse struct { } func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) { - if strings.TrimSpace(cfg.AccessToken) == "" { - return nil, errors.New("personal source: AccessToken is required") + if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" { + return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required") } if strings.TrimSpace(cfg.ClientID) == "" { return nil, errors.New("personal source: ClientID is required") @@ -192,6 +195,10 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) ( } func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) { + accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source") + if err != nil { + return nil, err + } body := map[string]any{ "sourceId": s.cfg.SourceID, "mode": s.cfg.TicketMode, @@ -207,8 +214,8 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("x-user-access-token", s.cfg.AccessToken) - req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken) + req.Header.Set("x-user-access-token", accessToken) + req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID) req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID) @@ -238,6 +245,23 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro return ticket, nil } +func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) { + if provider != nil { + token, err := provider(ctx) + if err != nil { + return "", fmt.Errorf("%s: resolve access token: %w", component, err) + } + if token = strings.TrimSpace(token); token != "" { + return token, nil + } + return "", fmt.Errorf("%s: access token provider returned empty token", component) + } + if token := strings.TrimSpace(fallback); token != "" { + return token, nil + } + return "", fmt.Errorf("%s: access token is required", component) +} + func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error { df, err := payload.DecodeDataFrame(data) if err != nil { diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go index 7f5444e9..9a559356 100644 --- a/internal/event/source/portal_ticket.go +++ b/internal/event/source/portal_ticket.go @@ -39,14 +39,15 @@ const ( // normal mode uses portal-side managed credentials; custom mode asks portal to // open the user connection with the caller-provided clientId/clientSecret. type PortalTicketConfig struct { - TicketURL string - AccessToken string - SourceID string - Mode string - ClientID string - ClientSecret string - UserAgent string - HTTPClient *http.Client + TicketURL string + AccessToken string + AccessTokenProvider AccessTokenProvider + SourceID string + Mode string + ClientID string + ClientSecret string + UserAgent string + HTTPClient *http.Client } var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error { @@ -60,8 +61,8 @@ func (c *PortalTicketConfig) Valid() error { if strings.TrimSpace(c.TicketURL) == "" { return errors.New("source: portal ticket URL is required") } - if strings.TrimSpace(c.AccessToken) == "" { - return errors.New("source: portal access token is required") + if c.AccessTokenProvider == nil && strings.TrimSpace(c.AccessToken) == "" { + return errors.New("source: portal access token or provider is required") } if strings.TrimSpace(c.SourceID) == "" { return errors.New("source: portal sourceId is required") @@ -161,6 +162,10 @@ type portalStreamTicket struct { } func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) { + accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket") + if err != nil { + return portalStreamTicket{}, err + } httpClient := cfg.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: 20 * time.Second} @@ -184,7 +189,7 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt if ua := strings.TrimSpace(cfg.UserAgent); ua != "" { req.Header.Set("User-Agent", ua) } - req.Header.Set("x-user-access-token", cfg.AccessToken) + req.Header.Set("x-user-access-token", accessToken) resp, err := httpClient.Do(req) if err != nil { diff --git a/internal/event/source/token_provider_test.go b/internal/event/source/token_provider_test.go new file mode 100644 index 00000000..174b2416 --- /dev/null +++ b/internal/event/source/token_provider_test.go @@ -0,0 +1,66 @@ +package source + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type tokenProviderRoundTripper func(*http.Request) (*http.Response, error) + +func (f tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestCrossPlatformCoveragePersonalSourceResolvesTokenForEveryTicketRequest(t *testing.T) { + tokens := []string{"token-a", "token-b"} + calls := 0 + source, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + token := tokens[calls] + calls++ + return token, nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(req *http.Request) (*http.Response, error) { + want := tokens[calls-1] + if got := req.Header.Get("x-user-access-token"); got != want { + t.Fatalf("token header = %q, want %q", got, want) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := source.fetchTicket(context.Background()); err != nil { + t.Fatal(err) + } + } + if calls != 2 { + t.Fatalf("provider calls = %d, want 2", calls) + } +} + +func TestCrossPlatformCoveragePortalTicketProviderFailureStopsBeforeHTTP(t *testing.T) { + want := errors.New("token store failed") + httpCalled := false + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://ticket.test", + AccessTokenProvider: func(context.Context) (string, error) { return "", want }, + SourceID: "source", + HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(*http.Request) (*http.Response, error) { + httpCalled = true + return nil, errors.New("unexpected HTTP") + })}, + }) + if !errors.Is(err, want) || httpCalled { + t.Fatalf("request error = %v, httpCalled=%v", err, httpCalled) + } +} From b0ded7deb83dae28e8a7efd07cff650c88bd9ed0 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 13:37:18 +0800 Subject: [PATCH 10/67] fix(auth): retry rejected access tokens safely --- internal/app/app_small_coverage_test.go | 18 +- internal/app/auth_refresh_retry.go | 136 +++++ .../app/auth_refresh_retry_runner_test.go | 354 ++++++++++++ internal/app/force_refresh.go | 43 +- internal/app/runner.go | 26 +- internal/app/runner_full_coverage_test.go | 2 +- internal/auth/force_refresh.go | 234 +++++++- internal/auth/oauth_provider.go | 2 +- internal/auth/rejected_token_refresh_test.go | 510 ++++++++++++++++++ internal/auth/token.go | 22 +- 10 files changed, 1310 insertions(+), 37 deletions(-) create mode 100644 internal/app/auth_refresh_retry_runner_test.go create mode 100644 internal/auth/rejected_token_refresh_test.go diff --git a/internal/app/app_small_coverage_test.go b/internal/app/app_small_coverage_test.go index b46c4b7f..a81497b7 100644 --- a/internal/app/app_small_coverage_test.go +++ b/internal/app/app_small_coverage_test.go @@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) { return g.token, g.err } +func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) { + return g.token, g.err +} + type fakeLegacyTokenGetter struct { token string err error @@ -248,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) { } func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) { - oldMark, oldFactory := markAccessTokenStale, newRefreshProvider + oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider oldStop := stopStdio t.Cleanup(func() { - markAccessTokenStale, newRefreshProvider = oldMark, oldFactory + loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory stopStdio = oldStop stdioMu.Lock() stdioClients = make(map[string]*transport.StdioClient) @@ -259,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) }) fail := errors.New("failure") _ = oldFactory(t.TempDir()) - markAccessTokenStale = func(string) error { return fail } + loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail } if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) { - t.Fatalf("mark stale error = %v", err) + t.Fatalf("load rejected token error = %v", err) + } + loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{AccessToken: "rejected"}, nil } - markAccessTokenStale = func(string) error { return nil } for _, tc := range []struct { getter fakeAccessTokenGetter want string @@ -272,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {getter: fakeAccessTokenGetter{token: " "}, want: "empty"}, {getter: fakeAccessTokenGetter{token: " refreshed "}}, } { - newRefreshProvider = func(string) accessTokenGetter { return tc.getter } + newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter } got, err := ForceRefreshAccessToken(context.Background(), "config") if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { t.Fatalf("refresh error = %v, want %q", err, tc.want) diff --git a/internal/app/auth_refresh_retry.go b/internal/app/auth_refresh_retry.go index 3fbd63d1..8712f16d 100644 --- a/internal/app/auth_refresh_retry.go +++ b/internal/app/auth_refresh_retry.go @@ -15,6 +15,15 @@ package app import ( "context" + "errors" + "fmt" + "log/slog" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry" ) // authRetryingKey marks a context that has already attempted one @@ -23,8 +32,35 @@ import ( // to the user instead. type authRetryingKeyType struct{} +type authRefreshFailureError struct { + rejection error + refresh error +} + +func (e *authRefreshFailureError) Error() string { + return "automatic access token refresh failed" +} + +func (e *authRefreshFailureError) Unwrap() []error { + if e == nil { + return nil + } + return []error{e.rejection, e.refresh} +} + var authRetryingKey = authRetryingKeyType{} +var ( + runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken + runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error) +) + +func init() { + runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) { + return r.executeInvocation(ctx, endpoint, invocation) + } +} + // IsAuthRetrying reports whether the current context is already inside an // AuthRefreshRequired retry. Mirrors IsPatRetrying. func IsAuthRetrying(ctx context.Context) bool { @@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool { v, _ := ctx.Value(authRetryingKey).(bool) return v } + +func withAuthRetrying(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, authRetryingKey, true) +} + +func authRefreshLogger() *slog.Logger { + if logger := FileLoggerInstance(); logger != nil { + return logger + } + return slog.Default() +} + +func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool { + if r == nil || hasPluginAuth { + return false + } + return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == "" +} + +// retryAuthRefreshRequired consumes only the explicit edition marker. It does +// not infer retryability from free text, generic auth categories, HTTP 403, or +// ordinary business errors. +func (r *runtimeRunner) retryAuthRefreshRequired( + ctx context.Context, + endpoint string, + invocation executor.Invocation, + rejectedAccessToken string, + markerErr error, + hasPluginAuth bool, +) (executor.Result, error, bool) { + marker, marked := authretry.As(markerErr) + if !marked { + return executor.Result{}, nil, false + } + cause := marker.Cause + if cause == nil { + cause = markerErr + } + + // Explicit --token and plugin credentials are not backed by the default + // OAuth refresh store. Preserve the overlay cause without mutating an + // unrelated persisted login. + if !r.managesRuntimeOAuth(hasPluginAuth) { + return executor.Result{}, cause, true + } + if IsAuthRetrying(ctx) { + authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + ) + return executor.Result{}, cause, true + } + + if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil { + // Keep every log credential-safe. The returned error chain retains the + // complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not + // serialize an OAuth response body or other attacker-controlled text. + authRefreshLogger().Warn("auth.runtime.refresh.failed", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + "stage", "force_refresh_rejected_token", + "error_type", fmt.Sprintf("%T", err), + ) + logging.AuthDebug("auth.runtime.refresh.failed.detail", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + "stage", "force_refresh_rejected_token", + "error_type", fmt.Sprintf("%T", err), + ) + combined := &authRefreshFailureError{rejection: cause, refresh: err} + return executor.Result{}, apperrors.NewAuth( + "automatic access token refresh failed", + apperrors.WithOperation("auth/token/refresh"), + apperrors.WithReason("auth_refresh_failed"), + apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"), + apperrors.WithCause(combined), + ), true + } + + logging.AuthDebug("auth.runtime.refresh.succeeded", + "product", invocation.CanonicalProduct, + "tool", invocation.Tool, + ) + result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation) + return result, err, true +} + +// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and +// generic CategoryAuth values. OnAuthError may request a refresh only for an +// exact transport-level unauthorized signal. +func isRefreshableTransportAuthError(err error) bool { + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth { + return false + } + return typed.Reason == "http_401" || typed.RPCCode == 401 +} diff --git a/internal/app/auth_refresh_retry_runner_test.go b/internal/app/auth_refresh_retry_runner_test.go new file mode 100644 index 00000000..23993d2a --- /dev/null +++ b/internal/app/auth_refresh_retry_runner_test.go @@ -0,0 +1,354 @@ +// 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 app + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func installAuthRefreshRunnerSeams(t *testing.T) { + t.Helper() + previousHooks := edition.Get() + previousCall := runnerCallTool + previousPreflight := runnerPreflightDocDownload + previousRefresh := runnerForceRefreshRejectedAccessToken + previousRetry := runnerExecuteAuthRetry + previousCapture := runnerCaptureRuntimeFailure + previousProfile := authpkg.RuntimeProfile() + + pluginAuthMu.Lock() + previousPlugins := pluginAuthRegistry + pluginAuthRegistry = make(map[string]*PluginAuth) + pluginAuthMu.Unlock() + + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return nil + } + runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {} + authpkg.SetRuntimeProfile("") + runtimeTokenManager.Invalidate() + t.Setenv("DWS_CONFIG_DIR", "") + t.Setenv("DWS_DEBUG_AUTH", "0") + + t.Cleanup(func() { + edition.Override(previousHooks) + runnerCallTool = previousCall + runnerPreflightDocDownload = previousPreflight + runnerForceRefreshRejectedAccessToken = previousRefresh + runnerExecuteAuthRetry = previousRetry + runnerCaptureRuntimeFailure = previousCapture + authpkg.SetRuntimeProfile(previousProfile) + runtimeTokenManager.Invalidate() + pluginAuthMu.Lock() + pluginAuthRegistry = previousPlugins + pluginAuthMu.Unlock() + }) +} + +func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner { + return &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: flags, + auditSink: audit.NopSink{}, + } +} + +func authRefreshTestInvocation() executor.Invocation { + return executor.Invocation{ + CanonicalProduct: "auth-retry-test-product", + Tool: "test_tool", + Params: map[string]any{"value": "safe"}, + } +} + +func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks { + return &edition.Hooks{ + ConfigDir: func() string { return configDir }, + TokenProvider: func(context.Context, func() (string, error)) (string, error) { + return *token, nil + }, + ClassifyToolResult: classify, + } +} + +func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected")) + edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error { + if expired, _ := content["expired"].(bool); expired { + return &authretry.AuthRefreshRequired{Cause: rejection} + } + return nil + })) + + var callTokens []string + runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) { + callTokens = append(callTokens, client.AuthToken) + if len(callTokens) == 1 { + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) { + refreshCalls++ + if gotDir != configDir || rejected != "old-access" { + t.Fatalf("refresh input = dir %q token %q", gotDir, rejected) + } + token = "new-access" + return token, nil + } + + result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if err != nil { + t.Fatal(err) + } + if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" { + t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens) + } + content, _ := result.Response["content"].(map[string]any) + if content["value"] != "ok" || content["success"] != true { + t.Fatalf("result content = %#v", content) + } +} + +func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) { + installAuthRefreshRunnerSeams(t) + t.Setenv("DWS_DEBUG_AUTH", "1") + configDir := t.TempDir() + token := "old-access" + rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected")) + edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error { + return &authretry.AuthRefreshRequired{Cause: rejection} + })) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"4496576595"}`) + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + return "", refreshErr + } + + var logs bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) { + t.Fatalf("error = %v, want rejection and refresh causes", err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" { + t.Fatalf("refresh envelope = %#v", typed) + } + var rendered bytes.Buffer + if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil { + t.Fatal(printErr) + } + for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} { + if !strings.Contains(rendered.String(), want) { + t.Fatalf("structured stderr missing %s: %s", want, rendered.String()) + } + } + for _, secret := range []string{"access-token-secret", "refresh-token-secret", "4496576595"} { + if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) { + t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String()) + } + } + for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} { + if !strings.Contains(logs.String(), want) { + t.Fatalf("safe refresh log missing %q: %s", want, logs.String()) + } + } +} + +func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + firstCause := errors.New("first rejection") + secondCause := errors.New("second rejection") + edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error { + attempt, _ := content["attempt"].(int) + if attempt == 1 { + return &authretry.AuthRefreshRequired{Cause: firstCause} + } + return &authretry.AuthRefreshRequired{Cause: secondCause} + })) + calls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + calls++ + return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, secondCause) || errors.Is(err, firstCause) { + t.Fatalf("error = %v, want only second rejection cause", err) + } + if calls != 2 || refreshCalls != 1 { + t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls) + } +} + +func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) { + t.Run("http 401 marker retries once", func(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := errors.New("transport rejected token") + hookCalls := 0 + hooks := authRefreshTokenHooks(configDir, &token, nil) + hooks.OnAuthError = func(string, error) error { + hookCalls++ + return &authretry.AuthRefreshRequired{Cause: rejection} + } + edition.Override(hooks) + calls := 0 + var callTokens []string + runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) { + calls++ + callTokens = append(callTokens, client.AuthToken) + if calls == 1 { + return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401")) + } + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil { + t.Fatal(err) + } + if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" { + t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens) + } + }) + + for _, tc := range []struct { + name string + err error + }{ + {name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))}, + {name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))}, + } { + t.Run(tc.name+" does not enter hook", func(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + hookCalls := 0 + hooks := authRefreshTokenHooks(configDir, &token, nil) + hooks.OnAuthError = func(string, error) error { + hookCalls++ + return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")} + } + edition.Override(hooks) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{}, tc.err + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + return "", nil + } + + _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 { + t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls) + } + }) + } +} + +func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) { + installAuthRefreshRunnerSeams(t) + rejection := errors.New("explicit token rejected") + edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { + return &authretry.AuthRefreshRequired{Cause: rejection} + }}) + calls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + calls++ + return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + return "", nil + } + + _, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()) + if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 { + t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls) + } +} + +func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) { + installAuthRefreshRunnerSeams(t) + configDir := t.TempDir() + token := "old-access" + rejection := errors.New("preflight token rejected") + edition.Override(authRefreshTokenHooks(configDir, &token, nil)) + preflightCalls := 0 + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + preflightCalls++ + if preflightCalls == 1 { + return &authretry.AuthRefreshRequired{Cause: rejection} + } + return nil + } + toolCalls := 0 + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + toolCalls++ + return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil + } + refreshCalls := 0 + runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { + refreshCalls++ + token = "new-access" + return token, nil + } + + if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil { + t.Fatal(err) + } + if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 { + t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls) + } +} diff --git a/internal/app/force_refresh.go b/internal/app/force_refresh.go index e12cf8fd..83bae8a2 100644 --- a/internal/app/force_refresh.go +++ b/internal/app/force_refresh.go @@ -27,9 +27,13 @@ type accessTokenGetter interface { GetAccessToken(context.Context) (string, error) } +type rejectedAccessTokenRefresher interface { + ForceRefreshRejectedToken(context.Context, string) (string, error) +} + var ( - markAccessTokenStale = authpkg.MarkAccessTokenStale - newRefreshProvider = func(configDir string) accessTokenGetter { + loadRefreshTokenData = authpkg.LoadTokenData + newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher { disc := slog.New(slog.NewTextHandler(io.Discard, nil)) provider := authpkg.NewOAuthProvider(configDir, disc) configureOAuthProviderCompatibility(provider, configDir) @@ -42,26 +46,33 @@ var ( // server-side rejection (HTTP 401 or business code such as // TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token. // -// Steps: -// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so -// OAuthProvider.GetAccessToken's fast-path will miss. -// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the -// existing dual-layer lock (process + file) to serialize concurrent -// refresh attempts across goroutines and processes. -// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the -// next resolveAuthToken call re-reads from disk. -// -// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper -// is the only entry point that orchestrates "force refresh" semantics. +// It snapshots the current access token, then delegates to the OAuth +// provider's dual-locked compare-and-refresh operation. If another caller has +// already rotated the token, that newer token is reused without another +// refresh request. func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) { if strings.TrimSpace(configDir) == "" { return "", fmt.Errorf("config directory is empty") } - if err := markAccessTokenStale(configDir); err != nil { - return "", fmt.Errorf("mark access token stale: %w", err) + data, err := loadRefreshTokenData(configDir) + if err != nil { + return "", err + } + if data == nil || strings.TrimSpace(data.AccessToken) == "" { + return "", fmt.Errorf("stored access token is empty") + } + return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken) +} + +func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) { + if strings.TrimSpace(configDir) == "" { + return "", fmt.Errorf("config directory is empty") + } + if strings.TrimSpace(rejectedAccessToken) == "" { + return "", fmt.Errorf("rejected access token is empty") } provider := newRefreshProvider(configDir) - tok, err := provider.GetAccessToken(ctx) + tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken) if err != nil { return "", err } diff --git a/internal/app/runner.go b/internal/app/runner.go index b09bf90e..f3765038 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -623,6 +623,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, err, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } @@ -631,9 +637,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params) RecordTiming(ctx, "mcp_call", time.Since(callStart)) if err != nil { - if isAuthError(err) { + if isRefreshableTransportAuthError(err) { if fn := edition.Get().OnAuthError; fn != nil { if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil { + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, err, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, err, overrideErr) return executor.Result{}, overrideErr } @@ -658,6 +670,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, editionErr, retryErr) + } + return result, retryErr + } return executor.Result{}, editionErr } } @@ -678,6 +696,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // patterns (PAT permission, gateway-auth) before generic handling. if classify := edition.Get().ClassifyToolResult; classify != nil { if hookErr := classify(callResult.Content); hookErr != nil { + if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled { + if retryErr != nil { + runnerCaptureRuntimeFailure(invocation, hookErr, retryErr) + } + return result, retryErr + } runnerCaptureRuntimeFailure(invocation, hookErr, hookErr) return executor.Result{}, hookErr } diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index 632ac05f..03a1b290 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) { return nil } - authErr := apperrors.NewAuth("expired") + authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401")) runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{}, authErr } diff --git a/internal/auth/force_refresh.go b/internal/auth/force_refresh.go index 05504b43..c6b893fe 100644 --- a/internal/auth/force_refresh.go +++ b/internal/auth/force_refresh.go @@ -13,7 +13,50 @@ package auth -import "time" +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +const rejectedTokenRefreshFailureCooldown = 3 * time.Second + +type rejectedTokenRefreshKey struct { + configDir string + profile string + tokenDigest [sha256.Size]byte +} + +type rejectedTokenRefreshCall struct { + done chan struct{} + participants int + token string + err error +} + +type rejectedTokenRefreshFailure struct { + at time.Time + err error +} + +var rejectedTokenRefreshCoordinator = struct { + sync.Mutex + inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall + failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure + now func() time.Time +}{ + inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall), + failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure), + now: time.Now, +} // MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past // instant (preserving access_token and refresh_token), and writes it back. The @@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error { data.ExpiresAt = time.Now().Add(-1 * time.Minute) return SaveTokenData(configDir, data) } + +// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is +// still the credential stored for the active profile. The compare and refresh +// run under the same process + file lock used by ordinary expiry refresh, so a +// late rejection cannot invalidate or refresh over a token another caller has +// already rotated. +// +// When the stored token no longer matches, the newer token is returned without +// calling the refresh endpoint. Refresh failures leave the stored credential in +// place; login/logout remain the only owners of credential deletion. +func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) { + if p == nil || strings.TrimSpace(p.configDir) == "" { + return "", fmt.Errorf("config directory is empty") + } + rejectedAccessToken = strings.TrimSpace(rejectedAccessToken) + if rejectedAccessToken == "" { + return "", fmt.Errorf("rejected access token is empty") + } + if ctx == nil { + ctx = context.Background() + } + + profile := strings.TrimSpace(RuntimeProfile()) + key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken) + call, leader := beginRejectedTokenRefresh(key) + if !leader { + select { + case <-call.done: + return call.token, call.err + case <-ctx.Done(): + return "", ctx.Err() + } + } + + token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key) + finishRejectedTokenRefresh(key, call, token, err, recordFailure) + return token, err +} + +func (p *OAuthProvider) forceRefreshRejectedTokenOnce( + ctx context.Context, + profile string, + rejectedAccessToken string, + key rejectedTokenRefreshKey, +) (string, error, bool) { + lock, err := oauthAcquireLock(ctx, p.configDir) + if err != nil { + return "", fmt.Errorf("acquiring dual lock: %w", err), false + } + defer lock.Release() + + data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile) + if err != nil { + return "", fmt.Errorf("reload rejected token: %w", err), false + } + current := strings.TrimSpace(data.AccessToken) + if current == "" { + clearRejectedTokenRefreshFailure(key) + return "", fmt.Errorf("stored access token is empty"), false + } + if current != rejectedAccessToken { + clearRejectedTokenRefreshFailure(key) + return current, nil, false + } + if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil { + return "", cachedErr, false + } + if !data.IsRefreshTokenValid() { + return "", fmt.Errorf("refresh_token 已过期"), true + } + if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil { + return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true + } + + refreshed, err := oauthRefreshToken(p, ctx, data) + if err != nil { + return "", err, true + } + if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" { + return "", fmt.Errorf("force refresh returned empty access token"), true + } + return strings.TrimSpace(refreshed.AccessToken), nil, false +} + +func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey { + canonicalDir := filepath.Clean(configDir) + if absolute, err := filepath.Abs(configDir); err == nil { + canonicalDir = filepath.Clean(absolute) + } + return rejectedTokenRefreshKey{ + configDir: canonicalDir, + profile: strings.TrimSpace(profile), + tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))), + } +} + +func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil { + call.participants++ + return call, false + } + call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1} + rejectedTokenRefreshCoordinator.inFlight[key] = call + return call, true +} + +func finishRejectedTokenRefresh( + key rejectedTokenRefreshKey, + call *rejectedTokenRefreshCall, + token string, + err error, + recordFailure bool, +) { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + call.token = token + call.err = err + delete(rejectedTokenRefreshCoordinator.inFlight, key) + if err == nil { + delete(rejectedTokenRefreshCoordinator.failures, key) + } else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + now := time.Now() + if rejectedTokenRefreshCoordinator.now != nil { + now = rejectedTokenRefreshCoordinator.now() + } + rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err} + } + close(call.done) +} + +func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error { + rejectedTokenRefreshCoordinator.Lock() + defer rejectedTokenRefreshCoordinator.Unlock() + + now := time.Now() + if rejectedTokenRefreshCoordinator.now != nil { + now = rejectedTokenRefreshCoordinator.now() + } + for failureKey, failure := range rejectedTokenRefreshCoordinator.failures { + age := now.Sub(failure.at) + if age < 0 || age >= rejectedTokenRefreshFailureCooldown { + delete(rejectedTokenRefreshCoordinator.failures, failureKey) + } + } + if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok { + return failure.err + } + return nil +} + +func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) { + rejectedTokenRefreshCoordinator.Lock() + delete(rejectedTokenRefreshCoordinator.failures, key) + rejectedTokenRefreshCoordinator.Unlock() +} + +// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a +// second, non-reentrant auth lock. Opaque edition storage hooks (for example +// Wukong's encrypted .data file) are read inside the caller's dual lock so the +// compare-and-refresh decision covers both Core and embedded storage. +func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) { + hooks := edition.Get() + if hooks.LoadToken == nil { + data, err := oauthLoadTokenLocked(configDir, profile) + if err != nil { + return nil, err + } + if data == nil { + return nil, fmt.Errorf("stored token data is empty") + } + return data, nil + } + if strings.TrimSpace(profile) != "" { + return nil, fmt.Errorf("profile selection is not supported by the current auth backend") + } + blob, err := hooks.LoadToken(configDir) + if err != nil { + return nil, err + } + var data TokenData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, fmt.Errorf("parsing token data from hook: %w", err) + } + return &data, nil +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 4b0f778d..3f894bc0 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -717,7 +717,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { // Double-check: re-load from disk — another goroutine/process may have refreshed // while we were waiting for the lock. - data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile()) + data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile()) if err != nil { return nil, err } diff --git a/internal/auth/rejected_token_refresh_test.go b/internal/auth/rejected_token_refresh_test.go new file mode 100644 index 00000000..42e7bda6 --- /dev/null +++ b/internal/auth/rejected_token_refresh_test.go @@ -0,0 +1,510 @@ +// 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 auth + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type rejectedTokenHookStore struct { + mu sync.Mutex + data TokenData + deletes int +} + +func (s *rejectedTokenHookStore) load(string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + return json.Marshal(s.data) +} + +func (s *rejectedTokenHookStore) save(_ string, blob []byte) error { + var data TokenData + if err := json.Unmarshal(blob, &data); err != nil { + return err + } + s.mu.Lock() + s.data = data + s.mu.Unlock() + return nil +} + +func (s *rejectedTokenHookStore) delete(string) error { + s.mu.Lock() + s.data = TokenData{} + s.deletes++ + s.mu.Unlock() + return nil +} + +func (s *rejectedTokenHookStore) snapshot() (TokenData, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data, s.deletes +} + +func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore { + t.Helper() + store := &rejectedTokenHookStore{data: data} + previousHooks := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: store.load, + SaveToken: store.save, + DeleteToken: store.delete, + }) + t.Cleanup(func() { edition.Override(previousHooks) }) + return store +} + +func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) { + t.Helper() + resetRejectedTokenRefreshCoordinator(t) + previous := oauthRefreshToken + oauthRefreshToken = fn + t.Cleanup(func() { oauthRefreshToken = previous }) +} + +func resetRejectedTokenRefreshCoordinator(t *testing.T) { + t.Helper() + reset := func() { + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall) + rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure) + rejectedTokenRefreshCoordinator.now = time.Now + rejectedTokenRefreshCoordinator.Unlock() + } + reset() + t.Cleanup(reset) +} + +func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + rejectedTokenRefreshCoordinator.Lock() + call := rejectedTokenRefreshCoordinator.inFlight[key] + got := 0 + if call != nil { + got = call.participants + } + rejectedTokenRefreshCoordinator.Unlock() + if got >= want { + return + } + if time.Now().After(deadline) { + t.Fatalf("refresh participants = %d, want %d", got, want) + } + time.Sleep(time.Millisecond) + } +} + +func installProfilesAcquireProbe(t *testing.T) <-chan struct{} { + t.Helper() + previous := profilesAcquireDualLock + attempted := make(chan struct{}, 1) + profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) { + attempted <- struct{}{} + return previous(ctx, configDir) + } + t.Cleanup(func() { profilesAcquireDualLock = previous }) + return attempted +} + +func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) { + t.Helper() + select { + case <-attempted: + case <-time.After(2 * time.Second): + t.Fatal("public opaque token mutation did not enter the Core dual lock") + } +} + +func validRejectedTokenData(accessToken string) TokenData { + return TokenData{ + AccessToken: accessToken, + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + Source: "mcp", + ClientID: "client-id", + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + var refreshCalls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + if refreshCalls.Add(1) == 1 { + close(started) + } + <-release + updated := *data + updated.AccessToken = "new-access" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + provider := NewOAuthProvider(t.TempDir(), nil) + const workers = 8 + results := make(chan string, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + wg.Add(workers) + for range workers { + go func() { + defer wg.Done() + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + results <- token + errs <- err + }() + } + <-started + close(release) + wg.Wait() + close(results) + close(errs) + + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + for token := range results { + if token != "new-access" { + t.Fatalf("token = %q, want new-access", token) + } + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "new-access" || deletes != 0 { + t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + refreshErr := errors.New("temporary refresh failure") + installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, refreshErr + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access") + if !errors.Is(err, refreshErr) { + t.Fatalf("error = %v, want refresh cause", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 { + t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + previousProfile := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousProfile) }) + + refreshErr := errors.New("temporary refresh failure") + var refreshCalls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + baseNow := time.Now() + var nowNanos atomic.Int64 + nowNanos.Store(baseNow.UnixNano()) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + call := refreshCalls.Add(1) + if call == 1 { + close(started) + <-release + return nil, refreshErr + } + updated := *data + updated.AccessToken = "recovered-access" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.now = func() time.Time { + return time.Unix(0, nowNanos.Load()) + } + rejectedTokenRefreshCoordinator.Unlock() + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + const workers = 8 + start := make(chan struct{}) + ready := make(chan struct{}, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + wg.Add(workers) + for range workers { + go func() { + defer wg.Done() + ready <- struct{}{} + <-start + _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + errs <- err + }() + } + for range workers { + <-ready + } + close(start) + <-started + key := newRejectedTokenRefreshKey(configDir, "", "old-access") + waitForRejectedTokenRefreshParticipants(t, key, workers) + close(release) + wg.Wait() + close(errs) + + for err := range errs { + if !errors.Is(err, refreshErr) { + t.Fatalf("shared refresh error = %v, want %v", err, refreshErr) + } + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls after concurrent failure = %d, want 1", got) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 { + t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes) + } + + if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) { + t.Fatalf("cooldown error = %v, want %v", err, refreshErr) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls inside cooldown = %d, want 1", got) + } + + nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano()) + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + if err != nil || token != "recovered-access" { + t.Fatalf("refresh after cooldown = %q, %v", token, err) + } + if got := refreshCalls.Load(); got != 2 { + t.Fatalf("refresh calls after cooldown = %d, want 2", got) + } + stored, deletes = store.snapshot() + if stored.AccessToken != "recovered-access" || deletes != 0 { + t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + previousProfile := RuntimeProfile() + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile(previousProfile) }) + + refreshErr := errors.New("temporary refresh failure") + var refreshCalls atomic.Int32 + baseNow := time.Now() + installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + refreshCalls.Add(1) + return nil, refreshErr + }) + rejectedTokenRefreshCoordinator.Lock() + rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow } + rejectedTokenRefreshCoordinator.Unlock() + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) { + t.Fatalf("initial refresh error = %v, want %v", err, refreshErr) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("initial refresh calls = %d, want 1", got) + } + + store.mu.Lock() + store.data = validRejectedTokenData("externally-refreshed") + store.mu.Unlock() + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + if err != nil || token != "externally-refreshed" { + t.Fatalf("refresh after external publication = %q, %v", token, err) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("external publication triggered another exchange: calls=%d", got) + } + key := newRejectedTokenRefreshKey(configDir, "", "old-access") + rejectedTokenRefreshCoordinator.Lock() + _, failurePresent := rejectedTokenRefreshCoordinator.failures[key] + rejectedTokenRefreshCoordinator.Unlock() + if failurePresent { + t.Fatal("old-token failure cache was not cleared after external publication") + } +} + +func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + close(started) + <-release + updated := *data + updated.AccessToken = "refreshed-from-old" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + refreshResult := make(chan struct { + token string + err error + }, 1) + go func() { + token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + refreshResult <- struct { + token string + err error + }{token: token, err: err} + }() + <-started + + acquireAttempted := installProfilesAcquireProbe(t) + publishResult := make(chan error, 1) + go func() { + publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published"))) + }() + waitForProfilesAcquire(t, acquireAttempted) + releaseOnce.Do(func() { close(release) }) + + refresh := <-refreshResult + if refresh.err != nil || refresh.token != "refreshed-from-old" { + t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err) + } + if err := <-publishResult; err != nil { + t.Fatalf("publish token: %v", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "login-published" || deletes != 0 { + t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes) + } +} + +func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) { + for _, tc := range []struct { + name string + logout func(string) error + }{ + {name: "current profile", logout: func(configDir string) error { + return DeleteTokenDataForProfile(configDir, "") + }}, + {name: "all profiles", logout: DeleteAllTokenData}, + } { + t.Run(tc.name, func(t *testing.T) { + store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access")) + started := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + close(started) + <-release + updated := *data + updated.AccessToken = "refreshed-before-logout" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + configDir := t.TempDir() + provider := NewOAuthProvider(configDir, nil) + refreshResult := make(chan error, 1) + go func() { + _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access") + refreshResult <- err + }() + <-started + + acquireAttempted := installProfilesAcquireProbe(t) + logoutResult := make(chan error, 1) + go func() { logoutResult <- tc.logout(configDir) }() + waitForProfilesAcquire(t, acquireAttempted) + releaseOnce.Do(func() { close(release) }) + + if err := <-refreshResult; err != nil { + t.Fatalf("refresh: %v", err) + } + if err := <-logoutResult; err != nil { + t.Fatalf("logout: %v", err) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 { + t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes) + } + }) + } +} + +func ptrTokenData(data TokenData) *TokenData { + return &data +} + +func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) { + data := validRejectedTokenData("expired-access") + data.ExpiresAt = time.Now().Add(-time.Hour) + store := installRejectedTokenHookStore(t, data) + var refreshCalls atomic.Int32 + installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) { + refreshCalls.Add(1) + updated := *current + updated.AccessToken = "proactively-refreshed" + updated.ExpiresAt = time.Now().Add(time.Hour) + if err := saveTokenDataLocked(p.configDir, &updated); err != nil { + return nil, err + } + return &updated, nil + }) + + token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background()) + if err != nil || token != "proactively-refreshed" { + t.Fatalf("GetAccessToken() = %q, %v", token, err) + } + if refreshCalls.Load() != 1 { + t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load()) + } + stored, deletes := store.snapshot() + if stored.AccessToken != "proactively-refreshed" || deletes != 0 { + t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes) + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go index 03c02171..518733b1 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -210,13 +210,10 @@ func DeleteTokenMarker(configDir string) error { return nil } -// SaveTokenData persists TokenData. When an edition hook (SaveToken) is -// registered, it delegates entirely to the hook; otherwise it falls back -// to the default keychain-based storage. +// SaveTokenData persists TokenData under the auth dual lock. When an edition +// hook (SaveToken) is registered, the locked write delegates to that hook; +// otherwise it falls back to the default keychain-based storage. func SaveTokenData(configDir string, data *TokenData) error { - if h := edition.Get(); h.SaveToken != nil { - return saveTokenViaHook(h, configDir, data) - } return withProfilesLock(configDir, func() error { return saveTokenDataLocked(configDir, data) }) @@ -490,9 +487,8 @@ func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) { return orgData, nil } -// DeleteTokenData removes token data. When an edition hook (DeleteToken) is -// registered, it delegates entirely to the hook; otherwise it falls back -// to keychain + legacy cleanup. +// DeleteTokenData removes token data. Edition hooks and the default keychain +// path are both serialized with refresh through the auth dual lock. func DeleteTokenData(configDir string) error { return DeleteTokenDataForProfile(configDir, RuntimeProfile()) } @@ -504,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error { if strings.TrimSpace(profile) != "" { return fmt.Errorf("profile selection is not supported by the current auth backend") } - return h.DeleteToken(configDir) + return withProfilesLock(configDir, func() error { + return h.DeleteToken(configDir) + }) } return withProfilesLock(configDir, func() error { return deleteTokenDataForProfileLocked(configDir, profile) @@ -939,7 +937,9 @@ func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error { // DeleteAllTokenData removes all profile-scoped and legacy token data. func DeleteAllTokenData(configDir string) error { if h := edition.Get(); h.DeleteToken != nil { - return h.DeleteToken(configDir) + return withProfilesLock(configDir, func() error { + return h.DeleteToken(configDir) + }) } return withProfilesLock(configDir, func() error { var firstErr error From 9ec1fa06385c45ca81dec59d749c6fd028fba3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E5=AE=98=E7=8E=84?= Date: Mon, 20 Jul 2026 14:15:18 +0800 Subject: [PATCH 11/67] test(auth): use synthetic log redaction sentinel --- internal/app/auth_refresh_retry_runner_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/auth_refresh_retry_runner_test.go b/internal/app/auth_refresh_retry_runner_test.go index 23993d2a..640b1f99 100644 --- a/internal/app/auth_refresh_retry_runner_test.go +++ b/internal/app/auth_refresh_retry_runner_test.go @@ -150,7 +150,7 @@ func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog( runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil } - refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"4496576595"}`) + refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`) runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) { return "", refreshErr } @@ -177,7 +177,7 @@ func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog( t.Fatalf("structured stderr missing %s: %s", want, rendered.String()) } } - for _, secret := range []string{"access-token-secret", "refresh-token-secret", "4496576595"} { + for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} { if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) { t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String()) } From 8259116f15597ea70c9e4964bbd8046896cb863a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 20 Jul 2026 14:32:46 +0800 Subject: [PATCH 12/67] test(auth): isolate Windows keychain packages --- internal/app/testmain_test.go | 10 +++++++++ internal/auth/testmain_test.go | 11 ++++++++++ internal/keychain/keychain.go | 5 +++++ internal/keychain/keychain_test.go | 8 +++++++ internal/keychain/keychain_windows.go | 23 ++++++++++++++++---- internal/keychain/keychain_windows_test.go | 25 ++++++++++++++++++++++ 6 files changed, 78 insertions(+), 4 deletions(-) diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 77b212ac..88d5f8c5 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -52,6 +52,10 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.TestNamespaceEnv + ": " + err.Error()) + } if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil { _ = os.RemoveAll(tmpDir) panic("set DWS_CONFIG_DIR: " + err.Error()) @@ -79,6 +83,12 @@ func TestMain(m *testing.M) { StopAllStdioClients() CloseAuditSink() CloseFileLogger() + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } if err := os.RemoveAll(tmpDir); err != nil { fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err) if code == 0 { diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go index 92310f85..3a84802d 100644 --- a/internal/auth/testmain_test.go +++ b/internal/auth/testmain_test.go @@ -14,6 +14,7 @@ package auth import ( + "fmt" "os" "testing" @@ -34,7 +35,17 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil { + _ = os.RemoveAll(tmpDir) + panic("set " + keychain.TestNamespaceEnv + ": " + err.Error()) + } code := m.Run() + if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } _ = os.RemoveAll(tmpDir) os.Exit(code) } diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 7cbb7133..cfc2475a 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -36,6 +36,11 @@ const ( // parallel. When empty, the platform default applies. StorageDirEnv = "DWS_KEYCHAIN_DIR" + // TestNamespaceEnv isolates the Windows HKCU registry backend for tests. + // Production code must not set it. Other platforms already isolate secure + // storage through StorageDirEnv and ignore this value. + TestNamespaceEnv = "DWS_KEYCHAIN_TEST_NAMESPACE" + // DisableKeychainEnv opts the macOS implementation out of system // Keychain access for the DEK, falling back to a file-based DEK // (same scheme as Linux). Intended for sandboxed runtimes where diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 16ca3e12..527fb6bb 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -14,6 +14,7 @@ package keychain import ( + "fmt" "os" "path/filepath" "testing" @@ -25,8 +26,15 @@ func TestMain(m *testing.M) { panic(err) } _ = os.Setenv(StorageDirEnv, dir) + _ = os.Setenv(TestNamespaceEnv, dir) _ = os.Setenv(DisableKeychainEnv, "1") code := m.Run() + if err := RemoveAuthTokenEntries(Service); err != nil { + fmt.Fprintf(os.Stderr, "internal/keychain test cleanup: %v\n", err) + if code == 0 { + code = 1 + } + } _ = os.RemoveAll(dir) os.Exit(code) } diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go index 69e66af6..00c35767 100644 --- a/internal/keychain/keychain_windows.go +++ b/internal/keychain/keychain_windows.go @@ -16,6 +16,7 @@ package keychain import ( + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -47,9 +48,8 @@ const regRootPath = `Software\DwsCli\keychain` // The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry // values rather than on disk, so this path is used only by the portable // auth-bundle export/import (internal/auth) to colocate config. When the -// DWS_KEYCHAIN_DIR environment variable is set (used by tests for isolation), -// the storage root is taken from that env var instead; otherwise it defaults -// to %LocalAppData%\. +// DWS_KEYCHAIN_DIR environment variable is set, the storage root is taken from +// that env var; otherwise it defaults to %LocalAppData%\. func StorageDir(service string) string { if override := os.Getenv(StorageDirEnv); override != "" { return filepath.Join(override, service) @@ -66,7 +66,22 @@ func StorageDir(service string) string { } func registryPathForService(service string) string { - return regRootPath + `\` + safeRegistryComponent(service) + path := regRootPath + `\` + safeRegistryComponent(service) + namespace := strings.TrimSpace(os.Getenv(TestNamespaceEnv)) + if namespace == "" { + return path + } + + // Windows stores credentials in HKCU instead of DWS_KEYCHAIN_DIR. Tests set + // an explicit process namespace so concurrent package binaries cannot + // delete each other's credentials. Hash it to avoid leaking temp paths or + // introducing registry separators. + namespace = filepath.Clean(namespace) + if absolute, err := filepath.Abs(namespace); err == nil { + namespace = absolute + } + sum := sha256.Sum256([]byte(strings.ToLower(namespace))) + return fmt.Sprintf(`%s\test-%x`, path, sum[:16]) } var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) diff --git a/internal/keychain/keychain_windows_test.go b/internal/keychain/keychain_windows_test.go index 9d9f4b80..73a5d91a 100644 --- a/internal/keychain/keychain_windows_test.go +++ b/internal/keychain/keychain_windows_test.go @@ -17,12 +17,37 @@ package keychain import ( "errors" + "strings" "testing" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) +func TestCrossPlatformCoverageRegistryPathForServiceHonorsTestNamespace(t *testing.T) { + t.Setenv(TestNamespaceEnv, "") + defaultPath := registryPathForService("service") + if defaultPath != regRootPath+`\service` { + t.Fatalf("default registry path = %q, want historical path %q", defaultPath, regRootPath+`\service`) + } + + t.Setenv(TestNamespaceEnv, t.TempDir()) + firstPath := registryPathForService("service") + + t.Setenv(TestNamespaceEnv, t.TempDir()) + secondPath := registryPathForService("service") + + if firstPath == defaultPath || secondPath == defaultPath { + t.Fatalf("isolated registry paths = %q, %q; want paths distinct from %q", firstPath, secondPath, defaultPath) + } + if firstPath == secondPath { + t.Fatalf("isolated registry paths collide: %q", firstPath) + } + if !strings.HasPrefix(firstPath, defaultPath+`\test-`) { + t.Fatalf("isolated registry path = %q, want prefix %q", firstPath, defaultPath+`\test-`) + } +} + func TestDeleteRegistryValuePropagatesFailure(t *testing.T) { originalDelete := registryDeleteValue failure := errors.New("delete failed") From 41088bb965a0a485cd12a1d6be4b00ff1ddf04c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:51:41 +0800 Subject: [PATCH 13/67] fix(release): derive OSS_REGION for ossutil v2 V4 signing (#692) ossutil 2.x signs requests with V4 and refuses to run without an explicit region, so the OSS mirror sync would fail in CI even with valid credentials. Derive OSS_REGION from the endpoint host (including -internal variants) and fail fast when it cannot be derived. --- scripts/release/sync-to-oss.sh | 13 +++++++++++++ test/scripts/release_script_test.go | 3 +++ 2 files changed, 16 insertions(+) diff --git a/scripts/release/sync-to-oss.sh b/scripts/release/sync-to-oss.sh index ce12085c..e0462ba4 100755 --- a/scripts/release/sync-to-oss.sh +++ b/scripts/release/sync-to-oss.sh @@ -47,6 +47,19 @@ if [ -n "$missing" ]; then exit 0 fi +# ossutil v2 signs with V4 and requires an explicit region. Derive it from the +# endpoint host (oss-[-internal].aliyuncs.com) unless OSS_REGION is set. +if [ -z "${OSS_REGION:-}" ]; then + OSS_REGION="$(printf '%s' "$OSS_ENDPOINT" \ + | sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' \ + | sed 's#-internal$##')" +fi +if [ -z "$OSS_REGION" ]; then + echo "❌ Could not derive OSS_REGION from OSS_ENDPOINT=${OSS_ENDPOINT}; set OSS_REGION explicitly." >&2 + exit 1 +fi +export OSS_REGION + # ── Resolve version ────────────────────────────────────────────────────────── VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}" CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}" diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 175ee3c0..64063e98 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -1311,6 +1311,7 @@ func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) { "OSS_ACCESS_KEY_ID=test-key", "OSS_ACCESS_KEY_SECRET=test-secret", "OSS_ENDPOINT=https://oss.example.com", + "OSS_REGION=cn-test", "OSS_BUCKET=test-bucket", "OSS_PREFIX=dws", "OSSUTIL="+fakeOSSUtil, @@ -1381,6 +1382,7 @@ func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) { "OSS_ACCESS_KEY_ID=test-key", "OSS_ACCESS_KEY_SECRET=test-secret", "OSS_ENDPOINT=https://oss.example.com", + "OSS_REGION=cn-test", "OSS_BUCKET=test-bucket", "OSS_PREFIX=dws", "OSSUTIL="+fakeOSSUtil, @@ -1417,6 +1419,7 @@ func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testin "OSS_ACCESS_KEY_ID=test-key", "OSS_ACCESS_KEY_SECRET=test-secret", "OSS_ENDPOINT=https://oss.example.com", + "OSS_REGION=cn-test", "OSS_BUCKET=test-bucket", "OSS_PREFIX=dws", "OSSUTIL="+fakeOSSUtil, From 6e070a7e244828e01a21e3dbd67d8f802515c442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 20 Jul 2026 17:14:35 +0800 Subject: [PATCH 14/67] ci: tighten PR coverage gate --- .github/workflows/ci.yml | 7 +- .github/workflows/notify-wukong.yml | 2 +- Makefile | 4 +- docs/architecture.md | 2 +- docs/ci-pr-gates.md | 16 +- docs/releasing.md | 2 +- scripts/policy/check-coverage-gate.sh | 25 +-- scripts/policy/coverage-gate/main.go | 80 +++++---- scripts/policy/coverage-gate/main_test.go | 202 +++++++++++++++++++++- test/scripts/changelog_pr_gate_test.go | 31 +++- 10 files changed, 308 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c47102d..d8c4e304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Code Admission — PR 合入门禁 +name: CI on: push: @@ -714,9 +714,10 @@ jobs: - name: Enforce coverage gate if: needs.lint.outputs.changelog_only != 'true' env: - COVERAGE_TARGET: "80" + COVERAGE_TARGET: "100" COVERAGE_ENFORCE_OVERALL: "false" - run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + COVERAGE_OVERALL_TOLERANCE: "0" + run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' diff --git a/.github/workflows/notify-wukong.yml b/.github/workflows/notify-wukong.yml index 3b04d509..d9897c73 100644 --- a/.github/workflows/notify-wukong.yml +++ b/.github/workflows/notify-wukong.yml @@ -3,7 +3,7 @@ name: Main Integration — Wukong Overlay on: workflow_run: workflows: - - Code Admission — PR 合入门禁 + - CI types: - completed diff --git a/Makefile b/Makefile index 4c647575..0fa71080 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,8 @@ help: @printf " make policy - Check the built dws plus open-source and Schema policies\n" @printf " make interface-integrity - Check historical commands and help contracts still work\n" @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" - @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and changed-code coverage\n" - @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce native-platform changed-code coverage\n" + @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and 100%% changed-code coverage\n" + @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce 100%% native changed-code coverage\n" @printf " make update-interface-baseline - Add new CLI contracts without removing history\n" @printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n" @printf " make schema-compatibility BASE_REF= - Check the complete Schema contract against the PR merge-base\n" diff --git a/docs/architecture.md b/docs/architecture.md index 3dc70019..d2e40eb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ run. ```mermaid flowchart TB - PR["Pull request"] --> CA["Code Admission — PR 合入门禁"] + PR["Pull request"] --> CA["CI"] subgraph CA_CHECKS["Nine required contexts"] L["Lint"] T["Test"] diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 83aa5de7..fc17690a 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -1,4 +1,4 @@ -# Code Admission — PR 合入门禁 +# CI — PR 合入门禁 The pull-request admission layer has exactly nine required external contexts: @@ -6,7 +6,7 @@ The pull-request admission layer has exactly nine required external contexts: |---|---| | `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint | | `Test` | Race/unit/release-script tests plus fast cross-platform compilation | -| `Coverage` | Overall non-regression and changed-code coverage | +| `Coverage` | Overall non-regression and 100% changed-code coverage | | `Policy` | Repository policy and the fail-closed CHANGELOG contract | | `Edition` | Edition contract tests | | `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility | @@ -14,7 +14,7 @@ The pull-request admission layer has exactly nine required external contexts: | `CLI Smoke` | Offline help for every public top-level command | | `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests | -The workflow display name is `Code Admission — PR 合入门禁`. Parallel helper +The workflow display name is `CI`. Parallel helper jobs may implement `Test` and `Coverage`, but they are not ruleset contexts. Do not require an aggregate alias or a downstream integration check in place of the nine contracts above. @@ -81,7 +81,7 @@ successful PR check. ```mermaid flowchart TB - PR["Pull request"] --> ADMISSION["Code Admission — PR 合入门禁"] + PR["Pull request"] --> ADMISSION["CI"] ADMISSION --> L["Lint"] ADMISSION --> T["Test"] ADMISSION --> C["Coverage"] @@ -122,7 +122,13 @@ base_ref=$(git merge-base HEAD origin/main) `make coverage-gate` is an enforcement step, not a profile generator. CI generates the candidate, supporting, merge-base, and (when risk-selected) -native profiles before the aggregate `Coverage` context evaluates them. +native profiles before the aggregate `Coverage` context evaluates them. The +aggregate and native gates require 100% coverage for changed executable Go +statements. Overall coverage remains an unrounded, zero-tolerance merge-base +non-regression check. Candidate and baseline profiles are evaluated by the +same block-deduplicating checker; supporting policy and shortcut profiles +contribute to changed-code coverage only. The checked-in badge is presentation +only and is never read as a gate input. Compatibility checks derive authoritative Interface snapshots from the PR merge-base and the latest reachable stable release. The candidate cannot bless diff --git a/docs/releasing.md b/docs/releasing.md index 2c9ca532..f3076519 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,7 +2,7 @@ 发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。 -发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `Code Admission — PR 合入门禁` 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 +发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 ## 日常只用一个入口 diff --git a/scripts/policy/check-coverage-gate.sh b/scripts/policy/check-coverage-gate.sh index 4b1d7c8d..2db179f8 100755 --- a/scripts/policy/check-coverage-gate.sh +++ b/scripts/policy/check-coverage-gate.sh @@ -4,17 +4,17 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" BASE_REF="" OVERALL_PROFILE="coverage.txt" -ADDITIONAL_PROFILE="${COVERAGE_ADDITIONAL_PROFILE:-}" +ADDITIONAL_DIFF_PROFILE="${COVERAGE_ADDITIONAL_DIFF_PROFILE:-${COVERAGE_ADDITIONAL_PROFILE:-}}" BASELINE_PROFILE="coverage-base.txt" DIFF_PROFILE="coverage-policy.txt" -TARGET="${COVERAGE_TARGET:-80}" -OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0.1}" +TARGET="${COVERAGE_TARGET:-100}" +OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}" ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}" CHANGED_ONLY="false" SCOPE_BUILDABLE="false" usage() { - printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--additional-profile ] [--baseline-profile ] [--diff-profile ]" >&2 + printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--additional-diff-profile ] [--baseline-profile ] [--diff-profile ]" >&2 } while [ "$#" -gt 0 ]; do @@ -29,9 +29,9 @@ while [ "$#" -gt 0 ]; do OVERALL_PROFILE="$2" shift 2 ;; - --additional-profile) + --additional-diff-profile|--additional-profile) [ "$#" -ge 2 ] || { usage; exit 2; } - ADDITIONAL_PROFILE="$2" + ADDITIONAL_DIFF_PROFILE="$2" shift 2 ;; --baseline-profile) @@ -87,19 +87,12 @@ set -- "$CHECKER" \ if [ "$CHANGED_ONLY" = "true" ]; then set -- "$@" --changed-only else - baseline="$(go tool cover -func="$BASELINE_PROFILE" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" - [ -n "$baseline" ] || { - printf 'error: cannot parse authoritative coverage from %s\n' "$BASELINE_PROFILE" >&2 - exit 2 - } set -- "$@" \ --overall-profile "$OVERALL_PROFILE" \ --diff-profile "$OVERALL_PROFILE" \ - --baseline-overall "$baseline" - if [ -n "$ADDITIONAL_PROFILE" ]; then - set -- "$@" \ - --overall-profile "$ADDITIONAL_PROFILE" \ - --diff-profile "$ADDITIONAL_PROFILE" + --baseline-profile "$BASELINE_PROFILE" + if [ -n "$ADDITIONAL_DIFF_PROFILE" ]; then + set -- "$@" --diff-profile "$ADDITIONAL_DIFF_PROFILE" fi fi if [ "$SCOPE_BUILDABLE" = "true" ]; then diff --git a/scripts/policy/coverage-gate/main.go b/scripts/policy/coverage-gate/main.go index 83f6b1ea..c0da205e 100644 --- a/scripts/policy/coverage-gate/main.go +++ b/scripts/policy/coverage-gate/main.go @@ -13,7 +13,6 @@ import ( "flag" "fmt" "io" - "math" "os" "os/exec" "path/filepath" @@ -37,11 +36,13 @@ func (values *stringList) Set(value string) error { } type coverageBlock struct { - File string - StartLine int - EndLine int - Statements int - Count int + File string + StartLine int + StartColumn int + EndLine int + EndColumn int + Statements int + Count int } type lineRange struct { @@ -78,6 +79,7 @@ func run( buildableLoader func() (map[string]bool, error), ) int { var overallPaths stringList + var baselinePaths stringList var diffPaths stringList var baseRef string var modulePath string @@ -90,12 +92,13 @@ func run( flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError) flags.SetOutput(stderr) flags.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)") + flags.Var(&baselinePaths, "baseline-profile", "merge-base coverage profile evaluated with the same model as the candidate (repeatable)") flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)") flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA") flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames") flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage") - flags.Float64Var(&overallTolerance, "overall-tolerance", 0.1, "allowed overall coverage measurement variance in percentage points") - flags.Float64Var(&target, "target", 80, "required changed-code and eventual overall coverage percentage") + flags.Float64Var(&overallTolerance, "overall-tolerance", 0, "allowed overall coverage measurement variance in percentage points") + flags.Float64Var(&target, "target", 100, "required changed-code coverage percentage and optional overall floor") flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target") flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline") flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform") @@ -103,8 +106,13 @@ func run( return 2 } - if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (len(overallPaths) == 0 || baselineOverall < 0)) { - fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall") + if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || + (!changedOnly && (len(overallPaths) == 0 || (len(baselinePaths) == 0 && baselineOverall < 0))) { + fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and either --baseline-profile or --baseline-overall") + return 2 + } + if len(baselinePaths) > 0 && baselineOverall >= 0 { + fmt.Fprintln(stderr, "coverage-gate accepts either --baseline-profile or --baseline-overall, not both") return 2 } var overall []coverageBlock @@ -115,6 +123,14 @@ func run( fmt.Fprintln(stderr, err) return 2 } + if len(baselinePaths) > 0 { + baseline, baselineErr := readProfiles(baselinePaths, modulePath) + if baselineErr != nil { + fmt.Fprintln(stderr, baselineErr) + return 2 + } + baselineOverall = coveragePercent(baseline) + } } diff, err := readProfiles(diffPaths, modulePath) if err != nil { @@ -150,12 +166,12 @@ func run( if enforceOverall { mode = "required" } - fmt.Fprintf(stdout, "overall coverage: %.1f%% (merge-base %.1f%%; tolerance %.1fpp; target %.1f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode) + fmt.Fprintf(stdout, "overall coverage: %.4f%% (merge-base %.4f%%; tolerance %.4fpp; target %.4f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode) } if result.ChangedStatements == 0 { - fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.1f%%)\n", target) + fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.4f%%)\n", target) } else { - fmt.Fprintf(stdout, "changed code coverage: %.1f%% (%d executable statements; target %.1f%%)\n", result.ChangedCoverage, result.ChangedStatements, target) + fmt.Fprintf(stdout, "changed code coverage: %.4f%% (%d executable statements; target %.4f%%)\n", result.ChangedCoverage, result.ChangedStatements, target) } if len(result.Failures) > 0 { fmt.Fprintln(stderr, "coverage gate failed:") @@ -171,13 +187,11 @@ func evaluate(input gateInput) gateResult { result := gateResult{Failures: []string{}} if !input.ChangedOnly { result.Overall = coveragePercent(input.Overall) - baselineRounded := roundOne(input.BaselineOverall) - overallRounded := roundOne(result.Overall) - if overallRounded+input.OverallTolerance+1e-9 < baselineRounded { - result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.1f%% to %.1f%%", baselineRounded, overallRounded)) + if result.Overall+input.OverallTolerance < input.BaselineOverall { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.4f%% to %.4f%%", input.BaselineOverall, result.Overall)) } - if input.EnforceOverall && overallRounded < input.Target { - result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.1f%% is below target %.1f%%", overallRounded, input.Target)) + if input.EnforceOverall && result.Overall < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.4f%% is below target %.4f%%", result.Overall, input.Target)) } } @@ -206,8 +220,8 @@ func evaluate(input gateInput) gateResult { result.ChangedStatements = total if total > 0 { result.ChangedCoverage = float64(covered) * 100 / float64(total) - if result.ChangedCoverage+1e-9 < input.Target { - result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.1f%% is below target %.1f%%", result.ChangedCoverage, input.Target)) + if result.ChangedCoverage < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.4f%% is below target %.4f%%", result.ChangedCoverage, input.Target)) } } return result @@ -253,11 +267,13 @@ func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) { values = append(values, value) } blocks = append(blocks, coverageBlock{ - File: normalizeProfilePath(match[1], modulePath), - StartLine: values[0], - EndLine: values[2], - Statements: values[4], - Count: values[5], + File: normalizeProfilePath(match[1], modulePath), + StartLine: values[0], + StartColumn: values[1], + EndLine: values[2], + EndColumn: values[3], + Statements: values[4], + Count: values[5], }) } err = scanner.Err() @@ -402,7 +418,15 @@ func coveragePercent(blocks []coverageBlock) float64 { func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock { merged := make(map[string]coverageBlock, len(blocks)) for _, block := range blocks { - key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements) + key := fmt.Sprintf( + "%s:%d:%d:%d:%d:%d", + block.File, + block.StartLine, + block.StartColumn, + block.EndLine, + block.EndColumn, + block.Statements, + ) current, ok := merged[key] if !ok || block.Count > current.Count { merged[key] = block @@ -419,5 +443,3 @@ func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock { } return result } - -func roundOne(value float64) float64 { return math.Round(value*10) / 10 } diff --git a/scripts/policy/coverage-gate/main_test.go b/scripts/policy/coverage-gate/main_test.go index 11f1770e..b12982ed 100644 --- a/scripts/policy/coverage-gate/main_test.go +++ b/scripts/policy/coverage-gate/main_test.go @@ -39,7 +39,7 @@ func TestRun(t *testing.T) { if code := run(args, &stdout, &stderr, loader, nil); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("unexpected output %q", stdout.String()) } @@ -54,6 +54,115 @@ func TestRun(t *testing.T) { } } +func TestRunDefaultsToFullChangedCodeCoverage(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + partialBody := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 9 1\n" + + "example.com/project/internal/a.go:13.1,13.2 1 0\n" + if err := os.WriteFile(profile, []byte(partialBody), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--changed-only", + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 13}}}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 1 { + t.Fatalf("run code=%d, want 1; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "changed code coverage 90.0000% is below target 100.0000%") { + t.Fatalf("default target was not enforced: %q", stderr.String()) + } + + fullBody := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 9 1\n" + + "example.com/project/internal/a.go:13.1,13.2 1 1\n" + if err := os.WriteFile(profile, []byte(fullBody), 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("100%% changed coverage code=%d; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } +} + +func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\n" + + "example.com/project/internal/a.go:10.1,12.2 5 0\n" + + "example.com/project/internal/a.go:10.1,12.2 5 1\n" + + "example.com/project/internal/a.go:20.1,22.2 5 0\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", profile, + "--baseline-profile", profile, + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("run code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "overall coverage: 50.0000% (merge-base 50.0000%") { + t.Fatalf("baseline and candidate did not share one coverage model: %q", stdout.String()) + } +} + +func TestRunFailsClosedWhenBaselineProfileIsMissing(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", profile, + "--baseline-profile", filepath.Join(t.TempDir(), "missing.out"), + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 2 { + t.Fatalf("missing baseline profile code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "open coverage profile") { + t.Fatalf("missing baseline profile did not fail closed: %q", stderr.String()) + } +} + +func TestRunRejectsConflictingBaselineSources(t *testing.T) { + args := []string{ + "--overall-profile", "candidate.out", + "--baseline-profile", "baseline.out", + "--baseline-overall", "100", + "--diff-profile", "candidate.out", + "--base-ref", "base", + "--module", "example.com/project", + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, nil, nil); code != 2 { + t.Fatalf("conflicting baseline sources code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "either --baseline-profile or --baseline-overall, not both") { + t.Fatalf("conflicting baseline sources were not rejected clearly: %q", stderr.String()) + } +} + func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) { dir := t.TempDir() uncovered := filepath.Join(dir, "uncovered.out") @@ -81,7 +190,7 @@ func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) { if code := run(args, &stdout, &stderr, loader, nil); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("repeated profiles were not unioned: %q", stdout.String()) } } @@ -113,7 +222,7 @@ func TestRunChangedOnlyWithBuildableScope(t *testing.T) { if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 { t.Fatalf("run code=%d stderr=%s", code, stderr.String()) } - if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") { t.Fatalf("unexpected changed-only output %q", stdout.String()) } @@ -143,7 +252,11 @@ func TestReadProfiles(t *testing.T) { if err != nil { t.Fatal(err) } - if len(blocks) != 1 || blocks[0].File != "internal/a.go" || blocks[0].Statements != 3 { + if len(blocks) != 1 || + blocks[0].File != "internal/a.go" || + blocks[0].StartColumn != 1 || + blocks[0].EndColumn != 2 || + blocks[0].Statements != 3 { t.Fatalf("blocks=%v", blocks) } if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil { @@ -275,6 +388,41 @@ func TestCrossPlatformCoverageUnionsDuplicateCrossPackageCoverageBlocks(t *testi } } +func TestCoverageKeepsDistinctBlocksOnTheSameLine(t *testing.T) { + result := evaluate(gateInput{ + Diff: []coverageBlock{ + { + File: "internal/a.go", + StartLine: 10, + StartColumn: 1, + EndLine: 10, + EndColumn: 5, + Statements: 1, + Count: 1, + }, + { + File: "internal/a.go", + StartLine: 10, + StartColumn: 6, + EndLine: 10, + EndColumn: 10, + Statements: 1, + Count: 0, + }, + }, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 10}}}, + Target: 100, + ChangedOnly: true, + }) + if result.ChangedCoverage != 50 || result.ChangedStatements != 2 { + t.Fatalf("same-line blocks were collapsed: %#v", result) + } + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "changed code coverage 50.0000% is below target 100.0000%") { + t.Fatalf("same-line uncovered block did not fail the gate: %v", result.Failures) + } +} + func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, @@ -288,6 +436,52 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { } } +func TestEvaluateDefaultsToZeroOverallTolerance(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, + Changed: map[string][]lineRange{}, + BaselineOverall: 40.4, + OverallTolerance: 0, + Target: 100, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "overall coverage regressed from 40.4000% to 40.3000%") { + t.Fatalf("zero-tolerance regression failures = %v", result.Failures) + } +} + +func TestEvaluateRejectsRegressionHiddenByOneDecimalRounding(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{ + {Statements: 1999, Count: 1}, + {Statements: 1, Count: 0}, + }, + Changed: map[string][]lineRange{}, + BaselineOverall: 100, + Target: 100, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "overall coverage regressed from 100.0000% to 99.9500%") { + t.Fatalf("sub-tenth regression failures = %v", result.Failures) + } +} + +func TestEvaluateRejectsChangedCoverageBelow100WithoutRounding(t *testing.T) { + result := evaluate(gateInput{ + Diff: []coverageBlock{ + {File: "internal/a.go", StartLine: 1, EndLine: 1999, Statements: 1999, Count: 1}, + {File: "internal/a.go", StartLine: 2000, EndLine: 2000, Statements: 1, Count: 0}, + }, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2000}}}, + Target: 100, + ChangedOnly: true, + }) + if len(result.Failures) != 1 || + !strings.Contains(result.Failures[0], "changed code coverage 99.9500% is below target 100.0000%") { + t.Fatalf("sub-100 changed-code failures = %v", result.Failures) + } +} + func TestEvaluateFailsClosed(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{ diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index b411804d..611819f3 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -401,7 +401,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { admission := readWorkflow(".github/workflows/ci.yml") for _, want := range []string{ - "name: Code Admission — PR 合入门禁", + "name: CI", "files.length === 1", "files[0].filename === 'CHANGELOG.md'", "files[0].status === 'modified'", @@ -417,6 +417,10 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { "mode=--fast-path", `"$mode" "$PR_BASE_SHA" HEAD`, "needs.lint.outputs.platform_sensitive == 'true'", + `COVERAGE_TARGET: "100"`, + `COVERAGE_ENFORCE_OVERALL: "false"`, + `COVERAGE_OVERALL_TOLERANCE: "0"`, + `COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`, } { if !strings.Contains(admission, want) { t.Errorf("Code Admission workflow missing contract %q", want) @@ -451,6 +455,31 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Error("Code Admission must not suppress required contexts with paths-ignore") } + notification := readWorkflow(".github/workflows/notify-wukong.yml") + if !strings.Contains(notification, "- CI") { + t.Error("Wukong notification must follow the renamed CI workflow") + } + if strings.Contains(notification, "Code Admission — PR 合入门禁") { + t.Error("Wukong notification still follows the retired workflow display name") + } + + coverageGate := readWorkflow("scripts/policy/check-coverage-gate.sh") + if !strings.Contains(coverageGate, `TARGET="${COVERAGE_TARGET:-100}"`) { + t.Error("coverage gate must default to 100% changed-code coverage") + } + if !strings.Contains(coverageGate, `OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}"`) { + t.Error("coverage gate must reject any reported overall regression") + } + if !strings.Contains(coverageGate, `--baseline-profile "$BASELINE_PROFILE"`) { + t.Error("coverage gate must evaluate the merge-base profile with the candidate checker") + } + if strings.Contains(coverageGate, `--overall-profile "$ADDITIONAL_DIFF_PROFILE"`) { + t.Error("supporting changed-code coverage must not inflate candidate overall coverage") + } + if strings.Contains(coverageGate, `go tool cover -func="$BASELINE_PROFILE"`) { + t.Error("coverage baseline must not use a different coverage calculator") + } + aiBehavior := readWorkflow(".github/workflows/ai-behavior-check.yml") for _, want := range []string{ "name: Code Admission — AI Behavior", From 2eca203e74dd0de6ccac18a6782b2c80469d7829 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 17:39:33 +0800 Subject: [PATCH 15/67] fix(event): retry stream ticket once with rotated token after 401 Portal and personal ticket requests now perform a single controlled refresh + retry inside the production chain when the server rejects the resolved access token with HTTP 401: - Add optional ForceRefreshToken callback to PortalTicketConfig and PersonalConfig. It receives the exact rejected token so the app-level compare-and-refresh (ForceRefreshRejectedToken) can dedupe concurrent rotations, and returns the fresh token. - requestPortalTicket / fetchTicket retry the ticket request once with the rotated token directly instead of surfacing an error and hoping an outer loop retries; a second 401 stays fatal to prevent refresh loops. - Refresh failures keep both the original 401 and the refresh error via errors.Join; empty rotated tokens fail fast before hitting the server. - Wire forceRefreshRejectedAccessToken into event consume (portal) and personal stream sources; resolveSourceAccessToken strict semantics are unchanged (provider errors still propagate, no static-token fallback). - Tests: full DingtalkSource.Start -> startPortalTicket chain (401 -> refresh -> ticket ok -> WebSocket event), rotated-token reuse, refresh failure, nil-callback compatibility, second-401 fatality, and app-level wiring. --- internal/app/event_command.go | 4 + internal/app/event_force_refresh_wire_test.go | 47 +++ internal/app/event_personal_command.go | 4 + internal/event/source/personal.go | 53 ++- internal/event/source/portal_ticket.go | 30 +- .../source/portal_ticket_refresh_test.go | 316 ++++++++++++++++++ 6 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 internal/app/event_force_refresh_wire_test.go create mode 100644 internal/event/source/portal_ticket_refresh_test.go diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 3e5434c1..b1235b7e 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -56,6 +56,7 @@ var ( eventNewEventSource = newEventSource eventNewDingtalkSource = source.New eventResolveAccessToken = ResolveAuxiliaryAccessToken + eventForceRefreshRejected = forceRefreshRejectedAccessToken eventBusRun = bus.Run eventReadyFDFromEnv = busctl.ReadyFDFromEnv eventResolvePersonal = resolvePersonalEventIdentity @@ -436,6 +437,9 @@ func newEventSource(_ context.Context, configDir, clientID, clientSecret string, AccessTokenProvider: func(ctx context.Context) (string, error) { return eventResolveAccessToken(ctx, configDir, "") }, + ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) { + return eventForceRefreshRejected(ctx, configDir, rejectedToken) + }, SourceID: eventStreamSourceID(streamOpts.SourceID), Mode: streamOpts.Mode, ClientID: portalClientID, diff --git a/internal/app/event_force_refresh_wire_test.go b/internal/app/event_force_refresh_wire_test.go new file mode 100644 index 00000000..e366681b --- /dev/null +++ b/internal/app/event_force_refresh_wire_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "errors" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" +) + +// TestNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket +// source receives a ForceRefreshToken callback that forwards the actual +// rejected token into the app-level compare-and-refresh chain. +func TestNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) { + oldNew, oldRefresh := eventNewDingtalkSource, eventForceRefreshRejected + t.Cleanup(func() { eventNewDingtalkSource, eventForceRefreshRejected = oldNew, oldRefresh }) + + var captured source.Config + eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) { + captured = cfg + return &source.DingtalkSource{}, nil + } + var gotDir, gotRejected string + eventForceRefreshRejected = func(_ context.Context, configDir, rejectedToken string) (string, error) { + gotDir, gotRejected = configDir, rejectedToken + return "fresh", nil + } + if _, err := newEventSource(context.Background(), "config-dir", "client", "secret", eventStreamTicketOptions{Mode: "custom"}); err != nil { + t.Fatal(err) + } + if captured.PortalTicket == nil || captured.PortalTicket.ForceRefreshToken == nil { + t.Fatal("ForceRefreshToken not wired into portal ticket config") + } + tok, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "rejected-token") + if err != nil || tok != "fresh" { + t.Fatalf("force refresh = %q, %v", tok, err) + } + if gotDir != "config-dir" || gotRejected != "rejected-token" { + t.Fatalf("wiring passed dir %q rejected %q", gotDir, gotRejected) + } + + fail := errors.New("refresh failed") + eventForceRefreshRejected = func(context.Context, string, string) (string, error) { return "", fail } + if _, err := captured.PortalTicket.ForceRefreshToken(context.Background(), "x"); !errors.Is(err, fail) { + t.Fatalf("refresh error = %v", err) + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 2d39c840..35de721d 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -131,6 +131,7 @@ var ( personalFindProcess = os.FindProcess personalSignalProcess = (*os.Process).Signal personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken + personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken personalLoadTokenData = authpkg.LoadTokenData personalClientID = authpkg.ClientID personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict @@ -832,6 +833,9 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio AccessTokenProvider: func(ctx context.Context) (string, error) { return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "") }, + ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) { + return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken) + }, ClientID: clientID, ClientSecret: clientSecret, SourceID: opts.Identity.SourceID, diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go index ec7e1a60..3e6f190a 100644 --- a/internal/event/source/personal.go +++ b/internal/event/source/personal.go @@ -43,6 +43,7 @@ const ( type PersonalConfig struct { AccessToken string AccessTokenProvider AccessTokenProvider + ForceRefreshToken ForceRefreshTokenFn ClientID string ClientSecret string SourceID string @@ -57,6 +58,13 @@ type PersonalConfig struct { type AccessTokenProvider func(context.Context) (string, error) +// ForceRefreshTokenFn rotates an access token that the server has just +// rejected (HTTP 401). It receives the exact rejected token so the caller's +// compare-and-refresh logic can skip the refresh when another goroutine has +// already rotated it, and returns the fresh token to retry with. Optional: +// when nil a 401 stays fatal, matching the previous behavior. +type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error) + type PersonalSource struct { cfg PersonalConfig machine *Machine @@ -199,6 +207,19 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro if err != nil { return nil, err } + ticket, status, err := s.fetchTicketAttempt(ctx, accessToken) + if status == http.StatusUnauthorized && s.cfg.ForceRefreshToken != nil { + refreshed, refreshErr := refreshRejectedSourceToken(ctx, s.cfg.ForceRefreshToken, accessToken, "personal source", err) + if refreshErr != nil { + return nil, refreshErr + } + // Retry once with the freshly rotated token; a second 401 stays fatal. + ticket, _, err = s.fetchTicketAttempt(ctx, refreshed) + } + return ticket, err +} + +func (s *PersonalSource) fetchTicketAttempt(ctx context.Context, accessToken string) (*ticketResponse, int, error) { body := map[string]any{ "sourceId": s.cfg.SourceID, "mode": s.cfg.TicketMode, @@ -210,7 +231,7 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro b, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b)) if err != nil { - return nil, fmt.Errorf("personal source: create ticket request: %w", err) + return nil, 0, fmt.Errorf("personal source: create ticket request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") @@ -221,28 +242,44 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro resp, err := s.cfg.HTTPClient.Do(req) if err != nil { - return nil, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) + return nil, 0, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) if err != nil { - return nil, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) + return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode) if retryableTicketStatus(resp.StatusCode) { - return nil, retryPersonal(err) + return nil, resp.StatusCode, retryPersonal(err) } - return nil, err + return nil, resp.StatusCode, err } ticket, err := decodeTicket(data) if err != nil { - return nil, err + return nil, resp.StatusCode, err } if ticket.Endpoint == "" || ticket.Ticket == "" { - return nil, errors.New("personal source: ticket response missing endpoint or ticket") + return nil, resp.StatusCode, errors.New("personal source: ticket response missing endpoint or ticket") } - return ticket, nil + return ticket, resp.StatusCode, nil +} + +// refreshRejectedSourceToken funnels a server-side 401 into the optional +// force-refresh callback. It hands the actual rejected token to the caller's +// compare-and-refresh logic and returns the rotated token for an immediate +// retry. Refresh failures keep the original 401 as context instead of being +// dropped. +func refreshRejectedSourceToken(ctx context.Context, refresh ForceRefreshTokenFn, rejectedToken, component string, cause error) (string, error) { + token, err := refresh(ctx, rejectedToken) + if err != nil { + return "", fmt.Errorf("%s: refresh rejected access token: %w", component, errors.Join(cause, err)) + } + if token = strings.TrimSpace(token); token == "" { + return "", fmt.Errorf("%s: refresh rejected access token returned empty token: %w", component, cause) + } + return token, nil } func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) { diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go index 9a559356..274c54a7 100644 --- a/internal/event/source/portal_ticket.go +++ b/internal/event/source/portal_ticket.go @@ -42,6 +42,7 @@ type PortalTicketConfig struct { TicketURL string AccessToken string AccessTokenProvider AccessTokenProvider + ForceRefreshToken ForceRefreshTokenFn SourceID string Mode string ClientID string @@ -170,6 +171,19 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt if httpClient == nil { httpClient = &http.Client{Timeout: 20 * time.Second} } + ticket, status, err := requestPortalTicketAttempt(ctx, cfg, httpClient, accessToken) + if status == http.StatusUnauthorized && cfg.ForceRefreshToken != nil { + refreshed, refreshErr := refreshRejectedSourceToken(ctx, cfg.ForceRefreshToken, accessToken, "source: portal ticket", err) + if refreshErr != nil { + return portalStreamTicket{}, refreshErr + } + // Retry once with the freshly rotated token; a second 401 stays fatal. + ticket, _, err = requestPortalTicketAttempt(ctx, cfg, httpClient, refreshed) + } + return ticket, err +} + +func requestPortalTicketAttempt(ctx context.Context, cfg *PortalTicketConfig, httpClient *http.Client, accessToken string) (portalStreamTicket, int, error) { body := map[string]string{ "sourceId": strings.TrimSpace(cfg.SourceID), "channelType": strings.TrimSpace(cfg.SourceID), @@ -182,7 +196,7 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt rawBody, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody)) if err != nil { - return portalStreamTicket{}, err + return portalStreamTicket{}, 0, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") @@ -193,18 +207,18 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt resp, err := httpClient.Do(req) if err != nil { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket request: %w", err) + return portalStreamTicket{}, 0, fmt.Errorf("source: portal ticket request: %w", err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode >= 400 { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket HTTP %d: %s", + return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket HTTP %d: %s", resp.StatusCode, truncatePortalTicketLog(string(raw), 300)) } var direct portalStreamTicket if err := json.Unmarshal(raw, &direct); err == nil && direct.Endpoint != "" && direct.Ticket != "" { - return direct, nil + return direct, resp.StatusCode, nil } var envelope struct { @@ -214,16 +228,16 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt ErrorMsg string `json:"errorMsg"` } if err := json.Unmarshal(raw, &envelope); err != nil { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket parse: %w", err) + return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket parse: %w", err) } if !envelope.Success { - return portalStreamTicket{}, fmt.Errorf("source: portal ticket failed: %s %s", + return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket failed: %s %s", envelope.ErrorCode, envelope.ErrorMsg) } if envelope.Result.Endpoint == "" || envelope.Result.Ticket == "" { - return portalStreamTicket{}, errors.New("source: portal ticket result missing endpoint/ticket") + return portalStreamTicket{}, resp.StatusCode, errors.New("source: portal ticket result missing endpoint/ticket") } - return envelope.Result, nil + return envelope.Result, resp.StatusCode, nil } func websocketURL(ticket portalStreamTicket) (string, error) { diff --git a/internal/event/source/portal_ticket_refresh_test.go b/internal/event/source/portal_ticket_refresh_test.go new file mode 100644 index 00000000..0ced74bb --- /dev/null +++ b/internal/event/source/portal_ticket_refresh_test.go @@ -0,0 +1,316 @@ +package source + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +// TestPortalStart401RefreshRetryEndToEnd drives the full production chain +// DingtalkSource.Start → startPortalTicket → requestPortalTicket: the first +// ticket request is rejected with 401, ForceRefreshToken rotates the token, +// the in-chain retry succeeds with the fresh token and a WebSocket event is +// delivered to emit. +func TestPortalStart401RefreshRetryEndToEnd(t *testing.T) { + var ticketCalls, refreshCalls atomic.Int64 + var rejectedSeen atomic.Value + + upgrader := websocket.Upgrader{} + var wsURL string + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) { + ticketCalls.Add(1) + switch r.Header.Get("x-user-access-token") { + case "fresh-token": + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]string{"endpoint": wsURL, "ticket": "ticket-1"}, + }) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, "token expired") + } + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "msg-1"}, Data: `{}`} + _ = conn.WriteJSON(df) + _, _, _ = conn.ReadMessage() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + s, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL + "/ticket", + AccessTokenProvider: func(context.Context) (string, error) { + return "stale-token", nil + }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + refreshCalls.Add(1) + rejectedSeen.Store(rejectedToken) + return "fresh-token", nil + }, + SourceID: "source", + HTTPClient: srv.Client(), + }}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + emitted := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { done <- s.Start(ctx, func(*dwsevent.RawEvent) { emitted <- struct{}{} }) }() + select { + case <-emitted: + case <-time.After(2 * time.Second): + t.Fatal("portal event timeout after 401 refresh retry") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("portal stop = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("portal stop timeout") + } + + if got := ticketCalls.Load(); got != 2 { + t.Fatalf("ticket calls = %d, want 2", got) + } + if got := refreshCalls.Load(); got != 1 { + t.Fatalf("refresh calls = %d, want 1", got) + } + if got, _ := rejectedSeen.Load().(string); got != "stale-token" { + t.Fatalf("rejected token = %q, want %q", got, "stale-token") + } +} + +// TestRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain +// retry sends the token returned by ForceRefreshToken instead of re-invoking +// the provider (which could still serve the stale token). +func TestRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) { + providerCalls := 0 + var attemptTokens []string + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + token := req.Header.Get("x-user-access-token") + attemptTokens = append(attemptTokens, token) + if token != "rotated" { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://x","ticket":"t"}`)), Header: make(http.Header)}, nil + })} + ticket, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessTokenProvider: func(context.Context) (string, error) { + providerCalls++ + return "stale", nil + }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + if rejectedToken != "stale" { + t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale") + } + return "rotated", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err != nil { + t.Fatalf("requestPortalTicket = %v", err) + } + if ticket.Endpoint != "wss://x" || ticket.Ticket != "t" { + t.Fatalf("ticket = %#v", ticket) + } + if providerCalls != 1 { + t.Fatalf("provider calls = %d, want 1", providerCalls) + } + if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" { + t.Fatalf("attempt tokens = %v", attemptTokens) + } +} + +// TestRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing +// refresh neither retries nor drops the refresh error or the original 401. +func TestRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) { + refreshErr := errors.New("refresh_token exchange failed") + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return "", refreshErr + }, + SourceID: "s", + HTTPClient: client, + }) + if !errors.Is(err, refreshErr) { + t.Fatalf("error should wrap refresh error, got %v", err) + } + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("error should keep original 401, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1 (no retry after failed refresh)", attempts) + } +} + +// TestRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward +// compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401. +func TestRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) { + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", AccessToken: "stale", SourceID: "s", HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 expected, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + +// TestRequestPortalTicketSecond401IsFatal guards against refresh loops: the +// controlled retry happens exactly once even if the rotated token is also +// rejected. +func TestRequestPortalTicketSecond401IsFatal(t *testing.T) { + attempts := 0 + refreshCalls := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + refreshCalls++ + return "rotated-but-still-rejected", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 expected after single retry, got %v", err) + } + if attempts != 2 || refreshCalls != 1 { + t.Fatalf("attempts = %d refreshCalls = %d, want 2/1", attempts, refreshCalls) + } +} + +// TestRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated +// token is rejected instead of being sent to the server. +func TestRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) { + attempts := 0 + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })} + _, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: "https://x", + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return " ", nil + }, + SourceID: "s", + HTTPClient: client, + }) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Fatalf("empty rotated token error expected, got %v", err) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + +// TestPersonalFetchTicket401RefreshRetry mirrors the portal behavior for the +// personal stream ticket path. +func TestPersonalFetchTicket401RefreshRetry(t *testing.T) { + var attemptTokens []string + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { return "stale", nil }, + ForceRefreshToken: func(_ context.Context, rejectedToken string) (string, error) { + if rejectedToken != "stale" { + t.Fatalf("rejected token = %q, want %q", rejectedToken, "stale") + } + return "rotated", nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + token := req.Header.Get("x-user-access-token") + attemptTokens = append(attemptTokens, token) + if token != "rotated" { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + ticket, err := src.fetchTicket(context.Background()) + if err != nil { + t.Fatalf("fetchTicket = %v", err) + } + if ticket.Endpoint != "wss://stream.test" || ticket.Ticket != "ticket" { + t.Fatalf("ticket = %#v", ticket) + } + if len(attemptTokens) != 2 || attemptTokens[0] != "stale" || attemptTokens[1] != "rotated" { + t.Fatalf("attempt tokens = %v", attemptTokens) + } +} + +// TestPersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh +// keeps the 401 fatal (not retryable) and wraps the refresh error. +func TestPersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) { + refreshErr := errors.New("refresh_token exchange failed") + src, err := NewPersonal(PersonalConfig{ + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + return "", refreshErr + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader("expired")), Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if !errors.Is(err, refreshErr) { + t.Fatalf("error should wrap refresh error, got %v", err) + } + if isRetryablePersonalError(err) { + t.Fatalf("failed refresh should stay fatal, got retryable %v", err) + } +} From 377ebc5e85ff9212344a4fc9cf3e11e6de22fde0 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 17:57:27 +0800 Subject: [PATCH 16/67] fix(event): classify personal ticket errors by status before reading body A 401 whose error body failed mid-read (e.g. unexpected EOF) was wrapped as retryable by the body-read path, letting the outer reconnect loop re-enter fetchTicket and refresh again on every iteration, bypassing the single refresh-retry guard. Classify non-2xx responses by status first; the body is only drained best-effort since it is never used for error reporting here. 401 stays fatal regardless of body state, while 2xx body-read failures remain retryable transport errors. --- internal/event/source/personal.go | 14 ++-- .../source/portal_ticket_refresh_test.go | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go index 3e6f190a..c7492a0f 100644 --- a/internal/event/source/personal.go +++ b/internal/event/source/personal.go @@ -245,17 +245,23 @@ func (s *PersonalSource) fetchTicketAttempt(ctx context.Context, accessToken str return nil, 0, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) } defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) - if err != nil { - return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) - } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Classify by status before touching the body: a truncated error body + // must not upgrade a fatal status (notably 401) into a retryable + // error, or the outer reconnect loop would bypass the single + // refresh-retry guard. The body is not used here, so drain it only + // best-effort for connection reuse. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, config.MaxResponseBodySize)) err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode) if retryableTicketStatus(resp.StatusCode) { return nil, resp.StatusCode, retryPersonal(err) } return nil, resp.StatusCode, err } + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, resp.StatusCode, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) + } ticket, err := decodeTicket(data) if err != nil { return nil, resp.StatusCode, err diff --git a/internal/event/source/portal_ticket_refresh_test.go b/internal/event/source/portal_ticket_refresh_test.go index 0ced74bb..b12c4b13 100644 --- a/internal/event/source/portal_ticket_refresh_test.go +++ b/internal/event/source/portal_ticket_refresh_test.go @@ -314,3 +314,74 @@ func TestPersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) { t.Fatalf("failed refresh should stay fatal, got retryable %v", err) } } + +// brokenBody simulates a response body that fails mid-read, e.g. the server +// closing the connection before the error payload is fully written. +type brokenBody struct{} + +func (brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (brokenBody) Close() error { return nil } + +// TestPersonalFetchTicket401TruncatedBodyStaysFatal guards the single +// refresh-retry protection: a 401 whose body fails with unexpected EOF must +// be classified by status (fatal) and never wrapped as retryable, otherwise +// the outer reconnect loop would refresh again on every iteration. +func TestPersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) { + attempts := 0 + refreshCalls := 0 + src, err := NewPersonal(PersonalConfig{ + AccessToken: "stale", + ForceRefreshToken: func(context.Context, string) (string, error) { + refreshCalls++ + return "rotated", nil + }, + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: brokenBody{}, Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if err == nil || !strings.Contains(err.Error(), "HTTP 401") { + t.Fatalf("fatal 401 error expected, got %v", err) + } + if isRetryablePersonalError(err) { + t.Fatalf("401 with truncated body must stay fatal, got retryable %v", err) + } + if refreshCalls != 1 { + t.Fatalf("refresh calls = %d, want 1", refreshCalls) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2", attempts) + } +} + +// TestPersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing +// behavior for success responses: a body read failure on 2xx is a transient +// transport problem and remains retryable. +func TestPersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) { + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: brokenBody{}, Header: make(http.Header)}, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + _, err = src.fetchTicket(context.Background()) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("error should wrap io.ErrUnexpectedEOF, got %v", err) + } + if !isRetryablePersonalError(err) { + t.Fatalf("2xx body read failure should stay retryable, got %v", err) + } +} From 660c908585c39e06367f2ab66973da117449a56e Mon Sep 17 00:00:00 2001 From: zhengyubai Date: Mon, 20 Jul 2026 18:09:15 +0800 Subject: [PATCH 17/67] fix(auth): classify refresh failures and keep transient ones recoverable Restored from the pre-rewrite branch head 342d44efe (backed up as backup/event-token-lazy-resolution-pre-rewrite); the auth-layer changes apply verbatim on the rebased branch. - Add ClassifyRefreshFailure with structured HTTPStatusError so refresh failures split into transient (network, timeout, 408/429/5xx) and terminal (400/401/403) classes; unknown errors stay fatal. - GetTokenSnapshot no longer marks a profile expired on transient refresh failures, so long-running sources can retry after backoff. - postJSON returns HTTPStatusError keeping the response body out of the error string; the OAuth callback page HTML-escapes the sanitized exchange error instead of echoing raw server output. - isInvalidGrantError also matches the preserved response body. --- CHANGELOG.md | 1 + internal/auth/device_flow.go | 2 +- internal/auth/oauth_helpers.go | 5 +- internal/auth/oauth_provider.go | 18 +++- internal/auth/refresh_failure.go | 85 ++++++++++++++++ internal/auth/refresh_failure_test.go | 138 ++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 internal/auth/refresh_failure.go create mode 100644 internal/auth/refresh_failure_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a636547..5bff2437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed +- **Long-running event authentication recovery** — personal and portal event streams resolve the current access token for every ticket request, refresh a server-rejected token with compare-and-refresh semantics, and reconnect with backoff when refresh is temporarily blocked by network failures, rate limits, or 5xx responses. - **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.” - **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery. - **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget. diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 644a9aba..30e86fc1 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -722,6 +722,6 @@ func isInvalidGrantError(err error) bool { if err == nil { return false } - msg := strings.ToLower(err.Error()) + msg := strings.ToLower(err.Error() + " " + httpStatusResponseBody(err)) return strings.Contains(msg, "invalid_grant") || (strings.Contains(msg, "code") && strings.Contains(msg, "expired")) } diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index bac901c9..9da470a0 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -242,7 +242,10 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) return nil, fmt.Errorf("reading response: %w", err) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200)) + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + responseBody: truncateBody(data, 200), + } } return data, nil } diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 3f894bc0..1e5bebe0 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -18,6 +18,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log/slog" "net" @@ -301,7 +302,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro callbackTokenMu.Unlock() w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprintf(w, "

授权失败

%s

", exchangeErr.Error()) + _, _ = fmt.Fprintf(w, "

授权失败

%s

", html.EscapeString(oauthExchangeDisplayError(exchangeErr))) select { case resultCh <- callbackResult{err: exchangeErr}: default: @@ -642,6 +643,14 @@ continueLogin: return tokenData, nil } +func oauthExchangeDisplayError(err error) string { + var statusErr *HTTPStatusError + if errors.As(err, &statusErr) && statusErr != nil { + return fmt.Sprintf("HTTP %d: token exchange failed", statusErr.StatusCode) + } + return err.Error() +} + // GetTokenSnapshot returns a valid token together with its expiry metadata. // Storage and refresh failures retain their original cause; only a confirmed // missing credential is reported as ErrTokenDataNotFound. @@ -665,7 +674,12 @@ func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error if rErr == nil { return refreshed, nil } - _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) + // A network, timeout, rate-limit or 5xx failure does not invalidate the + // refresh credential. Keep the profile active so a long-running source + // can retry after backoff. Terminal and unknown failures remain fatal. + if ClassifyRefreshFailure(rErr) != RefreshFailureTransient { + _ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired) + } if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } diff --git a/internal/auth/refresh_failure.go b/internal/auth/refresh_failure.go new file mode 100644 index 00000000..0c553607 --- /dev/null +++ b/internal/auth/refresh_failure.go @@ -0,0 +1,85 @@ +// 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 auth + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" +) + +// RefreshFailureClass separates refresh failures that may recover after a +// delay from failures that require new credentials or local intervention. +type RefreshFailureClass string + +const ( + RefreshFailureUnknown RefreshFailureClass = "unknown" + RefreshFailureTransient RefreshFailureClass = "transient" + RefreshFailureTerminal RefreshFailureClass = "terminal" +) + +// HTTPStatusError preserves an OAuth endpoint status for structured retry +// decisions without copying an untrusted response body into logs. +type HTTPStatusError struct { + StatusCode int + responseBody string +} + +func (e *HTTPStatusError) Error() string { + if e == nil { + return "OAuth endpoint request failed" + } + return fmt.Sprintf("HTTP %d", e.StatusCode) +} + +func httpStatusResponseBody(err error) string { + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr == nil { + return "" + } + return statusErr.responseBody +} + +// ClassifyRefreshFailure uses only structured transport and HTTP signals. +// Unknown errors, including parse, keychain and persistence failures, remain +// fatal so a long-running source cannot retry an error that needs user action. +func ClassifyRefreshFailure(err error) RefreshFailureClass { + if err == nil { + return RefreshFailureUnknown + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return RefreshFailureTransient + } + var netErr net.Error + if errors.As(err, &netErr) { + return RefreshFailureTransient + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr == nil { + return RefreshFailureUnknown + } + if statusErr.StatusCode == http.StatusRequestTimeout || + statusErr.StatusCode == http.StatusTooManyRequests || + statusErr.StatusCode >= http.StatusInternalServerError { + return RefreshFailureTransient + } + if statusErr.StatusCode == http.StatusBadRequest || + statusErr.StatusCode == http.StatusUnauthorized || + statusErr.StatusCode == http.StatusForbidden { + return RefreshFailureTerminal + } + return RefreshFailureUnknown +} diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go new file mode 100644 index 00000000..3acd1301 --- /dev/null +++ b/internal/auth/refresh_failure_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package auth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *testing.T) { + tests := []struct { + name string + err error + want RefreshFailureClass + }{ + {name: "deadline", err: context.DeadlineExceeded, want: RefreshFailureTransient}, + {name: "network", err: &url.Error{Op: "Post", URL: "https://oauth.test", Err: context.DeadlineExceeded}, want: RefreshFailureTransient}, + {name: "request timeout", err: &HTTPStatusError{StatusCode: http.StatusRequestTimeout}, want: RefreshFailureTransient}, + {name: "rate limited", err: &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, want: RefreshFailureTransient}, + {name: "server unavailable", err: &HTTPStatusError{StatusCode: http.StatusServiceUnavailable}, want: RefreshFailureTransient}, + {name: "refresh rejected", err: &HTTPStatusError{StatusCode: http.StatusUnauthorized}, want: RefreshFailureTerminal}, + {name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal}, + {name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal}, + {name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClassifyRefreshFailure(tt.err); got != tt.want { + t.Fatalf("ClassifyRefreshFailure() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *testing.T) { + want := &HTTPStatusError{StatusCode: http.StatusTooManyRequests} + err := errors.Join(errors.New("refresh failed"), want) + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %q, want transient", got) + } + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("HTTP status error not retained: %v", err) + } + if got, want := statusErr.Error(), "HTTP 429"; got != want { + t.Fatalf("HTTP status error = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) { + const secretBody = `{"refreshToken":"must-not-reach-logs"}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(secretBody)) + })) + defer server.Close() + + provider := &OAuthProvider{httpClient: server.Client()} + _, err := provider.postJSON(context.Background(), server.URL, map[string]string{"grantType": "refresh_token"}) + if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient { + t.Fatalf("ClassifyRefreshFailure() = %q, want transient: %v", got, err) + } + if strings.Contains(err.Error(), "must-not-reach-logs") { + t.Fatalf("postJSON error leaked response body: %v", err) + } + if got := httpStatusResponseBody(err); !strings.Contains(got, "must-not-reach-logs") { + t.Fatalf("postJSON did not retain bounded response details for internal classification: %q", got) + } +} + +func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientRefreshFailures(t *testing.T) { + oldLoad := oauthLoadToken + oldLoadLocked := oauthLoadTokenLocked + oldAcquire := oauthAcquireLock + oldRefresh := oauthRefreshToken + oldMark := oauthMarkProfile + oldEdition := edition.Get() + t.Cleanup(func() { + oauthLoadToken = oldLoad + oauthLoadTokenLocked = oldLoadLocked + oauthAcquireLock = oldAcquire + oauthRefreshToken = oldRefresh + oauthMarkProfile = oldMark + edition.Override(oldEdition) + }) + edition.Override(&edition.Hooks{}) + + expired := &TokenData{ + AccessToken: "expired-access", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshToken: "refresh", + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp", + UserID: "user", + } + oauthLoadToken = func(string) (*TokenData, error) { return expired, nil } + oauthLoadTokenLocked = func(string, string) (*TokenData, error) { return expired, nil } + oauthAcquireLock = func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil } + + markCalls := 0 + oauthMarkProfile = func(_, _, status string) error { + if status != ProfileStatusExpired { + t.Fatalf("profile status = %q, want %q", status, ProfileStatusExpired) + } + markCalls++ + return nil + } + provider := NewOAuthProvider(t.TempDir(), nil) + + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &HTTPStatusError{StatusCode: http.StatusServiceUnavailable} + } + if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTransient { + t.Fatalf("transient refresh error = %v", err) + } + if markCalls != 0 { + t.Fatalf("transient refresh marked profile expired %d times", markCalls) + } + + oauthRefreshToken = func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &HTTPStatusError{StatusCode: http.StatusUnauthorized} + } + if _, err := provider.GetTokenSnapshot(context.Background()); ClassifyRefreshFailure(err) != RefreshFailureTerminal { + t.Fatalf("terminal refresh error = %v", err) + } + if markCalls != 1 { + t.Fatalf("terminal refresh marked profile expired %d times, want 1", markCalls) + } +} From c0f4d21c055ce12e0bbe3ebe6b5422963d08b12e Mon Sep 17 00:00:00 2001 From: zhengyubai Date: Mon, 20 Jul 2026 18:37:23 +0800 Subject: [PATCH 18/67] fix(event): keep long-running sources alive across transient auth failures Ported from 342d44efe (backup/event-token-lazy-resolution-pre-rewrite) and adapted to the current in-place single 401 refresh+retry design: - portal source: classify ticket/dial/read/ack failures via portalStageError and reconnect with backoff on retryable stages only (DisableReconnect for tests and one-shot callers); stage errors never leak response bodies - personal/portal: transient token provider or refresh failures (network, 408/429/5xx) go through the reconnect loop instead of killing the source; terminal failures (400/401/403) remain fatal - personalRetryLogError: token resolution/refresh errors log only the structured HTTP status, never provider error details Unlike the original commit, a rejected token is still retried once in place after a successful refresh, and a second 401 stays fatal (single-refresh guard agreed in review). --- internal/event/source/coverage_edges_test.go | 4 +- internal/event/source/personal.go | 24 ++ internal/event/source/portal_ticket.go | 146 ++++++++++-- .../source/transient_auth_reconnect_test.go | 218 ++++++++++++++++++ 4 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 internal/event/source/transient_auth_reconnect_test.go diff --git a/internal/event/source/coverage_edges_test.go b/internal/event/source/coverage_edges_test.go index 9849ef74..59381253 100644 --- a/internal/event/source/coverage_edges_test.go +++ b/internal/event/source/coverage_edges_test.go @@ -414,7 +414,7 @@ func TestCrossPlatformCoveragePortalStartEndToEndAndFailures(t *testing.T) { return s } network := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errSourceInjected })} - s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network}) + s := makeSource(&PortalTicketConfig{TicketURL: "https://x", AccessToken: "t", SourceID: "s", HTTPClient: network, DisableReconnect: true}) if err := s.Start(context.Background(), func(*dwsevent.RawEvent) {}); err == nil { t.Fatal("ticket failure expected") } @@ -483,7 +483,7 @@ func TestCrossPlatformCoveragePortalStartHandshakeReadAndAckErrors(t *testing.T) makeSource := func(endpoint string) *DingtalkSource { s, err := New(Config{PortalTicket: &PortalTicketConfig{ TicketURL: "https://ticket", AccessToken: "token", SourceID: "source", - HTTPClient: staticPersonalTicketClient(endpoint, ""), + HTTPClient: staticPersonalTicketClient(endpoint, ""), DisableReconnect: true, }}) if err != nil { t.Fatal(err) diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go index c7492a0f..c461c17b 100644 --- a/internal/event/source/personal.go +++ b/internal/event/source/personal.go @@ -27,6 +27,7 @@ import ( "sync/atomic" "time" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/gorilla/websocket" @@ -205,12 +206,20 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) ( func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) { accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source") if err != nil { + // Transient provider failures (network, 429, 5xx) must not kill a + // long-running source; the reconnect loop retries after backoff. + if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient { + return nil, retryPersonal(err) + } return nil, err } ticket, status, err := s.fetchTicketAttempt(ctx, accessToken) if status == http.StatusUnauthorized && s.cfg.ForceRefreshToken != nil { refreshed, refreshErr := refreshRejectedSourceToken(ctx, s.cfg.ForceRefreshToken, accessToken, "personal source", err) if refreshErr != nil { + if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient { + return nil, retryPersonal(refreshErr) + } return nil, refreshErr } // Retry once with the freshly rotated token; a second 401 stays fatal. @@ -424,6 +433,13 @@ func isRetryablePersonalError(err error) bool { func personalRetryLogError(err error) string { message := err.Error() switch { + case strings.Contains(message, "resolve access token"), strings.Contains(message, "refresh rejected access token"): + // Token resolution/refresh errors may carry provider details; log + // only the structured HTTP status. + if status := refreshHTTPStatus(err); status != 0 { + return fmt.Sprintf("personal source: token refresh HTTP %d", status) + } + return "personal source: token refresh: temporary network error" case strings.Contains(message, "ticket HTTP"): return message case strings.Contains(message, "fetch ticket"): @@ -441,6 +457,14 @@ func personalRetryLogError(err error) string { } } +func refreshHTTPStatus(err error) int { + var statusErr *authpkg.HTTPStatusError + if !errors.As(err, &statusErr) || statusErr == nil { + return 0 + } + return statusErr.StatusCode +} + func retryableTicketStatus(status int) bool { return status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go index 274c54a7..bf3a1647 100644 --- a/internal/event/source/portal_ticket.go +++ b/internal/event/source/portal_ticket.go @@ -20,11 +20,13 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" "time" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/gorilla/websocket" "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" @@ -33,6 +35,8 @@ import ( const ( PortalTicketModeNormal = "normal" PortalTicketModeCustom = "custom" + portalReconnectMin = time.Second + portalReconnectMax = 30 * time.Second ) // PortalTicketConfig describes the portal-managed user Stream ticket flow. @@ -49,6 +53,38 @@ type PortalTicketConfig struct { ClientSecret string UserAgent string HTTPClient *http.Client + WebSocketDialer *websocket.Dialer + ReconnectMin time.Duration + ReconnectMax time.Duration + DisableReconnect bool +} + +// portalStageError tags a portal stream failure with the stage it happened +// in and whether the reconnect loop may retry it. Error() stays free of +// untrusted response content so it is safe to log on every reconnect. +type portalStageError struct { + stage string + status int + retryable bool + cause error +} + +func (e *portalStageError) Error() string { + if e == nil { + return "source: portal stream failed" + } + message := "source: portal " + strings.ReplaceAll(strings.TrimSpace(e.stage), "_", " ") + " failed" + if e.status != 0 { + message += fmt.Sprintf(" (HTTP %d)", e.status) + } + return message +} + +func (e *portalStageError) Unwrap() error { + if e == nil { + return nil + } + return e.cause } var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error { @@ -98,62 +134,122 @@ func normalizePortalTicketMode(mode string) string { func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.EmitFn) error { s.machine.OnConnecting() + defer s.machine.OnStopped() + minBackoff := s.cfg.PortalTicket.ReconnectMin + if minBackoff <= 0 { + minBackoff = portalReconnectMin + } + maxBackoff := s.cfg.PortalTicket.ReconnectMax + if maxBackoff <= 0 { + maxBackoff = portalReconnectMax + } + if maxBackoff < minBackoff { + maxBackoff = minBackoff + } + backoff := minBackoff + for { + acked, err := s.runPortalTicketAttempt(ctx, emit) + if ctx.Err() != nil { + return ctx.Err() + } + var stageErr *portalStageError + if !errors.As(err, &stageErr) || stageErr == nil || !stageErr.retryable || s.cfg.PortalTicket.DisableReconnect { + return err + } + if acked { + backoff = minBackoff + } + s.machine.OnReconnect() + slog.Warn("portal source reconnecting", + "stage", stageErr.stage, + "http_status", stageErr.status, + "error_type", fmt.Sprintf("%T", stageErr.cause), + "retry_in", backoff, + "reconnect_count", s.machine.Snapshot().ReconnectCount, + ) + if err := waitPersonalReconnect(ctx, backoff); err != nil { + return err + } + backoff = nextPersonalBackoff(backoff, maxBackoff) + } +} + +func (s *DingtalkSource) runPortalTicketAttempt(ctx context.Context, emit dwsevent.EmitFn) (bool, error) { ticket, err := requestPortalTicket(ctx, s.cfg.PortalTicket) if err != nil { - s.machine.OnStopped() - return err + return false, err } wsURL, err := websocketURL(ticket) if err != nil { - s.machine.OnStopped() - return err + return false, err } userAgent := strings.TrimSpace(s.cfg.PortalTicket.UserAgent) if userAgent == "" { userAgent = "dws-event-consume" } - conn, resp, err := (&websocket.Dialer{HandshakeTimeout: 20 * time.Second}).DialContext(ctx, wsURL, http.Header{ + dialer := s.cfg.PortalTicket.WebSocketDialer + if dialer == nil { + dialer = &websocket.Dialer{HandshakeTimeout: 20 * time.Second} + } + conn, resp, err := dialer.DialContext(ctx, wsURL, http.Header{ "User-Agent": []string{userAgent}, }) if err != nil { - s.machine.OnStopped() + status := 0 + cause := fmt.Errorf("source: portal stream connect: %w", err) if resp != nil { defer resp.Body.Close() + status = resp.StatusCode raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - return fmt.Errorf("source: portal stream connect HTTP %d: %s: %w", + cause = fmt.Errorf("source: portal stream connect HTTP %d: %s: %w", resp.StatusCode, truncatePortalTicketLog(string(raw), 300), err) } - return fmt.Errorf("source: portal stream connect: %w", err) + return false, &portalStageError{ + stage: "stream_connect", + status: status, + retryable: status == 0 || retryableTicketStatus(status), + cause: cause, + } } - defer conn.Close() + attemptCtx, cancel := context.WithCancel(ctx) + defer func() { + cancel() + _ = conn.Close() + }() + closeOnContext(attemptCtx, conn) s.machine.OnConnected() - closeOnContext(ctx, conn) handler := s.makeHandler(emit) + acked := false for { _, message, err := conn.ReadMessage() if err != nil { - s.machine.OnStopped() if isContextDone(ctx) { - return ctx.Err() + return acked, ctx.Err() } - return fmt.Errorf("source: portal stream read: %w", err) + return acked, &portalStageError{stage: "stream_read", retryable: true, cause: fmt.Errorf("source: portal stream read: %w", err)} } df, err := payload.DecodeDataFrame(message) if err != nil { continue } - resp, _ := handler(ctx, df) + resp, err := handler(ctx, df) + if err != nil { + return acked, fmt.Errorf("source: portal event handler: %w", err) + } + if resp == nil { + continue + } ensurePortalAckHeaders(resp, df) if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil { - s.machine.OnStopped() if isContextDone(ctx) { - return ctx.Err() + return acked, ctx.Err() } - return fmt.Errorf("source: portal stream ack: %w", err) + return acked, &portalStageError{stage: "stream_ack", retryable: true, cause: fmt.Errorf("source: portal stream ack: %w", err)} } + acked = true } } @@ -165,6 +261,11 @@ type portalStreamTicket struct { func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) { accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket") if err != nil { + // Transient provider failures (network, 429, 5xx) must not kill a + // long-running source; the reconnect loop retries after backoff. + if authpkg.ClassifyRefreshFailure(err) == authpkg.RefreshFailureTransient { + return portalStreamTicket{}, &portalStageError{stage: "ticket_auth", status: refreshHTTPStatus(err), retryable: true, cause: err} + } return portalStreamTicket{}, err } httpClient := cfg.HTTPClient @@ -175,6 +276,9 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt if status == http.StatusUnauthorized && cfg.ForceRefreshToken != nil { refreshed, refreshErr := refreshRejectedSourceToken(ctx, cfg.ForceRefreshToken, accessToken, "source: portal ticket", err) if refreshErr != nil { + if authpkg.ClassifyRefreshFailure(refreshErr) == authpkg.RefreshFailureTransient { + return portalStreamTicket{}, &portalStageError{stage: "ticket_auth_refresh", status: refreshHTTPStatus(refreshErr), retryable: true, cause: refreshErr} + } return portalStreamTicket{}, refreshErr } // Retry once with the freshly rotated token; a second 401 stays fatal. @@ -207,13 +311,17 @@ func requestPortalTicketAttempt(ctx context.Context, cfg *PortalTicketConfig, ht resp, err := httpClient.Do(req) if err != nil { - return portalStreamTicket{}, 0, fmt.Errorf("source: portal ticket request: %w", err) + return portalStreamTicket{}, 0, &portalStageError{stage: "ticket_request", retryable: true, cause: fmt.Errorf("source: portal ticket request: %w", err)} } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode >= 400 { - return portalStreamTicket{}, resp.StatusCode, fmt.Errorf("source: portal ticket HTTP %d: %s", + httpErr := fmt.Errorf("source: portal ticket HTTP %d: %s", resp.StatusCode, truncatePortalTicketLog(string(raw), 300)) + if retryableTicketStatus(resp.StatusCode) { + return portalStreamTicket{}, resp.StatusCode, &portalStageError{stage: "ticket_request", status: resp.StatusCode, retryable: true, cause: httpErr} + } + return portalStreamTicket{}, resp.StatusCode, httpErr } var direct portalStreamTicket diff --git a/internal/event/source/transient_auth_reconnect_test.go b/internal/event/source/transient_auth_reconnect_test.go new file mode 100644 index 00000000..3c2d8c42 --- /dev/null +++ b/internal/event/source/transient_auth_reconnect_test.go @@ -0,0 +1,218 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package source + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" +) + +func transientRefreshError() error { + return &authpkg.HTTPStatusError{StatusCode: http.StatusServiceUnavailable} +} + +func terminalRefreshError() error { + return &authpkg.HTTPStatusError{StatusCode: http.StatusUnauthorized} +} + +func TestCrossPlatformCoveragePersonalRetryLogErrorReportsOnlySafeTransientStatus(t *testing.T) { + cause := errors.New("personal source: ticket HTTP 401 secret detail") + _, err := refreshRejectedSourceToken(context.Background(), func(context.Context, string) (string, error) { + return "", transientRefreshError() + }, "rejected", "personal source", cause) + if got, want := personalRetryLogError(retryPersonal(err)), "personal source: token refresh HTTP 503"; got != want { + t.Fatalf("personalRetryLogError() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePersonalSourceRetriesTransientTokenResolutionFailure(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls atomic.Int32 + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + if calls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: "https://ticket.invalid", + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if calls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePersonalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) { + var calls atomic.Int32 + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { + calls.Add(1) + return "", terminalRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: "https://ticket.invalid", + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(context.Background(), func(*dwsevent.RawEvent) {}) + if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal { + t.Fatalf("Start() error = %v, want terminal refresh failure", err) + } + if calls.Load() != 1 || src.State().ReconnectCount != 0 { + t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePersonalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var ticketCalls atomic.Int32 + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ticketCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := NewPersonal(PersonalConfig{ + AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil }, + ForceRefreshToken: func(_ context.Context, rejected string) (string, error) { + if rejected != "old-token" { + t.Fatalf("rejected token = %q, want old-token", rejected) + } + if refreshCalls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL, + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls atomic.Int32 + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: "https://ticket.invalid", + AccessTokenProvider: func(context.Context) (string, error) { + if calls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + SourceID: "open", + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if calls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("provider calls=%d reconnects=%d, want 2 calls and 1 reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) { + var calls atomic.Int32 + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: "https://ticket.invalid", + AccessTokenProvider: func(context.Context) (string, error) { + calls.Add(1) + return "", terminalRefreshError() + }, + SourceID: "open", + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(context.Background(), func(*dwsevent.RawEvent) {}) + if authpkg.ClassifyRefreshFailure(err) != authpkg.RefreshFailureTerminal { + t.Fatalf("Start() error = %v, want terminal refresh failure", err) + } + if calls.Load() != 1 || src.State().ReconnectCount != 0 { + t.Fatalf("provider calls=%d reconnects=%d, want 1 call and no reconnect", calls.Load(), src.State().ReconnectCount) + } +} + +func TestCrossPlatformCoveragePortalSourceRetriesTransientRejectedTokenRefresh(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var ticketCalls atomic.Int32 + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ticketCalls.Add(1) + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL, + AccessTokenProvider: func(context.Context) (string, error) { return "old-token", nil }, + ForceRefreshToken: func(_ context.Context, rejected string) (string, error) { + if rejected != "old-token" { + t.Fatalf("rejected token = %q, want old-token", rejected) + } + if refreshCalls.Add(1) == 2 { + cancel() + } + return "", transientRefreshError() + }, + SourceID: "open", + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + err = src.Start(ctx, func(*dwsevent.RawEvent) {}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled after retry", err) + } + if ticketCalls.Load() != 2 || refreshCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("ticket calls=%d refresh calls=%d reconnects=%d", ticketCalls.Load(), refreshCalls.Load(), src.State().ReconnectCount) + } +} From 55d7fbf59abb52d25a0aad225604e05d316062de Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 21:00:21 +0800 Subject: [PATCH 19/67] test: close coverage gate gaps on transient auth recovery paths The Coverage gate flagged 16 uncovered changed statements (90.6% < 100%): - drop the unreachable handler error / nil response branches in runPortalTicketAttempt: makeHandler never fails, matching the pre-port portal loop on main - cover portalStageError nil Error/Unwrap, the reconnect min/max clamp, and the acked backoff reset via an end-to-end reconnect test - cover personalRetryLogError fallback when a token failure carries no structured HTTP status - cover ClassifyRefreshFailure nil/net.Error/redirect branches, the nil HTTPStatusError message, and oauthExchangeDisplayError fallback - cover the personal stream source ForceRefreshToken wiring end to end Local gate now reports changed code coverage 100.0% (165 statements). --- .../event_personal_force_refresh_wire_test.go | 54 +++++++++++++ internal/auth/refresh_failure_test.go | 17 ++++ internal/event/source/portal_ticket.go | 8 +- .../source/transient_auth_reconnect_test.go | 81 ++++++++++++++++++- 4 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 internal/app/event_personal_force_refresh_wire_test.go diff --git a/internal/app/event_personal_force_refresh_wire_test.go b/internal/app/event_personal_force_refresh_wire_test.go new file mode 100644 index 00000000..2b1ae8df --- /dev/null +++ b/internal/app/event_personal_force_refresh_wire_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" +) + +// TestNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the +// personal stream source receives a ForceRefreshToken callback that forwards +// the rejected token into the app-level compare-and-refresh chain. +func TestNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) { + oldAux := personalResolveAuxiliaryAccessToken + oldRefresh := personalForceRefreshRejectedToken + t.Cleanup(func() { + personalResolveAuxiliaryAccessToken = oldAux + personalForceRefreshRejectedToken = oldRefresh + }) + personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { + return "old-token", nil + } + refreshErr := errors.New("refresh rejected") + var gotDir, gotRejected string + personalForceRefreshRejectedToken = func(_ context.Context, configDir, rejectedToken string) (string, error) { + gotDir, gotRejected = configDir, rejectedToken + return "", refreshErr + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ + ConfigDir: "config-dir", + Identity: personal.Identity{ClientID: "client", SourceID: "source"}, + TicketURL: srv.URL, + }) + if err != nil { + t.Fatal(err) + } + // The 401 ticket response routes the rejected token through the wired + // ForceRefreshToken; the unknown refresh failure stays fatal. + if err := src.Start(context.Background(), func(*dwsevent.RawEvent) {}); !errors.Is(err, refreshErr) { + t.Fatalf("Start() error = %v, want wrapped refresh error", err) + } + if gotDir != "config-dir" || gotRejected != "old-token" { + t.Fatalf("refresh wiring got dir %q rejected %q", gotDir, gotRejected) + } +} diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 3acd1301..e27a4920 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -6,6 +6,7 @@ package auth import ( "context" "errors" + "net" "net/http" "net/http/httptest" "net/url" @@ -31,6 +32,9 @@ func TestCrossPlatformCoverageClassifyRefreshFailureUsesStructuredSignals(t *tes {name: "invalid grant", err: &HTTPStatusError{StatusCode: http.StatusBadRequest}, want: RefreshFailureTerminal}, {name: "forbidden", err: &HTTPStatusError{StatusCode: http.StatusForbidden}, want: RefreshFailureTerminal}, {name: "local persistence", err: errors.New("save refreshed token failed"), want: RefreshFailureUnknown}, + {name: "nil error", err: nil, want: RefreshFailureUnknown}, + {name: "dns failure", err: &net.DNSError{Err: "no such host", Name: "oauth.test"}, want: RefreshFailureTransient}, + {name: "redirect status", err: &HTTPStatusError{StatusCode: http.StatusFound}, want: RefreshFailureUnknown}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -54,6 +58,19 @@ func TestCrossPlatformCoverageHTTPStatusErrorRetainsStatusThroughWrapping(t *tes if got, want := statusErr.Error(), "HTTP 429"; got != want { t.Fatalf("HTTP status error = %q, want %q", got, want) } + var nilStatus *HTTPStatusError + if got, want := nilStatus.Error(), "OAuth endpoint request failed"; got != want { + t.Fatalf("nil HTTP status error = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoverageOAuthExchangeDisplayErrorFallsBackToPlainError(t *testing.T) { + if got, want := oauthExchangeDisplayError(&HTTPStatusError{StatusCode: http.StatusBadGateway}), "HTTP 502: token exchange failed"; got != want { + t.Fatalf("status display error = %q, want %q", got, want) + } + if got, want := oauthExchangeDisplayError(errors.New("exchange failed")), "exchange failed"; got != want { + t.Fatalf("plain display error = %q, want %q", got, want) + } } func TestCrossPlatformCoveragePostJSONClassifiesStatusWithoutLoggingResponseBody(t *testing.T) { diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go index bf3a1647..75f5ca3f 100644 --- a/internal/event/source/portal_ticket.go +++ b/internal/event/source/portal_ticket.go @@ -235,13 +235,7 @@ func (s *DingtalkSource) runPortalTicketAttempt(ctx context.Context, emit dwseve if err != nil { continue } - resp, err := handler(ctx, df) - if err != nil { - return acked, fmt.Errorf("source: portal event handler: %w", err) - } - if resp == nil { - continue - } + resp, _ := handler(ctx, df) ensurePortalAckHeaders(resp, df) if err := portalWriteMessage(conn, websocket.TextMessage, resp.Encode()); err != nil { if isContextDone(ctx) { diff --git a/internal/event/source/transient_auth_reconnect_test.go b/internal/event/source/transient_auth_reconnect_test.go index 3c2d8c42..35d3b395 100644 --- a/internal/event/source/transient_auth_reconnect_test.go +++ b/internal/event/source/transient_auth_reconnect_test.go @@ -6,14 +6,19 @@ package source import ( "context" "errors" + "fmt" + "io" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" ) func transientRefreshError() error { @@ -127,6 +132,28 @@ func TestCrossPlatformCoveragePersonalSourceRetriesTransientRejectedTokenRefresh } } +func TestCrossPlatformCoveragePersonalRetryLogErrorFallsBackToNetworkMessage(t *testing.T) { + err := retryPersonal(fmt.Errorf("personal source: resolve access token: %w", errors.New("dial tcp: lookup oauth.invalid"))) + if got, want := personalRetryLogError(err), "personal source: token refresh: temporary network error"; got != want { + t.Fatalf("personalRetryLogError() = %q, want %q", got, want) + } +} + +func TestCrossPlatformCoveragePortalStageErrorNilAndUnwrap(t *testing.T) { + var nilErr *portalStageError + if got, want := nilErr.Error(), "source: portal stream failed"; got != want { + t.Fatalf("nil stage error = %q, want %q", got, want) + } + if nilErr.Unwrap() != nil { + t.Fatal("nil stage error should unwrap to nil") + } + cause := errors.New("cause") + stageErr := &portalStageError{stage: "stream_read", retryable: true, cause: cause} + if !errors.Is(stageErr, cause) { + t.Fatalf("stage error should unwrap to cause: %v", stageErr) + } +} + func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -139,8 +166,9 @@ func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure } return "", transientRefreshError() }, - SourceID: "open", - ReconnectMin: time.Millisecond, + SourceID: "open", + // Min above max exercises the reconnect clamp. + ReconnectMin: 2 * time.Millisecond, ReconnectMax: time.Millisecond, }}) if err != nil { @@ -155,6 +183,55 @@ func TestCrossPlatformCoveragePortalSourceRetriesTransientTokenResolutionFailure } } +func TestCrossPlatformCoveragePortalSourceResetsBackoffAfterAckedAttempt(t *testing.T) { + var ticketCalls atomic.Int32 + upgrader := websocket.Upgrader{} + var wsURL string + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) { + if ticketCalls.Add(1) > 1 { + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = io.WriteString(w, `{"endpoint":`+strconvQuote(wsURL)+`,"ticket":"t"}`) + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + df := payload.DataFrame{Type: "event", Headers: payload.DataFrameHeader{payload.DataFrameHeaderKMessageId: "m"}, Data: `{}`} + _ = conn.WriteJSON(df) + // Wait for the ACK, then close so the read fails retryably with an + // acked attempt behind it, which resets the reconnect backoff. + _, _, _ = conn.ReadMessage() + }) + srv := httptest.NewServer(mux) + defer srv.Close() + wsURL = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + src, err := New(Config{PortalTicket: &PortalTicketConfig{ + TicketURL: srv.URL + "/ticket", + AccessToken: "t", + SourceID: "open", + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }}) + if err != nil { + t.Fatal(err) + } + var events atomic.Int32 + err = src.Start(context.Background(), func(*dwsevent.RawEvent) { events.Add(1) }) + if err == nil || !strings.Contains(err.Error(), "portal ticket HTTP 400") { + t.Fatalf("Start() error = %v, want fatal ticket HTTP 400 after reconnect", err) + } + if events.Load() != 1 || ticketCalls.Load() != 2 || src.State().ReconnectCount != 1 { + t.Fatalf("events=%d ticket calls=%d reconnects=%d, want 1/2/1", events.Load(), ticketCalls.Load(), src.State().ReconnectCount) + } +} + func TestCrossPlatformCoveragePortalSourceDoesNotRetryTerminalTokenResolutionFailure(t *testing.T) { var calls atomic.Int32 src, err := New(Config{PortalTicket: &PortalTicketConfig{ From 99e5a3cceb406d89d48d8729c17a47c6f4ade8c6 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 20 Jul 2026 22:35:15 +0800 Subject: [PATCH 20/67] test: rename 401-refresh tests into TestCrossPlatformCoverage so platform gates count them The macOS/Windows coverage gates only execute tests matching ^(TestAllShortcuts|TestCrossPlatformCoverage), so the 401 refresh-retry tests added for this change were invisible to them, leaving 12 changed statements uncovered (92.73% < 100%). Rename the 12 existing tests into the TestCrossPlatformCoverage prefix and add a fetchTicketAttempt edge test covering transport failures, retryable statuses, and missing endpoint/ticket payload fields. --- internal/app/event_force_refresh_wire_test.go | 4 +- .../event_personal_force_refresh_wire_test.go | 4 +- .../source/portal_ticket_refresh_test.go | 83 ++++++++++++++----- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/internal/app/event_force_refresh_wire_test.go b/internal/app/event_force_refresh_wire_test.go index e366681b..0750b2e1 100644 --- a/internal/app/event_force_refresh_wire_test.go +++ b/internal/app/event_force_refresh_wire_test.go @@ -8,10 +8,10 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" ) -// TestNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket +// TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken asserts the portal ticket // source receives a ForceRefreshToken callback that forwards the actual // rejected token into the app-level compare-and-refresh chain. -func TestNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) { +func TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken(t *testing.T) { oldNew, oldRefresh := eventNewDingtalkSource, eventForceRefreshRejected t.Cleanup(func() { eventNewDingtalkSource, eventForceRefreshRejected = oldNew, oldRefresh }) diff --git a/internal/app/event_personal_force_refresh_wire_test.go b/internal/app/event_personal_force_refresh_wire_test.go index 2b1ae8df..0e663a91 100644 --- a/internal/app/event_personal_force_refresh_wire_test.go +++ b/internal/app/event_personal_force_refresh_wire_test.go @@ -11,10 +11,10 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" ) -// TestNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the +// TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the // personal stream source receives a ForceRefreshToken callback that forwards // the rejected token into the app-level compare-and-refresh chain. -func TestNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) { +func TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken(t *testing.T) { oldAux := personalResolveAuxiliaryAccessToken oldRefresh := personalForceRefreshRejectedToken t.Cleanup(func() { diff --git a/internal/event/source/portal_ticket_refresh_test.go b/internal/event/source/portal_ticket_refresh_test.go index b12c4b13..319c7504 100644 --- a/internal/event/source/portal_ticket_refresh_test.go +++ b/internal/event/source/portal_ticket_refresh_test.go @@ -17,12 +17,12 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" ) -// TestPortalStart401RefreshRetryEndToEnd drives the full production chain +// TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd drives the full production chain // DingtalkSource.Start → startPortalTicket → requestPortalTicket: the first // ticket request is rejected with 401, ForceRefreshToken rotates the token, // the in-chain retry succeeds with the fresh token and a WebSocket event is // delivered to emit. -func TestPortalStart401RefreshRetryEndToEnd(t *testing.T) { +func TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd(t *testing.T) { var ticketCalls, refreshCalls atomic.Int64 var rejectedSeen atomic.Value @@ -104,10 +104,10 @@ func TestPortalStart401RefreshRetryEndToEnd(t *testing.T) { } } -// TestRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain +// TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly asserts the in-chain // retry sends the token returned by ForceRefreshToken instead of re-invoking // the provider (which could still serve the stale token). -func TestRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) { +func TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) { providerCalls := 0 var attemptTokens []string client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -147,9 +147,9 @@ func TestRequestPortalTicketRetryUsesRotatedTokenDirectly(t *testing.T) { } } -// TestRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing +// TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing // refresh neither retries nor drops the refresh error or the original 401. -func TestRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) { +func TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) { refreshErr := errors.New("refresh_token exchange failed") attempts := 0 client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -176,9 +176,9 @@ func TestRequestPortalTicketRefreshFailureKeepsBothErrors(t *testing.T) { } } -// TestRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward +// TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward // compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401. -func TestRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) { +func TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) { attempts := 0 client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { attempts++ @@ -195,10 +195,10 @@ func TestRequestPortalTicketWithoutRefreshCallback401StaysFatal(t *testing.T) { } } -// TestRequestPortalTicketSecond401IsFatal guards against refresh loops: the +// TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal guards against refresh loops: the // controlled retry happens exactly once even if the rotated token is also // rejected. -func TestRequestPortalTicketSecond401IsFatal(t *testing.T) { +func TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal(t *testing.T) { attempts := 0 refreshCalls := 0 client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -223,9 +223,9 @@ func TestRequestPortalTicketSecond401IsFatal(t *testing.T) { } } -// TestRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated +// TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated // token is rejected instead of being sent to the server. -func TestRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) { +func TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) { attempts := 0 client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { attempts++ @@ -248,9 +248,9 @@ func TestRequestPortalTicketRefreshEmptyTokenIsFatal(t *testing.T) { } } -// TestPersonalFetchTicket401RefreshRetry mirrors the portal behavior for the +// TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry mirrors the portal behavior for the // personal stream ticket path. -func TestPersonalFetchTicket401RefreshRetry(t *testing.T) { +func TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry(t *testing.T) { var attemptTokens []string src, err := NewPersonal(PersonalConfig{ AccessTokenProvider: func(context.Context) (string, error) { return "stale", nil }, @@ -287,9 +287,9 @@ func TestPersonalFetchTicket401RefreshRetry(t *testing.T) { } } -// TestPersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh +// TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh // keeps the 401 fatal (not retryable) and wraps the refresh error. -func TestPersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) { +func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *testing.T) { refreshErr := errors.New("refresh_token exchange failed") src, err := NewPersonal(PersonalConfig{ AccessToken: "stale", @@ -322,11 +322,11 @@ type brokenBody struct{} func (brokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } func (brokenBody) Close() error { return nil } -// TestPersonalFetchTicket401TruncatedBodyStaysFatal guards the single +// TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal guards the single // refresh-retry protection: a 401 whose body fails with unexpected EOF must // be classified by status (fatal) and never wrapped as retryable, otherwise // the outer reconnect loop would refresh again on every iteration. -func TestPersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) { +func TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) { attempts := 0 refreshCalls := 0 src, err := NewPersonal(PersonalConfig{ @@ -361,10 +361,10 @@ func TestPersonalFetchTicket401TruncatedBodyStaysFatal(t *testing.T) { } } -// TestPersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing +// TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing // behavior for success responses: a body read failure on 2xx is a transient // transport problem and remains retryable. -func TestPersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) { +func TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) { src, err := NewPersonal(PersonalConfig{ AccessToken: "token", ClientID: "client", @@ -385,3 +385,46 @@ func TestPersonalFetchTicket200TruncatedBodyStaysRetryable(t *testing.T) { t.Fatalf("2xx body read failure should stay retryable, got %v", err) } } + +// TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges covers the +// remaining fetchTicketAttempt branches: transport failures and retryable +// statuses stay retryable, while a well-formed response missing the endpoint +// or ticket fields stays fatal. +func TestCrossPlatformCoveragePersonalFetchTicketAttemptTransportAndPayloadEdges(t *testing.T) { + newSource := func(rt roundTripFunc) *PersonalSource { + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "source", + TicketURL: "https://ticket.test", + HTTPClient: &http.Client{Transport: rt}, + }) + if err != nil { + t.Fatal(err) + } + return src + } + + dialErr := errors.New("dial tcp: connection refused") + src := newSource(func(*http.Request) (*http.Response, error) { return nil, dialErr }) + _, _, err := src.fetchTicketAttempt(context.Background(), "token") + if !errors.Is(err, dialErr) || !isRetryablePersonalError(err) { + t.Fatalf("transport failure should stay retryable and wrap cause, got %v", err) + } + + src = newSource(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("busy")), Header: make(http.Header)}, nil + }) + _, status, err := src.fetchTicketAttempt(context.Background(), "token") + if status != http.StatusServiceUnavailable || !isRetryablePersonalError(err) { + t.Fatalf("503 should stay retryable, got status %d err %v", status, err) + } + + src = newSource(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"endpoint":"","ticket":""}`)), Header: make(http.Header)}, nil + }) + _, _, err = src.fetchTicketAttempt(context.Background(), "token") + if err == nil || isRetryablePersonalError(err) || !strings.Contains(err.Error(), "missing endpoint or ticket") { + t.Fatalf("missing ticket fields should stay fatal, got %v", err) + } +} From 7fddace8df0e8e8a752c29f373883a93217135d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 20 Jul 2026 17:24:26 +0800 Subject: [PATCH 21/67] ci: enforce complete Go test coverage --- .github/workflows/ai-behavior-check.yml | 1 + .github/workflows/ci.yml | 51 ++-- CONTRIBUTING.md | 2 +- Makefile | 33 ++- internal/event/consume/integration_test.go | 78 ++++-- pkg/asynctask/asynctask.go | 4 +- pkg/asynctask/asynctask_test.go | 4 +- scripts/README.md | 9 +- scripts/ci/test-packages.sh | 121 +++++++++ scripts/dev/ci-local.sh | 2 +- scripts/dev/lint.sh | 11 +- scripts/dev/run-mock-e2e.sh | 8 +- test/cli/docs_compatibility_test.go | 4 - test/cli/root_command_test.go | 4 - test/cli/testmain_test.go | 2 + test/cli/unregistered_modules_command_test.go | 2 - test/scripts/ai_behavior_policy_test.go | 1 + test/scripts/changelog_pr_gate_test.go | 4 + test/scripts/ci_test_package_plan_test.go | 106 ++++++++ test/scripts/run_all_tests.sh | 234 ------------------ 20 files changed, 368 insertions(+), 313 deletions(-) create mode 100755 scripts/ci/test-packages.sh create mode 100644 test/scripts/ci_test_package_plan_test.go delete mode 100755 test/scripts/run_all_tests.sh diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml index 304879f3..3fd9c131 100644 --- a/.github/workflows/ai-behavior-check.yml +++ b/.github/workflows/ai-behavior-check.yml @@ -95,6 +95,7 @@ jobs: typeof filename === 'string' && ( filename.startsWith('.github/workflows/') || + filename.startsWith('scripts/ci/') || filename.startsWith('scripts/policy/') || filename.startsWith('scripts/release/') || filename === 'test/fixtures/cli-interface-baseline.txt' || diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c4e304..633df24a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,11 +130,13 @@ jobs: with: go-version-file: go.mod + - name: Verify Test Package Plan + if: steps.classify.outputs.changelog_only != 'true' + run: make test-plan + - name: Format Check if: steps.classify.outputs.changelog_only != 'true' - run: | - unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)" - test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) + run: make format-check - name: Go Vet if: steps.classify.outputs.changelog_only != 'true' @@ -167,38 +169,20 @@ jobs: with: go-version-file: go.mod - - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Build if: ${{ matrix.shard == 'remaining' }} run: make build - name: Test shard with Race Detection + shell: bash env: + DWS_PACKAGE_VERSION: 0.0.0-test TEST_SHARD: ${{ matrix.shard }} run: | - case "$TEST_SHARD" in - app) - packages=(./internal/app/...) - ;; - generators) - packages=(./internal/generator/...) - ;; - helpers) - packages=(./internal/helpers/...) - ;; - remaining) - mapfile -t packages < <( - go list ./cmd/... ./internal/... | - grep -Ev '/internal/(app|generator|helpers)(/|$)' - ) - ;; - *) - printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2 - exit 1 - ;; - esac + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")" + test -n "$package_output" + mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 go test -v -race -count=1 -timeout=10m "${packages[@]}" @@ -207,7 +191,7 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@v4 @@ -221,7 +205,16 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zip unzip - name: Test release scripts - run: go test -v -count=1 -timeout=5m ./test/scripts + shell: bash + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list release-scripts)" + test -n "$package_output" + mapfile -t packages <<< "$package_output" + test "${#packages[@]}" -gt 0 + go test -v -count=1 -timeout=10m "${packages[@]}" test-cross-platform: name: Test (cross-platform compile) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7eff27..38b9e684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,8 @@ Common repository checks already used here include: ./scripts/policy/check-open-source-assets.sh go test ./... make test +make test-plan make lint -bash test/scripts/run_all_tests.sh --jobs 8 ./scripts/policy/check-generated-drift.sh ./scripts/policy/check-command-surface.sh --strict ./scripts/release/verify-package-managers.sh diff --git a/Makefile b/Makefile index 0fa71080..b5131e7b 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ GO ?= go +DWS_PACKAGE_VERSION ?= 0.0.0-test 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)" +GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.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 +.PHONY: all help build rebuild test test-plan lint format-check 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 @@ -14,8 +16,10 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" - @printf " make lint - Run formatting checks and golangci-lint when available\n" - @printf " make fmt - Format Go source files\n" + @printf " make test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make lint - Run formatting checks, go vet, and staticcheck\n" + @printf " make format-check - Check all repository Go source files with gofmt\n" + @printf " make fmt - Format all repository Go source files\n" @printf " make policy - Check the built dws plus open-source and Schema policies\n" @printf " make interface-integrity - Check historical commands and help contracts still work\n" @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" @@ -45,13 +49,32 @@ rebuild: @./scripts/dev/build.sh test: - @./test/scripts/run_all_tests.sh --timeout 5m + @DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./... + +test-plan: + @./scripts/ci/test-packages.sh verify lint: @./scripts/dev/lint.sh +format-check: + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \ + if [ -n "$$unformatted" ]; then \ + printf '%s\n' "$$unformatted"; \ + printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \ + exit 1; \ + fi + fmt: - @find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" policy: @mkdir -p "$(POLICY_GOTMPDIR)" diff --git a/internal/event/consume/integration_test.go b/internal/event/consume/integration_test.go index 5408a259..50b7d206 100644 --- a/internal/event/consume/integration_test.go +++ b/internal/event/consume/integration_test.go @@ -31,6 +31,31 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) +type consumeReadyWriter struct { + ready chan struct{} + once sync.Once +} + +func newConsumeReadyWriter() *consumeReadyWriter { + return &consumeReadyWriter{ready: make(chan struct{})} +} + +func (w *consumeReadyWriter) Write(p []byte) (int, error) { + if bytes.Contains(p, []byte("[event] ready ")) { + w.once.Do(func() { close(w.ready) }) + } + return len(p), nil +} + +func waitForConsumerReady(t *testing.T, ready <-chan struct{}) { + t.Helper() + select { + case <-ready: + case <-time.After(3 * time.Second): + t.Fatal("consumer did not report ready") + } +} + // TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time // event_types pushdown contract (plan §4 unsung superpower): a consumer // subscribing to "im.*" must NOT receive "approval.*" events even when @@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) { defer func() { cancel(); <-runDone }() var imBuf, approvalBuf bytes.Buffer - var wg sync.WaitGroup - wg.Add(2) + imReady := newConsumeReadyWriter() + approvalReady := newConsumeReadyWriter() + consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumers() + consumeDone := make(chan error, 2) // Consumer A: im.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &imBuf, - Stderr: io.Discard, + Stderr: imReady, EventTypes: []string{"im.*"}, MaxEvents: 3, // 3 im events expected }) }() // Consumer B: approval.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &approvalBuf, - Stderr: io.Discard, + Stderr: approvalReady, EventTypes: []string{"approval.*"}, MaxEvents: 2, // 2 approval events expected }) }() - // Give both consumers time to Hello + register. - time.Sleep(200 * time.Millisecond) + waitForConsumerReady(t, imReady.ready) + waitForConsumerReady(t, approvalReady.ready) close(trigger) - wg.Wait() + for range 2 { + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumers did not finish: %v", consumeCtx.Err()) + } + } // Verify consumer A got exactly the 3 im.* events. imLines := nonEmptyLines(imBuf.String()) @@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) { defer func() { cancel(); <-runDone }() var buf bytes.Buffer - consumeDone := make(chan struct{}) + ready := newConsumeReadyWriter() + consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumer() + consumeDone := make(chan error, 1) go func() { - defer close(consumeDone) - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &buf, - Stderr: io.Discard, + Stderr: ready, EventTypes: []string{"im.*"}, Filter: `\.at_v1$`, // only at_v1 events MaxEvents: 1, }) }() - time.Sleep(150 * time.Millisecond) + waitForConsumerReady(t, ready.ready) close(trigger) - <-consumeDone + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumer did not finish: %v", consumeCtx.Err()) + } lines := nonEmptyLines(buf.String()) if len(lines) != 1 { diff --git a/pkg/asynctask/asynctask.go b/pkg/asynctask/asynctask.go index 653f2411..1b7e0cc6 100644 --- a/pkg/asynctask/asynctask.go +++ b/pkg/asynctask/asynctask.go @@ -20,8 +20,8 @@ // 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义) // 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id ` 续等 // 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl -// ↓ -// FAILED → 返回 error +// ↓ +// FAILED → 返回 error // 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘 package asynctask diff --git a/pkg/asynctask/asynctask_test.go b/pkg/asynctask/asynctask_test.go index 4e6e0e10..dd2faff3 100644 --- a/pkg/asynctask/asynctask_test.go +++ b/pkg/asynctask/asynctask_test.go @@ -112,7 +112,9 @@ func TestSubmit_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() submitFn := func(ctx context.Context) (string, error) { return "job-x", nil } - queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { return QueryResult{Status: StatusProcessing}, nil } + queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { + return QueryResult{Status: StatusProcessing}, nil + } _, err := Submit(ctx, submitFn, queryFn, Options{Backoff: testBackoff, Timeout: 1 * time.Second}) if !errors.Is(err, context.Canceled) { t.Fatalf("expect ctx.Canceled, got %v", err) diff --git a/scripts/README.md b/scripts/README.md index 179e022b..e0823cbe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -3,9 +3,11 @@ These repo-local entrypoints are the supported shell entrypoints for building, testing, packaging, and policy checks. - `make build`: build the `dws` CLI from `cmd` -- `make test`: run `go test ./...` -- `make lint`: run formatting checks and required `golangci-lint` -- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/` +- `make test`: run every default package returned by `go list ./...` +- `make test-plan`: verify every default Go package belongs to exactly one CI test shard +- `make lint`: run the repository-wide format check, `go vet`, and required `staticcheck` +- `make format-check`: check every tracked or non-ignored untracked repository Go source file with `gofmt` +- `make fmt`: format every tracked or non-ignored untracked repository Go source file - `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first) - `make package`: build all release artifacts locally - `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag) @@ -15,6 +17,7 @@ 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` +- CI package plan: `./scripts/ci/test-packages.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-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 diff --git a/scripts/ci/test-packages.sh b/scripts/ci/test-packages.sh new file mode 100755 index 00000000..041497d1 --- /dev/null +++ b/scripts/ci/test-packages.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +MODULE="$(cd "$ROOT" && go list -m)" + +usage() { + printf '%s\n' \ + "usage: $0 list " \ + " $0 verify" >&2 + exit 2 +} + +list_shard() { + shard="$1" + cd "$ROOT" + + case "$shard" in + app) + go list ./internal/app/... + ;; + generators) + go list ./internal/generator/... + ;; + helpers) + go list ./internal/helpers/... + ;; + release-scripts) + go list ./test/scripts/... + ;; + remaining) + all_packages="$(go list ./...)" + printf '%s\n' "$all_packages" | while IFS= read -r package; do + case "$package" in + "$MODULE/internal/app"|"$MODULE/internal/app/"*) ;; + "$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;; + "$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;; + "$MODULE/test/scripts"|"$MODULE/test/scripts/"*) ;; + *) printf '%s\n' "$package" ;; + esac + done + ;; + *) + printf 'unknown test package shard: %s\n' "$shard" >&2 + exit 2 + ;; + esac +} + +verify_plan() { + workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")" + trap 'rm -rf "$workdir"' EXIT HUP INT TERM + + expected="$workdir/expected" + assigned="$workdir/assigned" + unique="$workdir/unique" + duplicates="$workdir/duplicates" + missing="$workdir/missing" + unexpected="$workdir/unexpected" + all_packages="$workdir/all-packages" + + cd "$ROOT" + go list ./... > "$all_packages" + LC_ALL=C sort -u "$all_packages" > "$expected" + : > "$assigned" + + for shard in app generators helpers remaining release-scripts; do + shard_packages="$workdir/$shard" + unsorted_shard_packages="$workdir/$shard.unsorted" + list_shard "$shard" > "$unsorted_shard_packages" + LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages" + if [ ! -s "$shard_packages" ]; then + printf 'test package shard is empty: %s\n' "$shard" >&2 + exit 1 + fi + cat "$shard_packages" >> "$assigned" + done + + LC_ALL=C sort "$assigned" -o "$assigned" + uniq -d "$assigned" > "$duplicates" + LC_ALL=C sort -u "$assigned" > "$unique" + comm -23 "$expected" "$unique" > "$missing" + comm -13 "$expected" "$unique" > "$unexpected" + + failed=0 + if [ -s "$duplicates" ]; then + printf '%s\n' 'test packages assigned to more than one shard:' >&2 + sed 's/^/ /' "$duplicates" >&2 + failed=1 + fi + if [ -s "$missing" ]; then + printf '%s\n' 'default Go packages missing from the CI test plan:' >&2 + sed 's/^/ /' "$missing" >&2 + failed=1 + fi + if [ -s "$unexpected" ]; then + printf '%s\n' 'CI test plan contains packages outside go list ./...:' >&2 + sed 's/^/ /' "$unexpected" >&2 + failed=1 + fi + if [ "$failed" -ne 0 ]; then + exit 1 + fi + + package_count="$(wc -l < "$expected" | tr -d ' ')" + printf 'test package plan covers %s default packages exactly once\n' "$package_count" +} + +case "${1:-}" in + list) + [ "$#" -eq 2 ] || usage + list_shard "$2" + ;; + verify) + [ "$#" -eq 1 ] || usage + verify_plan + ;; + *) + usage + ;; +esac diff --git a/scripts/dev/ci-local.sh b/scripts/dev/ci-local.sh index 58282c41..e9da065b 100755 --- a/scripts/dev/ci-local.sh +++ b/scripts/dev/ci-local.sh @@ -11,7 +11,7 @@ run() { cd "$ROOT" run ./scripts/dev/lint.sh -run go test ./... +run make test COVERAGE_PROFILE="$(mktemp "${TMPDIR:-/tmp}/dws-coverage.XXXXXX")" run ./scripts/dev/coverage.sh "$COVERAGE_PROFILE" rm -f "$COVERAGE_PROFILE" diff --git a/scripts/dev/lint.sh b/scripts/dev/lint.sh index 22a191e6..ca6e3a43 100755 --- a/scripts/dev/lint.sh +++ b/scripts/dev/lint.sh @@ -1,13 +1,12 @@ #!/bin/sh set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" + # ── Format check ────────────────────────────────────────── -unformatted="$(find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -l)" -if [ -n "$unformatted" ]; then - echo "$unformatted" - echo "Go files are not formatted. Run 'make fmt'." >&2 - exit 1 -fi +echo "Running gofmt check..." +make format-check # ── go vet (built-in) ──────────────────────────────────── echo "Running go vet..." diff --git a/scripts/dev/run-mock-e2e.sh b/scripts/dev/run-mock-e2e.sh index 4ac42faf..18c83f6a 100755 --- a/scripts/dev/run-mock-e2e.sh +++ b/scripts/dev/run-mock-e2e.sh @@ -4,7 +4,7 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" cd "$ROOT" -go test ./test/mock_mcp/... \ - ./test/integration/discovery/... \ - ./test/integration/extensions/... \ - ./test/integration/recovery/... +DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" \ + go test -count=1 -timeout=5m \ + ./test/mock_mcp/... \ + ./test/integration/... diff --git a/test/cli/docs_compatibility_test.go b/test/cli/docs_compatibility_test.go index c6fda9ad..cf037014 100644 --- a/test/cli/docs_compatibility_test.go +++ b/test/cli/docs_compatibility_test.go @@ -20,8 +20,6 @@ type docsFlagCase struct { } func TestDWSDocsCommandTreeCoverage(t *testing.T) { - t.Parallel() - docPaths, _, err := parseDWSDocsCompatibility() if err != nil { t.Fatalf("parseDWSDocsCompatibility() error = %v", err) @@ -45,8 +43,6 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) { } func TestDWSDocsLocalFlagsCoverage(t *testing.T) { - t.Parallel() - docPaths, docFlags, err := parseDWSDocsCompatibility() if err != nil { t.Fatalf("parseDWSDocsCompatibility() error = %v", err) diff --git a/test/cli/root_command_test.go b/test/cli/root_command_test.go index 44bc4616..fc2db003 100644 --- a/test/cli/root_command_test.go +++ b/test/cli/root_command_test.go @@ -14,8 +14,6 @@ import ( // and their availability depends on the test environment's fixture data. func TestHiddenMCPHelpIsReachable(t *testing.T) { - t.Parallel() - cmd := app.NewRootCommand() var out strings.Builder cmd.SetOut(&out) @@ -33,8 +31,6 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) { } func TestSkillCommandIsRegistered(t *testing.T) { - t.Parallel() - root := app.NewRootCommand() var found bool diff --git a/test/cli/testmain_test.go b/test/cli/testmain_test.go index d5264d50..73b09d78 100644 --- a/test/cli/testmain_test.go +++ b/test/cli/testmain_test.go @@ -11,6 +11,8 @@ import ( func TestMain(m *testing.M) { // Set an empty catalog fixture so that EnvironmentLoader does not // attempt live discovery (which would hang on unreachable MCP endpoints). + // Tests that construct app root commands must remain serial because root + // construction initializes process-wide helper dependencies. absFixture, _ := filepath.Abs("testdata/empty_catalog.json") os.Setenv(cli.CatalogFixtureEnv, absFixture) diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index c9502cad..1f89476c 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -8,8 +8,6 @@ import ( ) func TestRootHelpHidesRecoveredModules(t *testing.T) { - t.Parallel() - cmd := app.NewRootCommand() var out strings.Builder cmd.SetOut(&out) diff --git a/test/scripts/ai_behavior_policy_test.go b/test/scripts/ai_behavior_policy_test.go index 0030b3fd..57875c59 100644 --- a/test/scripts/ai_behavior_policy_test.go +++ b/test/scripts/ai_behavior_policy_test.go @@ -24,6 +24,7 @@ func TestAIBehaviorPolicyProtectsEnforcementInputs(t *testing.T) { workflow := string(data) for _, want := range []string{ + "filename.startsWith('scripts/ci/')", "filename.startsWith('scripts/policy/')", "filename === 'test/fixtures/cli-interface-baseline.txt'", "previous_filename", diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 611819f3..df21137b 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -421,6 +421,10 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { `COVERAGE_ENFORCE_OVERALL: "false"`, `COVERAGE_OVERALL_TOLERANCE: "0"`, `COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`, + "run: make test-plan", + "run: make format-check", + "./scripts/ci/test-packages.sh list", + `package_output="$(./scripts/ci/test-packages.sh list`, } { if !strings.Contains(admission, want) { t.Errorf("Code Admission workflow missing contract %q", want) diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go new file mode 100644 index 00000000..6d6fda11 --- /dev/null +++ b/test/scripts/ci_test_package_plan_test.go @@ -0,0 +1,106 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) { + root := testPackagePlanRoot(t) + output := runTestPackagePlan(t, root, "verify") + if !strings.Contains(output, "default packages exactly once") { + t.Fatalf("verify output = %q, want coverage summary", output) + } +} + +func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { + root := testPackagePlanRoot(t) + remaining := strings.Fields(runTestPackagePlan(t, root, "list", "remaining")) + releaseScripts := strings.Fields(runTestPackagePlan(t, root, "list", "release-scripts")) + + for _, suffix := range []string{ + "/test/cli", + "/test/contract", + "/test/integration/extensions", + "/test/mock_mcp", + "/test/smoke", + "/test/unit", + } { + if !containsPackageSuffix(remaining, suffix) { + t.Errorf("remaining shard does not contain package ending in %q", suffix) + } + } + if containsPackageSuffix(remaining, "/test/scripts") { + t.Error("remaining shard unexpectedly contains /test/scripts") + } + if !containsPackageSuffix(releaseScripts, "/test/scripts") { + t.Error("release-scripts shard does not contain /test/scripts") + } +} + +func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { + root := testPackagePlanRoot(t) + fakeBin := t.TempDir() + fakeGo := filepath.Join(fakeBin, "go") + err := os.WriteFile(fakeGo, []byte(`#!/bin/sh +if [ "$1" = "list" ] && [ "$2" = "-m" ]; then + printf '%s\n' 'github.com/DingTalk-Real-AI/dingtalk-workspace-cli' + exit 0 +fi +printf '%s\n' 'injected go list failure' >&2 +exit 42 +`), 0o755) + if err != nil { + t.Fatalf("write fake go: %v", err) + } + + script := filepath.Join(root, "scripts", "ci", "test-packages.sh") + for _, args := range [][]string{{"list", "remaining"}, {"verify"}} { + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Dir = root + cmd.Env = []string{ + "PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "TMPDIR=" + t.TempDir(), + } + output, runErr := cmd.CombinedOutput() + if runErr == nil { + t.Fatalf("%s unexpectedly succeeded with failing go list:\n%s", strings.Join(args, " "), output) + } + if !strings.Contains(string(output), "injected go list failure") { + t.Fatalf("%s failure output = %q, want injected failure", strings.Join(args, " "), output) + } + } +} + +func testPackagePlanRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + return root +} + +func runTestPackagePlan(t *testing.T, root string, args ...string) string { + t.Helper() + script := filepath.Join(root, "scripts", "ci", "test-packages.sh") + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", script, strings.Join(args, " "), err, output) + } + return string(output) +} + +func containsPackageSuffix(packages []string, suffix string) bool { + for _, packagePath := range packages { + if strings.HasSuffix(packagePath, suffix) { + return true + } + } + return false +} diff --git a/test/scripts/run_all_tests.sh b/test/scripts/run_all_tests.sh deleted file mode 100755 index 23743b10..00000000 --- a/test/scripts/run_all_tests.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash -# run_all_tests.sh — Run all test suites in parallel and print a summary. -# Usage: bash test/scripts/run_all_tests.sh [--verbose] [--timeout ] [--jobs ] -# -# Options: -# --verbose Print full test output for every suite (default: only on failure) -# --timeout Per-suite go test timeout (default: 120s) -# --jobs Max parallel suites (default: number of CPU cores) - -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -VERBOSE=false -TIMEOUT="120s" -MAX_JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" - -# ── Parse flags ────────────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --verbose) VERBOSE=true; shift ;; - --timeout) TIMEOUT="$2"; shift 2 ;; - --jobs) MAX_JOBS="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; - esac -done - -# ── Colour helpers ──────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# ── Test suite definitions ──────────────────────────────────────────────────── -# Each entry: "label|package_path" -declare -a SUITES=( - "cli|./test/cli/..." - "contract|./test/contract/..." - "integration/extensions|./test/integration/extensions/..." - "integration/recovery|./test/integration/recovery/..." - "mock_mcp|./test/mock_mcp/..." - "scripts|./test/scripts/..." -) - -# ── Temporary workspace ─────────────────────────────────────────────────────── -LOG_DIR="$(mktemp -d)" -trap 'rm -rf "$LOG_DIR"' EXIT - -# ── Worker: run one suite, write result files ───────────────────────────────── -# Result files written to $LOG_DIR//{status,output} -run_suite_worker() { - local label="$1" - local pkg="$2" - local safe_label="${label//\//_}" - local suite_dir="$LOG_DIR/$safe_label" - mkdir -p "$suite_dir" - - # Check whether the package directory actually exists - local pkg_dir="${pkg//\.\//}" - pkg_dir="${pkg_dir%/...}" - if [[ ! -d "$REPO_ROOT/$pkg_dir" ]]; then - echo "skip" > "$suite_dir/status" - echo "(directory not found)" > "$suite_dir/output" - return - fi - - local exit_code=0 - ( - cd "$REPO_ROOT" - go test -timeout "$TIMEOUT" -count=1 "$pkg" 2>&1 - ) > "$suite_dir/output" 2>&1 || exit_code=$? - - if [[ $exit_code -eq 0 ]]; then - echo "pass" > "$suite_dir/status" - elif grep -q "matched no packages\|no packages to test\|\[no test files\]" "$suite_dir/output" 2>/dev/null; then - echo "skip" > "$suite_dir/status" - echo "(no test files found)" > "$suite_dir/output" - else - echo "fail" > "$suite_dir/status" - fi -} - -export -f run_suite_worker -export REPO_ROOT TIMEOUT LOG_DIR - -# ── Main ────────────────────────────────────────────────────────────────────── -echo "" -printf "${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n" -printf "${BOLD}║ dws — Full Test Suite Runner ║${RESET}\n" -printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n" -echo "" -printf "Repo root : %s\n" "$REPO_ROOT" -printf "Timeout : %s per suite\n" "$TIMEOUT" -printf "Parallel : %s jobs\n" "$MAX_JOBS" -printf "Verbose : %s\n" "$VERBOSE" -echo "" -printf "${BOLD}Launching %d suites in parallel…${RESET}\n" "${#SUITES[@]}" -echo "" - -START_TIME=$(date +%s) - -# ── Dispatch all suites concurrently with a job-slot semaphore ──────────────── -declare -a PIDS=() -declare -a PID_LABELS=() -active_jobs=0 - -for suite in "${SUITES[@]}"; do - label="${suite%%|*}" - pkg="${suite##*|}" - - printf " ${CYAN}START${RESET} %s\n" "$label" - - run_suite_worker "$label" "$pkg" & - PIDS+=($!) - PID_LABELS+=("$label") - active_jobs=$((active_jobs + 1)) - - # Throttle: wait for one slot to free up when at capacity - if [[ $active_jobs -ge $MAX_JOBS ]]; then - wait "${PIDS[$((${#PIDS[@]} - MAX_JOBS))]}" 2>/dev/null || true - active_jobs=$((active_jobs - 1)) - fi -done - -# Wait for all remaining background jobs -for pid in "${PIDS[@]}"; do - wait "$pid" 2>/dev/null || true -done - -END_TIME=$(date +%s) -ELAPSED=$((END_TIME - START_TIME)) - -# ── Collect results in original suite order ─────────────────────────────────── -TOTAL=0 -PASSED=0 -FAILED=0 -SKIPPED=0 - -declare -a FAILED_SUITES=() -declare -a PASSED_SUITES=() -declare -a SKIPPED_SUITES=() - -echo "" -printf "${BOLD}Results:${RESET}\n" -echo "" - -for suite in "${SUITES[@]}"; do - label="${suite%%|*}" - safe_label="${label//\//_}" - suite_dir="$LOG_DIR/$safe_label" - status_file="$suite_dir/status" - output_file="$suite_dir/output" - - TOTAL=$((TOTAL + 1)) - - if [[ ! -f "$status_file" ]]; then - # Worker never wrote a status — treat as failure - printf " ${RED}FAIL${RESET} %s (worker did not complete)\n" "$label" - FAILED=$((FAILED + 1)) - FAILED_SUITES+=("$label") - continue - fi - - status="$(cat "$status_file")" - - case "$status" in - pass) - PASSED=$((PASSED + 1)) - PASSED_SUITES+=("$label") - printf " ${GREEN}PASS${RESET} %s\n" "$label" - if $VERBOSE; then - sed 's/^/ /' "$output_file" - fi - ;; - skip) - SKIPPED=$((SKIPPED + 1)) - SKIPPED_SUITES+=("$label") - printf " ${YELLOW}SKIP${RESET} %s %s\n" "$label" "$(cat "$output_file")" - ;; - fail) - FAILED=$((FAILED + 1)) - FAILED_SUITES+=("$label") - printf " ${RED}FAIL${RESET} %s\n" "$label" - echo " ── output ──────────────────────────────────────────────────────" - sed 's/^/ /' "$output_file" - echo " ────────────────────────────────────────────────────────────────" - ;; - esac -done - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" -printf "${BOLD} Summary (elapsed: %ds, parallel: %s jobs)${RESET}\n" "$ELAPSED" "$MAX_JOBS" -printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" -printf " Total : %d\n" "$TOTAL" -printf " ${GREEN}Passed : %d${RESET}\n" "$PASSED" -printf " ${RED}Failed : %d${RESET}\n" "$FAILED" -printf " ${YELLOW}Skipped : %d${RESET}\n" "$SKIPPED" -echo "" - -if [[ ${#FAILED_SUITES[@]} -gt 0 ]]; then - printf "${RED}${BOLD}Failed suites:${RESET}\n" - for suite in "${FAILED_SUITES[@]}"; do - printf " ${RED}✗${RESET} %s\n" "$suite" - done - echo "" -fi - -if [[ ${#SKIPPED_SUITES[@]} -gt 0 ]]; then - printf "${YELLOW}Skipped suites:${RESET}\n" - for suite in "${SKIPPED_SUITES[@]}"; do - printf " ${YELLOW}–${RESET} %s\n" "$suite" - done - echo "" -fi - -if [[ ${#PASSED_SUITES[@]} -gt 0 ]]; then - printf "${GREEN}Passed suites:${RESET}\n" - for suite in "${PASSED_SUITES[@]}"; do - printf " ${GREEN}✓${RESET} %s\n" "$suite" - done - echo "" -fi - -# ── Exit code ───────────────────────────────────────────────────────────────── -if [[ $FAILED -gt 0 ]]; then - printf "${RED}${BOLD}Result: FAILED${RESET}\n\n" - exit 1 -else - printf "${GREEN}${BOLD}Result: ALL PASSED${RESET}\n\n" - exit 0 -fi From 76d301268dd74f2138f16ddf2e2591d2493a3815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Tue, 21 Jul 2026 10:03:25 +0800 Subject: [PATCH 22/67] ci: add cloud release and withdrawal workflows --- .github/workflows/release.yml | 750 +++++++++-- .github/workflows/withdraw-release.yml | 148 ++ docs/releasing.md | 78 +- scripts/release/next-release-version.sh | 203 +++ scripts/release/recover-release.sh | 53 +- scripts/release/release-contract.sh | 64 +- scripts/release/release.sh | 8 +- scripts/release/verify-delivered-stable.sh | 2 +- .../verify-release-workflow-delivery.sh | 357 ++++- scripts/release/withdraw-release.sh | 1186 +++++++++++++++++ test/scripts/package_script_test.go | 119 +- test/scripts/release_script_test.go | 401 ++++++ test/scripts/withdraw_release_test.go | 746 +++++++++++ 13 files changed, 3945 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/withdraw-release.yml create mode 100755 scripts/release/next-release-version.sh create mode 100755 scripts/release/withdraw-release.sh create mode 100644 test/scripts/withdraw_release_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0398057..c45d4675 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ name: Release -run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }} +run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }} on: push: @@ -7,6 +7,36 @@ on: - "v*" workflow_dispatch: inputs: + release_operation: + description: "Plan the next version or publish it from the current default-branch commit" + required: true + default: none + type: choice + options: + - none + - plan + - publish + release_channel: + description: "Release channel (used only when release_operation is plan/publish)" + required: true + default: beta + type: choice + options: + - beta + - stable + release_bump: + description: "New beta core bump; ignored while continuing an open beta line" + required: true + default: patch + type: choice + options: + - patch + - minor + - major + release_confirmation: + description: "For publish, type PUBLISH beta or PUBLISH stable" + required: false + type: string repair_npm_version: description: "Recover npm for a public immutable release without moving latest/beta" required: false @@ -89,13 +119,21 @@ jobs: RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} RECOVER_NONCE: ${{ inputs.recover_release_nonce }} RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }} + RELEASE_OPERATION: ${{ inputs.release_operation }} + RELEASE_CHANNEL: ${{ inputs.release_channel }} + RELEASE_BUMP: ${{ inputs.release_bump }} + RELEASE_CONFIRMATION: ${{ inputs.release_confirmation }} run: | set -eu + release_flow=0 npm_repair=0 gitee_repair=0 oss_repair=0 governance=0 recovery=0 + if test -n "$RELEASE_OPERATION" && test "$RELEASE_OPERATION" != none; then + release_flow=1 + fi test -z "$REPAIR_NPM_VERSION" || npm_repair=1 test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1 test -z "$REPAIR_OSS_VERSION" || oss_repair=1 @@ -105,11 +143,36 @@ jobs: test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \ test -n "$RECOVER_NONCE" || \ test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi - test $((npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { + test $((release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || { echo "workflow_dispatch must select exactly one release mode" >&2 exit 1 } - if test "$npm_repair" -eq 1; then + if test "$release_flow" -eq 1; then + case "$RELEASE_CHANNEL" in beta|stable) ;; *) + echo "release_channel must be beta or stable" >&2 + exit 1 + esac + case "$RELEASE_BUMP" in patch|minor|major) ;; *) + echo "release_bump must be patch, minor, or major" >&2 + exit 1 + esac + if test "$RELEASE_OPERATION" = plan; then + test -z "$RELEASE_CONFIRMATION" || { + echo "release_confirmation must be empty for a plan" >&2 + exit 1 + } + echo "mode=plan_release" >> "$GITHUB_OUTPUT" + elif test "$RELEASE_OPERATION" = publish; then + test "$RELEASE_CONFIRMATION" = "PUBLISH $RELEASE_CHANNEL" || { + echo "release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL" >&2 + exit 1 + } + echo "mode=create_release" >> "$GITHUB_OUTPUT" + else + echo "release_operation must be none, plan, or publish" >&2 + exit 1 + fi + elif test "$npm_repair" -eq 1; then echo "mode=repair_npm" >> "$GITHUB_OUTPUT" elif test "$gitee_repair" -eq 1; then echo "mode=repair_gitee" >> "$GITHUB_OUTPUT" @@ -197,7 +260,7 @@ jobs: governance-preflight: name: Release governance preflight needs: dispatch-contract - if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }} + if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' || needs.dispatch-contract.outputs.mode == 'create_release' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -208,7 +271,8 @@ jobs: env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }} REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }} run: | @@ -225,10 +289,24 @@ jobs: echo "governance preflight cannot be combined with npm repair" >&2 exit 1 } - if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then - echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 - exit 1 - fi + case "$DISPATCH_MODE" in + governance_preflight) + if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then + echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2 + exit 1 + fi + ;; + create_release) + test -z "$PREFLIGHT_NONCE" || { + echo "cloud release cannot be combined with a governance nonce" >&2 + exit 1 + } + ;; + *) + echo "unsupported governance preflight mode: $DISPATCH_MODE" >&2 + exit 1 + ;; + esac case "$PREFLIGHT_COMMIT" in *[!0-9a-f]*) echo "governance_preflight_commit must be a lowercase commit SHA" >&2 @@ -247,7 +325,7 @@ jobs: - name: Require successful Code Admission contexts on the preflight commit uses: actions/github-script@v7 env: - PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }} + PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} with: script: | const { owner, repo } = context.repo; @@ -299,7 +377,7 @@ jobs: - name: Check out trusted preflight tooling uses: actions/checkout@v4 with: - ref: ${{ inputs.governance_preflight_commit }} + ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }} persist-credentials: false fetch-depth: 0 @@ -348,8 +426,8 @@ jobs: ./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary release-contract: - needs: [dispatch-contract, authorize-recovery] - if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }} + needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release] + if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -377,16 +455,28 @@ jobs: RECOVER_COMMIT: ${{ inputs.recover_release_commit }} RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }} RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }} + DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }} + SEALED_VERSION: ${{ needs.seal-release.outputs.release_version }} + SEALED_COMMIT: ${{ needs.seal-release.outputs.release_commit }} + SEALED_TAG_OBJECT: ${{ needs.seal-release.outputs.release_tag_object }} with: script: | const { owner, repo } = context.repo; - const isRecovery = context.eventName === "workflow_dispatch"; + const dispatchMode = process.env.DISPATCH_MODE; + const isRecovery = + context.eventName === "workflow_dispatch" && dispatchMode === "recover_release"; + const isCreate = + context.eventName === "workflow_dispatch" && dispatchMode === "create_release"; let version = context.ref.replace("refs/tags/", ""); let commit = context.sha; let expectedTagObject = ""; let failedRunId = ""; let failedRunAttempt = ""; - if (isRecovery) { + if (context.eventName === "workflow_dispatch" && !isRecovery && !isCreate) { + core.setFailed(`unsupported publication dispatch mode: ${dispatchMode}`); + return; + } + if (isRecovery || isCreate) { if ( context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" || context.ref !== `refs/heads/${context.payload.repository.default_branch}` @@ -394,24 +484,28 @@ jobs: core.setFailed("release recovery must be dispatched from the official default branch"); return; } - version = process.env.RECOVER_VERSION; - commit = process.env.RECOVER_COMMIT; - expectedTagObject = process.env.RECOVER_TAG_OBJECT; - failedRunId = process.env.RECOVER_FAILED_RUN_ID; - failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + version = isCreate ? process.env.SEALED_VERSION : process.env.RECOVER_VERSION; + commit = isCreate ? process.env.SEALED_COMMIT : process.env.RECOVER_COMMIT; + expectedTagObject = isCreate + ? process.env.SEALED_TAG_OBJECT + : process.env.RECOVER_TAG_OBJECT; + if (isRecovery) { + failedRunId = process.env.RECOVER_FAILED_RUN_ID; + failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT; + } if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) { - core.setFailed(`invalid recovery release version: ${version}`); + core.setFailed(`invalid dispatched release version: ${version}`); return; } if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) { - core.setFailed("recovery tag object and commit must be full lowercase SHA-1 values"); + core.setFailed("dispatched tag object and commit must be full lowercase SHA-1 values"); return; } - if (!/^[1-9]\d*$/.test(failedRunId)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunId)) { core.setFailed("recover_failed_run_id must be a positive workflow run ID"); return; } - if (!/^[1-9]\d*$/.test(failedRunAttempt)) { + if (isRecovery && !/^[1-9]\d*$/.test(failedRunAttempt)) { core.setFailed("recover_failed_run_attempt must be a positive run attempt"); return; } @@ -432,12 +526,67 @@ jobs: core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`); return; } - if (isRecovery && tagObject !== expectedTagObject) { + if ((isRecovery || isCreate) && tagObject !== expectedTagObject) { core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`); return; } + const tagFields = new Map(); + for (const line of tag.data.message.split(/\r?\n/)) { + const separator = line.indexOf(": "); + if (separator < 1) continue; + const key = line.slice(0, separator); + if (tagFields.has(key)) { + core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`); + return; + } + tagFields.set(key, line.slice(separator + 2)); + } + const cloudKeys = [ + "Channel", + "Release-Run", + "Release-Run-Attempt", + "Requested-By", + "Requested-By-ID", + "Sealed-Commit", + "Workflow-Commit", + "Allocation-Fingerprint", + ]; + const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key)); + const isCloudSeal = cloudKeys.every((key) => tagFields.has(key)); + if (hasAnyCloudMetadata && !isCloudSeal) { + core.setFailed(`${version} contains incomplete cloud seal metadata`); + return; + } + if (isCloudSeal) { + const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable"; + const actorId = String(context.payload.sender?.id || ""); + if ( + tagFields.get("Channel") !== expectedChannel || + tagFields.get("Sealed-Commit") !== commit || + tagFields.get("Workflow-Commit") !== commit || + !/^[0-9a-f]{64}$/.test(tagFields.get("Allocation-Fingerprint")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run")) || + !/^[1-9]\d*$/.test(tagFields.get("Release-Run-Attempt")) || + !/^[1-9]\d*$/.test(tagFields.get("Requested-By-ID")) + ) { + core.setFailed(`${version} cloud seal metadata does not match its tag authority`); + return; + } + if (isCreate && ( + tagFields.get("Release-Run") !== String(context.runId) || + tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT || + tagFields.get("Requested-By") !== context.actor || + tagFields.get("Requested-By-ID") !== actorId + )) { + core.setFailed(`${version} cloud seal is not bound to this release request`); + return; + } + } else if (isCreate) { + core.setFailed(`${version} is missing cloud seal metadata`); + return; + } - if (isRecovery) { + if (isRecovery || isCreate) { const defaultBranch = context.payload.repository.default_branch; const branch = await github.rest.repos.getBranch({ owner, repo, branch: defaultBranch }); const comparison = await github.rest.repos.compareCommitsWithBasehead({ @@ -449,6 +598,8 @@ jobs: core.setFailed(`${version} commit is not contained in ${defaultBranch}`); return; } + } + if (isRecovery) { const failedRun = await github.request( "GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}", { @@ -459,19 +610,34 @@ jobs: }, ); const run = failedRun.data; + const failedByCloud = isCloudSeal; + const expectedBranch = failedByCloud + ? context.payload.repository.default_branch + : version; + const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"; if ( run.id !== Number(failedRunId) || run.run_attempt !== Number(failedRunAttempt) || run.repository.full_name !== `${owner}/${repo}` || run.path !== ".github/workflows/release.yml" || - run.event !== "push" || + run.event !== expectedEvent || run.status !== "completed" || !["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) || - run.head_branch !== version || + run.head_branch !== expectedBranch || run.head_sha !== commit ) { core.setFailed( - `run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`, + `run ${failedRunId} attempt ${failedRunAttempt} is not the failed sealed Release attempt for ${version}`, + ); + return; + } + if (failedByCloud && ( + tagFields.get("Release-Run") !== failedRunId || + tagFields.get("Release-Run-Attempt") !== failedRunAttempt || + tagFields.get("Requested-By-ID") !== String(run.actor?.id || "") + )) { + core.setFailed( + `run ${failedRunId} attempt ${failedRunAttempt} is not bound by the cloud seal for ${version}`, ); return; } @@ -512,7 +678,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Validate release contract id: contract @@ -757,7 +924,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing release source env: @@ -952,7 +1120,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing publication source env: @@ -1337,7 +1506,8 @@ jobs: '+refs/heads/main:refs/remotes/origin/main' git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' - name: Verify sealed tag before executing channel source env: @@ -1568,7 +1738,8 @@ jobs: run: | git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \ "$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -1628,6 +1799,17 @@ jobs: script: | const { owner, repo } = context.repo; const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } const ref = await github.rest.git.getRef({ owner, repo, @@ -1694,100 +1876,18 @@ jobs: core.setFailed(`${version} does not contain the exact release asset set`); return; } - const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: version, - event: "push", - status: "completed", - per_page: 100, - }); - let passed = false; - for (const run of runs) { - if (run.head_sha !== commitSha || run.head_branch !== version) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === commitSha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - if (!passed) { - const recoveryTitle = `Release recovery ${version} at ${commitSha}`; - const recoveries = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: "release.yml", - branch: defaultBranch, - event: "workflow_dispatch", - status: "completed", - per_page: 100, - }); - for (const run of recoveries) { - const nonce = run.display_title.startsWith(`${recoveryTitle} `) - ? run.display_title.slice(recoveryTitle.length + 1) - : ""; - if ( - !new RegExp(`^${commitSha}-[0-9]+-[0-9]+$`).test(nonce) || - run.head_branch !== defaultBranch || - run.path !== ".github/workflows/release.yml" || - run.repository.full_name !== `${owner}/${repo}` - ) { - continue; - } - const workflowComparison = await github.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `${run.head_sha}...${branch.data.commit.sha}`, - }); - if (!["ahead", "identical"].includes(workflowComparison.data.status)) continue; - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner, - repo, - run_id: run.id, - filter: "all", - per_page: 100, - }); - if ( - jobs.some( - (job) => - job.name === "Publish immutable GitHub Release" && - job.head_sha === run.head_sha && - job.steps?.some( - (step) => - step.name === "Require immutable published GitHub Release" && - step.conclusion === "success", - ), - ) - ) { - passed = true; - break; - } - } - } - if (!passed) { - core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`); - return; - } core.setOutput("commit_sha", commitSha); + - name: Require successful Release workflow delivery + working-directory: tooling + env: + VERSION: ${{ inputs.repair_npm_version }} + RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./scripts/release/verify-release-workflow-delivery.sh --npm-repair \ + "$VERSION" "$RELEASE_COMMIT" + - name: Check out target release source uses: actions/checkout@v4 with: @@ -1888,6 +1988,8 @@ jobs: - mirror-gitee-release - repair-npm - repair-channel + - release-plan + - seal-release runs-on: ubuntu-latest timeout-minutes: 2 permissions: {} @@ -1908,6 +2010,8 @@ jobs: MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} + RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }} + SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }} run: | set -eu require_result() { @@ -1931,6 +2035,10 @@ jobs: require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped fi } + require_cloud_jobs_skipped() { + require_result release-plan "$RELEASE_PLAN_RESULT" skipped + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + } case "$EVENT_NAME:$DISPATCH_MODE" in push:) require_result dispatch-contract "$DISPATCH_RESULT" skipped @@ -1938,14 +2046,41 @@ jobs: require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; + workflow_dispatch:create_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" success + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_publication + ;; + workflow_dispatch:plan_release) + require_result dispatch-contract "$DISPATCH_RESULT" success + require_result release-plan "$RELEASE_PLAN_RESULT" success + require_result seal-release "$SEAL_RELEASE_RESULT" skipped + require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped + require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped + require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped + require_result release "$RELEASE_RESULT" skipped + require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped + require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped + require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped + require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + ;; workflow_dispatch:recover_release) require_result dispatch-contract "$DISPATCH_RESULT" success require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped require_publication ;; workflow_dispatch:governance_preflight) @@ -1960,6 +2095,7 @@ jobs: require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_npm) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1973,6 +2109,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_gitee) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1986,6 +2123,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_cloud_jobs_skipped ;; workflow_dispatch:repair_oss) require_result dispatch-contract "$DISPATCH_RESULT" success @@ -1999,6 +2137,7 @@ jobs: require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped + require_cloud_jobs_skipped ;; *) echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2 @@ -2050,6 +2189,17 @@ jobs: script: | const { owner, repo } = context.repo; const version = process.env.VERSION; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`${version} was withdrawn and cannot be repaired`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } const ref = await github.rest.git.getRef({ owner, repo, @@ -2142,7 +2292,8 @@ jobs: set -eu git fetch --force --no-tags \ https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ - '+refs/tags/v*:refs/tags/v*' + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' ./scripts/release/verify-github-tag-authority.sh \ "$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT" @@ -2207,3 +2358,348 @@ jobs: OSS_BUCKET: ${{ secrets.OSS_BUCKET }} OSS_PREFIX: ${{ secrets.OSS_PREFIX }} DWS_REQUIRE_OSS: "1" + + release-plan: + name: Plan next cloud release + needs: [dispatch-contract, governance-preflight] + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'plan_release' || (needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success')) }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + outputs: + release_version: ${{ steps.allocate.outputs.release_version }} + release_commit: ${{ steps.allocate.outputs.release_commit }} + channel: ${{ steps.allocate.outputs.channel }} + from_beta: ${{ steps.allocate.outputs.from_beta }} + base: ${{ steps.allocate.outputs.base }} + refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }} + steps: + - name: Validate official default-branch release request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli + run: | + set -eu + test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || { + echo "cloud releases are restricted to $EXPECTED_REPOSITORY" >&2 + exit 1 + } + test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" || { + echo "cloud releases must be dispatched from $DEFAULT_BRANCH" >&2 + exit 1 + } + printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || { + echo "cloud release commit must be a full lowercase SHA" >&2 + exit 1 + } + + - name: Check out exact default-branch commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Fetch current main and all allocated versions + run: | + set -eu + git fetch --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + git fetch --force --no-tags \ + https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" || { + echo "checked-out release commit differs from the dispatched commit" >&2 + exit 1 + } + test "$(git rev-parse refs/remotes/origin/main)" = "$GITHUB_SHA" || { + echo "main moved after this release was dispatched; run it again from current main" >&2 + exit 1 + } + + - name: Allocate the next version + id: allocate + env: + REQUESTED_CHANNEL: ${{ inputs.release_channel }} + REQUESTED_BUMP: ${{ inputs.release_bump }} + run: | + set -eu + allocation="$RUNNER_TEMP/next-release-version" + ./scripts/release/next-release-version.sh \ + --channel "$REQUESTED_CHANNEL" \ + --bump "$REQUESTED_BUMP" > "$allocation" + for key in release_version from_beta channel base; do + count="$(grep -c "^$key=" "$allocation")" + test "$count" -eq 1 || { + echo "allocator returned $count values for $key" >&2 + exit 1 + } + value="$(sed -n "s/^$key=//p" "$allocation")" + printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT" + done + refs_fingerprint="$( + git for-each-ref --format='%(refname)=%(objectname)' \ + refs/tags/v refs/tags/withdrawn/v \ + | LC_ALL=C sort \ + | sha256sum \ + | awk '{print $1}' + )" + echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT" + + release_version="$(sed -n 's/^release_version=//p' "$allocation")" + from_beta="$(sed -n 's/^from_beta=//p' "$allocation")" + channel="$(sed -n 's/^channel=//p' "$allocation")" + { + echo "## Cloud release plan" + echo + echo "- Version: \`$release_version\`" + echo "- Channel: \`$channel\`" + echo "- Commit: \`$GITHUB_SHA\`" + if test -n "$from_beta"; then + echo "- Promotes beta: \`$from_beta\`" + fi + if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then + echo + echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Validate the candidate release contract before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }} + RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + run: | + set -eu + git config user.name "dws release cloud seal" + git config user.email "dws-release@users.noreply.github.com" + { + printf 'Release %s\n\n' "$RELEASE_VERSION" + printf 'Channel: %s\n' "$RELEASE_CHANNEL" + test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA" + } > "$RUNNER_TEMP/candidate-tag-message" + git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message" + set -- \ + --channel "$RELEASE_CHANNEL" \ + --version "$RELEASE_VERSION" \ + --context ci \ + --remote origin \ + --branch main \ + --notes-output "$RUNNER_TEMP/release-notes.md" \ + --metadata-output "$RUNNER_TEMP/release-contract-output" + if test -n "$FROM_BETA"; then + set -- "$@" --from-beta "$FROM_BETA" + fi + ./scripts/release/release-contract.sh "$@" + + - name: Require delivered previous stable baseline before sealing + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + previous_stable="$(sed -n 's/^previous_stable=//p' "$RUNNER_TEMP/release-contract-output")" + previous_commit="$(sed -n 's/^previous_stable_commit=//p' "$RUNNER_TEMP/release-contract-output")" + if test -n "$previous_stable"; then + ./scripts/release/verify-delivered-stable.sh "$previous_stable" "$previous_commit" + fi + + - name: Require delivered beta before sealing stable + if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && steps.allocate.outputs.channel == 'stable' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FROM_BETA: ${{ steps.allocate.outputs.from_beta }} + run: | + set -eu + from_beta_commit="$(git rev-parse "$FROM_BETA^{commit}")" + release_state="$( + gh api -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/releases/tags/$FROM_BETA" \ + --jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv' + )" + test "$release_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" || { + echo "$FROM_BETA is not a published immutable prerelease" >&2 + exit 1 + } + ./scripts/release/verify-github-release-assets.sh "$FROM_BETA" + ./scripts/release/verify-release-workflow-delivery.sh \ + "$FROM_BETA" "$from_beta_commit" + + seal-release: + name: Seal cloud release tag + needs: [dispatch-contract, governance-preflight, release-plan] + if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + outputs: + release_version: ${{ steps.seal.outputs.release_version }} + release_commit: ${{ steps.seal.outputs.release_commit }} + release_tag_object: ${{ steps.seal.outputs.release_tag_object }} + steps: + - name: Create one immutable annotated release tag + id: seal + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ needs.release-plan.outputs.release_version }} + RELEASE_COMMIT: ${{ needs.release-plan.outputs.release_commit }} + RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }} + FROM_BETA: ${{ needs.release-plan.outputs.from_beta }} + REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }} + with: + script: | + const crypto = require("crypto"); + const { owner, repo } = context.repo; + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + const version = process.env.RELEASE_VERSION; + const commit = process.env.RELEASE_COMMIT; + const channel = process.env.RELEASE_CHANNEL; + const fromBeta = process.env.FROM_BETA; + const expectedFingerprint = process.env.REFS_FINGERPRINT; + + if ( + context.payload.repository.full_name !== expectedRepository || + context.ref !== `refs/heads/${defaultBranch}` || + context.sha !== commit + ) { + core.setFailed("cloud release seal is not bound to the official dispatched default-branch commit"); + return; + } + if ( + !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) || + !/^[0-9a-f]{40}$/.test(commit) || + !/^[0-9a-f]{64}$/.test(expectedFingerprint) + ) { + core.setFailed("cloud release plan contains an invalid sealed identity"); + return; + } + if ( + (channel === "stable" && !fromBeta) || + (channel === "prerelease" && fromBeta) + ) { + core.setFailed("cloud release channel and beta promotion baseline disagree"); + return; + } + + const branch = await github.rest.repos.getBranch({ + owner, + repo, + branch: defaultBranch, + }); + if (branch.data.commit.sha !== commit) { + core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`); + return; + } + + const refs = []; + for (const prefix of ["tags/v", "tags/withdrawn/v"]) { + const matching = await github.paginate(github.rest.git.listMatchingRefs, { + owner, + repo, + ref: prefix, + per_page: 100, + }); + for (const ref of matching) { + refs.push(`${ref.ref}=${ref.object.sha}`); + } + } + refs.sort(); + const actualFingerprint = crypto + .createHash("sha256") + .update(refs.length > 0 ? `${refs.join("\n")}\n` : "") + .digest("hex"); + if (actualFingerprint !== expectedFingerprint) { + core.setFailed("allocated release refs changed after planning; run the release again"); + return; + } + + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }); + core.setFailed(`release tag already exists: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/withdrawn/${version}`, + }); + core.setFailed(`release version was withdrawn and cannot be reused: ${version}`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + const message = [ + `Release ${version}`, + "", + `Channel: ${channel}`, + ...(fromBeta ? [`From-Beta: ${fromBeta}`] : []), + `Release-Run: ${context.runId}`, + `Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`, + `Requested-By: ${context.actor}`, + `Requested-By-ID: ${context.payload.sender?.id || ""}`, + `Sealed-Commit: ${commit}`, + `Workflow-Commit: ${context.sha}`, + `Allocation-Fingerprint: ${expectedFingerprint}`, + ].join("\n"); + const tag = await github.rest.git.createTag({ + owner, + repo, + tag: version, + message, + object: commit, + type: "commit", + }); + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${version}`, + sha: tag.data.sha, + }); + + const sealedRef = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${version}`, + }); + if ( + sealedRef.data.object.type !== "tag" || + sealedRef.data.object.sha !== tag.data.sha + ) { + core.setFailed(`${version} did not seal to the new annotated tag object`); + return; + } + const sealedTag = await github.rest.git.getTag({ + owner, + repo, + tag_sha: tag.data.sha, + }); + if ( + sealedTag.data.tag !== version || + sealedTag.data.object.type !== "commit" || + sealedTag.data.object.sha !== commit || + sealedTag.data.message !== message + ) { + core.setFailed(`${version} annotated tag authority differs after creation`); + return; + } + + core.setOutput("release_version", version); + core.setOutput("release_commit", commit); + core.setOutput("release_tag_object", tag.data.sha); + await core.summary + .addHeading("Cloud release sealed") + .addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`) + .write(); diff --git a/.github/workflows/withdraw-release.yml b/.github/workflows/withdraw-release.yml new file mode 100644 index 00000000..b84bafa9 --- /dev/null +++ b/.github/workflows/withdraw-release.yml @@ -0,0 +1,148 @@ +name: Withdraw release +run-name: Withdraw ${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)" + required: true + type: string + reason: + description: "Public, single-line withdrawal reason (8-300 characters)" + required: true + type: string + confirmation: + description: "Type WITHDRAW followed by a space and the exact version" + required: true + type: string + +permissions: + contents: read + +# Share the publication lock with release.yml. A withdrawal and a publication +# must never mutate channel pointers concurrently. +concurrency: + group: dws-release-publication + cancel-in-progress: false + +jobs: + withdraw: + name: Withdraw release from every distribution channel + environment: release-withdrawal + runs-on: ubuntu-latest + timeout-minutes: 180 + permissions: + actions: read + contents: write + steps: + - name: Verify withdrawal environment protection + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const response = await github.request( + "GET /repos/{owner}/{repo}/environments/{environment_name}", + { owner, repo, environment_name: "release-withdrawal" }, + ); + const reviewerRule = response.data.protection_rules.find( + (rule) => rule.type === "required_reviewers", + ); + if ( + !reviewerRule || + reviewerRule.prevent_self_review !== true || + !Array.isArray(reviewerRule.reviewers) || + reviewerRule.reviewers.length === 0 + ) { + core.setFailed("release-withdrawal must require a reviewer and prevent self-review"); + return; + } + if (response.data.deployment_branch_policy?.protected_branches !== true) { + core.setFailed("release-withdrawal must allow only protected branches"); + } + if (response.data.can_admins_bypass !== false) { + core.setFailed("release-withdrawal must not allow administrator bypass"); + } + + - name: Require the exact current official default-branch commit + uses: actions/github-script@v7 + with: + script: | + const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"; + const defaultBranch = context.payload.repository.default_branch; + if (context.eventName !== "workflow_dispatch") { + core.setFailed("release withdrawal accepts workflow_dispatch only"); + return; + } + if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) { + core.setFailed(`release withdrawal is restricted to ${expectedRepository}`); + return; + } + if (context.ref !== `refs/heads/${defaultBranch}`) { + core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`); + return; + } + const branch = await github.rest.git.getRef({ + ...context.repo, + ref: `heads/${defaultBranch}`, + }); + if (branch.data.object.sha !== context.sha) { + core.setFailed( + `default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`, + ); + } + + - name: Check out trusted withdrawal tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js for npm channel withdrawal + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Withdraw immutable release and roll back channels + id: withdrawal + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WITHDRAW_VERSION: ${{ inputs.version }} + WITHDRAW_REASON: ${{ inputs.reason }} + WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }} + OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_PREFIX: ${{ secrets.OSS_PREFIX }} + GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + GITEE_USER: ${{ secrets.GITEE_USER }} + GITEE_REPO: ${{ secrets.GITEE_REPO }} + DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }} + HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }} + run: | + ./scripts/release/withdraw-release.sh \ + "$WITHDRAW_VERSION" \ + "$WITHDRAW_REASON" \ + "$WITHDRAW_CONFIRMATION" + + - name: Report withdrawal boundary + if: ${{ always() }} + env: + VERSION: ${{ inputs.version }} + RESULT: ${{ steps.withdrawal.outcome }} + run: | + { + echo "### Release withdrawal: ${VERSION}" + echo + echo "- Workflow result: ${RESULT}" + echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier." + echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause." + echo "- The problem GitHub Release and original tag are removed after npm/OSS/Gitee rollback so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed." + echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded." + echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/releasing.md b/docs/releasing.md index f3076519..7b2e8a00 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,10 +1,52 @@ # 发布手册(预发 / 正式) -发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。 +发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。 -发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。 +发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。 -## 日常只用一个入口 +## 推荐入口:GitHub 云端发布 + +任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布: + +1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。 +2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。 +3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。 +4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。 + +`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm/OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。 + +## 自动版本规则 + +- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。 +- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。 +- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。 +- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。 +- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。 + +## 全平台撤回与回滚 + +已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写: + +- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。 +- `reason`:8–300 字符的单行公开原因。 +- `confirmation`:精确输入 `WITHDRAW `,例如 `WITHDRAW v1.0.53`。 + +该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行: + +1. 先创建永久 annotated tag `withdrawn/`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。 +2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。 +3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;OSS 先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。 +4. npm、OSS 和 Gitee 均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。 + +GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。 + +撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。 + +撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。 + +撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。 + +## 兼容入口:本地发布 安装发布 Skill 后直接运行: @@ -29,7 +71,7 @@ main 上的候选代码 + beta CHANGELOG → vX.Y.Z(正式发布) ``` -正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 +云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。 ## 预发发布 @@ -68,7 +110,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish ``` -`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 +本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。 ## CHANGELOG 契约 @@ -86,14 +128,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish ## CI/CD 保证 -- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。 +- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。 - tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。 - 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。 - GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。 - 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。 - stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。 -- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。 -- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。 +- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。 +- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。 npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。 @@ -110,25 +152,27 @@ workflow 和本地直发脚本已停用,避免绕开 publication queue 或用 ## 既有 tag 的紧急恢复 -tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: +云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行: ```bash dws-release recover v1.2.3-beta.1 ``` -命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: +命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run ` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足: -- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。 -- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。 +- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。 +- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。 - `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。 - 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。 - 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。 成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。 -OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。 +云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。 -Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。 +OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。发布和撤回仍把 OSS 作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本。 + +Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。 ## 平台治理前置 @@ -136,8 +180,14 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac - `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。 - 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。 +- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。 - 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。 +- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。 +- 配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。 +- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。 - 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。 - 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。 +- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。 +- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。 immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。 diff --git a/scripts/release/next-release-version.sh b/scripts/release/next-release-version.sh new file mode 100755 index 00000000..e0d4a6d7 --- /dev/null +++ b/scripts/release/next-release-version.sh @@ -0,0 +1,203 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +. "$SCRIPT_DIR/release-lib.sh" + +ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" +CHANNEL="" +REQUESTED_CHANNEL="" +BUMP="patch" + +usage() { + cat >&2 <<'EOF' +usage: next-release-version.sh --channel [options] + +Options: + --bump Core bump when starting a new beta line (default: patch) + --repo-root Override repository root (primarily for tests) + +Output: + release_version= + from_beta= + channel= + base= + +Both ordinary release tags and refs/tags/withdrawn/v... tombstones reserve a +version permanently. The caller must create the returned release tag +atomically; this script only calculates the next candidate. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --channel) + [ "$#" -ge 2 ] || { usage; exit 2; } + REQUESTED_CHANNEL="$2" + shift 2 + ;; + --bump) + [ "$#" -ge 2 ] || { usage; exit 2; } + BUMP="$2" + shift 2 + ;; + --repo-root) + [ "$#" -ge 2 ] || { usage; exit 2; } + ROOT="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +case "$REQUESTED_CHANNEL" in + beta|prerelease) CHANNEL="prerelease" ;; + stable) CHANNEL="stable" ;; + *) + printf 'invalid release channel: %s (expected beta, prerelease, or stable)\n' "${REQUESTED_CHANNEL:-}" >&2 + exit 2 + ;; +esac +case "$BUMP" in + patch|minor|major) ;; + *) + printf 'invalid version bump: %s (expected patch, minor, or major)\n' "$BUMP" >&2 + exit 2 + ;; +esac + +cd "$ROOT" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'not a Git worktree: %s\n' "$ROOT" >&2 + exit 1 +} + +allocated_version_for_ref() { + _avfr_ref="$1" + case "$_avfr_ref" in + refs/tags/withdrawn/v*) printf '%s\n' "${_avfr_ref#refs/tags/withdrawn/}" ;; + refs/tags/v*) printf '%s\n' "${_avfr_ref#refs/tags/}" ;; + *) return 1 ;; + esac +} + +stable_core_is_allocated() { + _scia_core="$1" + git show-ref --verify --quiet "refs/tags/$_scia_core" || + git show-ref --verify --quiet "refs/tags/withdrawn/$_scia_core" +} + +version_is_withdrawn() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +bump_core() { + _bc_version="${1#v}" + _bc_major="${_bc_version%%.*}" + _bc_remainder="${_bc_version#*.}" + _bc_minor="${_bc_remainder%%.*}" + _bc_patch="${_bc_remainder#*.}" + case "$2" in + patch) _bc_patch=$((_bc_patch + 1)) ;; + minor) + _bc_minor=$((_bc_minor + 1)) + _bc_patch=0 + ;; + major) + _bc_major=$((_bc_major + 1)) + _bc_minor=0 + _bc_patch=0 + ;; + esac + printf 'v%s.%s.%s\n' "$_bc_major" "$_bc_minor" "$_bc_patch" +} + +tag_refs="$(git for-each-ref --format='%(refname)' refs/tags)" +latest_stable="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + if release_is_stable_version "$version"; then + if [ -z "$latest_stable" ] || release_core_is_greater "$version" "$latest_stable"; then + latest_stable="$version" + fi + fi +done + +highest_open_beta_core="" +for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + core="$(release_core_tag "$version")" + stable_core_is_allocated "$core" && continue + if [ -n "$latest_stable" ] && ! release_core_is_greater "$core" "$latest_stable"; then + continue + fi + if [ -z "$highest_open_beta_core" ] || release_core_is_greater "$core" "$highest_open_beta_core"; then + highest_open_beta_core="$core" + fi +done + +release_version="" +from_beta="" +base="$latest_stable" + +if [ "$CHANNEL" = "prerelease" ]; then + if [ -n "$highest_open_beta_core" ]; then + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + next_beta_number=$(( $(release_beta_number "$latest_beta") + 1 )) + release_version="$highest_open_beta_core-beta.$next_beta_number" + else + [ -n "$latest_stable" ] || { + printf 'cannot start a beta line without an allocated stable baseline\n' >&2 + exit 1 + } + next_core="$(bump_core "$latest_stable" "$BUMP")" + release_version="$next_core-beta.1" + fi +else + [ -n "$highest_open_beta_core" ] || { + printf 'cannot create a stable release without an open beta line newer than the latest allocated stable\n' >&2 + exit 1 + } + latest_beta="" + for ref in $tag_refs; do + version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)" + [ -n "$version" ] || continue + release_is_prerelease_version "$version" || continue + [ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue + if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then + latest_beta="$version" + fi + done + if version_is_withdrawn "$latest_beta" || + ! git show-ref --verify --quiet "refs/tags/$latest_beta"; then + printf 'latest beta %s is withdrawn; create the next beta before stable promotion\n' "$latest_beta" >&2 + exit 1 + fi + release_version="$highest_open_beta_core" + from_beta="$latest_beta" +fi + +printf 'release_version=%s\n' "$release_version" +printf 'from_beta=%s\n' "$from_beta" +printf 'channel=%s\n' "$CHANNEL" +printf 'base=%s\n' "$base" diff --git a/scripts/release/recover-release.sh b/scripts/release/recover-release.sh index 0e6c4a8a..5e4df00e 100755 --- a/scripts/release/recover-release.sh +++ b/scripts/release/recover-release.sh @@ -120,12 +120,41 @@ git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref" } tag_object="$(git rev-parse "$recovery_ref")" commit="$(git rev-parse "$recovery_ref^{commit}")" +tag_message="$(git for-each-ref "$recovery_ref" --format='%(contents)')" +cloud_run_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run" { print $2 }')" +cloud_run_attempt="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run-Attempt" { print $2 }')" +cloud_actor="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By" { print $2 }')" +cloud_actor_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By-ID" { print $2 }')" +cloud_sealed_commit="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Sealed-Commit" { print $2 }')" +cloud_marker_count=0 +for cloud_value in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor" "$cloud_actor_id" "$cloud_sealed_commit"; do + [ -z "$cloud_value" ] || cloud_marker_count=$((cloud_marker_count + 1)) +done +if [ "$cloud_marker_count" -ne 0 ] && [ "$cloud_marker_count" -ne 5 ]; then + printf '%s contains incomplete cloud release metadata\n' "$VERSION" >&2 + exit 1 +fi +if [ "$cloud_marker_count" -eq 5 ]; then + for cloud_number in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor_id"; do + printf '%s\n' "$cloud_number" | grep -Eq '^[1-9][0-9]*$' || { + printf '%s contains invalid cloud release identity\n' "$VERSION" >&2 + exit 1 + } + done + [ "$cloud_sealed_commit" = "$commit" ] || { + printf '%s cloud release metadata is not bound to %s\n' "$VERSION" "$commit" >&2 + exit 1 + } +fi git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || { printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2 exit 1 } -if [ -z "$FAILED_RUN_ID" ]; then +if [ -z "$FAILED_RUN_ID" ] && [ "$cloud_marker_count" -eq 5 ]; then + FAILED_RUN_ID="$cloud_run_id" + FAILED_RUN_ATTEMPT="$cloud_run_attempt" +elif [ -z "$FAILED_RUN_ID" ]; then candidate_runs="$( gh api \ -H 'Accept: application/vnd.github+json' \ @@ -160,7 +189,7 @@ attempt_record="$( gh api \ -H 'Accept: application/vnd.github+json' \ "repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \ - --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv' + --jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv' )" || { printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2 exit 1 @@ -174,6 +203,7 @@ attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)" attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)" attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)" attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)" +attempt_actor_id="$(printf '%s\n' "$attempt_record" | cut -f11)" case "$attempt_conclusion" in failure|cancelled|timed_out|startup_failure|stale) ;; *) @@ -182,18 +212,33 @@ case "$attempt_conclusion" in exit 1 ;; esac +if [ "$cloud_marker_count" -eq 5 ]; then + expected_attempt_event="workflow_dispatch" + expected_attempt_branch="main" +else + expected_attempt_event="push" + expected_attempt_branch="$VERSION" +fi if [ "$attempt_id" != "$FAILED_RUN_ID" ] || [ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] || [ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] || [ "$attempt_path" != ".github/workflows/release.yml" ] || - [ "$attempt_event" != "push" ] || + [ "$attempt_event" != "$expected_attempt_event" ] || [ "$attempt_status" != "completed" ] || - [ "$attempt_branch" != "$VERSION" ] || + [ "$attempt_branch" != "$expected_attempt_branch" ] || [ "$attempt_commit" != "$commit" ]; then printf 'Release run %s attempt %s does not match %s at %s\n' \ "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" "$commit" >&2 exit 1 fi +if [ "$cloud_marker_count" -eq 5 ] && + { [ "$cloud_run_id" != "$FAILED_RUN_ID" ] || + [ "$cloud_run_attempt" != "$FAILED_RUN_ATTEMPT" ] || + [ "$cloud_actor_id" != "$attempt_actor_id" ]; }; then + printf 'Release run %s attempt %s is not bound by the cloud seal for %s\n' \ + "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" >&2 + exit 1 +fi printf 'Recovery target:\n' printf ' version: %s\n' "$VERSION" diff --git a/scripts/release/release-contract.sh b/scripts/release/release-contract.sh index 71bbb6b1..3e4aadc7 100755 --- a/scripts/release/release-contract.sh +++ b/scripts/release/release-contract.sh @@ -91,7 +91,13 @@ if [ "$CONTEXT" = "local" ]; then printf 'release tag already exists locally: %s\n' "$VERSION" >&2 exit 1 fi - remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || { + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi + remote_tag="$(git ls-remote --tags "$REMOTE" \ + "refs/tags/$VERSION" "refs/tags/$VERSION^{}" \ + "refs/tags/withdrawn/$VERSION" "refs/tags/withdrawn/$VERSION^{}")" || { printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2 exit 1 } @@ -100,6 +106,10 @@ if [ "$CONTEXT" = "local" ]; then exit 1 } else + if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then + printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || { printf 'CI release tag is not available: %s\n' "$VERSION" >&2 exit 1 @@ -120,32 +130,58 @@ fi previous_stable="" previous_stable_commit="" -for tag in $(git tag --list 'v*' --sort=-version:refname); do +latest_allocated_stable="" +for tag_ref in $(git for-each-ref --format='%(refname)' refs/tags); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_stable_version "$tag"; then - previous_stable="$tag" - previous_stable_commit="$(git rev-parse "$tag^{commit}")" - break + release_is_stable_version "$tag" || continue + if [ -z "$latest_allocated_stable" ] || + release_core_is_greater "$tag" "$latest_allocated_stable"; then + latest_allocated_stable="$tag" fi done -if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then - printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2 +if [ -n "$latest_allocated_stable" ] && + ! release_core_is_greater "$VERSION" "$latest_allocated_stable"; then + printf 'release version %s must be greater than latest allocated stable %s\n' \ + "$VERSION" "$latest_allocated_stable" >&2 exit 1 fi +for tag in $(git tag --list 'v*' --sort=-version:refname); do + [ "$tag" = "$VERSION" ] && continue + release_is_stable_version "$tag" || continue + if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then + continue + fi + previous_stable="$tag" + previous_stable_commit="$(git rev-parse "$tag^{commit}")" + break +done + core_tag="$(release_core_tag "$VERSION")" if [ "$CHANNEL" = "prerelease" ]; then [ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; } - if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then + if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null || + git rev-parse --verify --quiet "refs/tags/withdrawn/$core_tag" >/dev/null; then printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2 exit 1 fi previous_beta="" - for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do + for tag_ref in $(git for-each-ref --format='%(refname)' \ + "refs/tags/$core_tag-beta.*" "refs/tags/withdrawn/$core_tag-beta.*"); do + case "$tag_ref" in + refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;; + refs/tags/*) tag="${tag_ref#refs/tags/}" ;; + *) continue ;; + esac [ "$tag" = "$VERSION" ] && continue - if release_is_prerelease_version "$tag"; then + release_is_prerelease_version "$tag" || continue + if [ -z "$previous_beta" ] || release_version_is_greater "$tag" "$previous_beta"; then previous_beta="$tag" - break fi done beta_number="$(release_beta_number "$VERSION")" @@ -178,6 +214,10 @@ else printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2 exit 1 } + if git rev-parse --verify --quiet "refs/tags/withdrawn/$FROM_BETA" >/dev/null; then + printf 'stable beta baseline was withdrawn: %s\n' "$FROM_BETA" >&2 + exit 1 + fi git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || { printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2 exit 1 diff --git a/scripts/release/release.sh b/scripts/release/release.sh index db88045a..fa0f87c9 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -288,8 +288,12 @@ build_policy_binary() { } fetch_release_tags() { - git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*' - git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*' + git fetch --force "$REMOTE" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' + git fetch --force --no-tags "$OFFICIAL_TAGS_URL" \ + '+refs/tags/v*:refs/tags/v*' \ + '+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*' } printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH" diff --git a/scripts/release/verify-delivered-stable.sh b/scripts/release/verify-delivered-stable.sh index 2e83fa2b..28b2dd69 100755 --- a/scripts/release/verify-delivered-stable.sh +++ b/scripts/release/verify-delivered-stable.sh @@ -82,7 +82,7 @@ if [ "$delivered_by_push" -ne 1 ]; then if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \ DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \ "$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then - printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \ + printf 'Delivered stable baseline verified through trusted default-branch delivery: %s -> %s\n' \ "$TAG" "$EXPECTED_COMMIT" exit 0 fi diff --git a/scripts/release/verify-release-workflow-delivery.sh b/scripts/release/verify-release-workflow-delivery.sh index 40f19ca0..0a264000 100755 --- a/scripts/release/verify-release-workflow-delivery.sh +++ b/scripts/release/verify-release-workflow-delivery.sh @@ -18,6 +18,9 @@ if [ "${1:-}" = "--channel-repair" ]; then exit 2 ;; esac +elif [ "${1:-}" = "--npm-repair" ]; then + MODE="npm-repair" + shift fi TAG="${1:-}" @@ -25,7 +28,7 @@ EXPECTED_COMMIT="${2:-}" REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" [ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || { - printf 'usage: verify-release-workflow-delivery.sh [--channel-repair ] \n' >&2 + printf 'usage: verify-release-workflow-delivery.sh [--channel-repair | --npm-repair] \n' >&2 exit 2 } if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then @@ -81,6 +84,209 @@ for run in runs: done } +find_cloud_delivery_identity() { + tag_ref="$( + github_get "repos/$REPOSITORY/git/ref/tags/$TAG" \ + | python3 -c 'import json,sys +ref=json.load(sys.stdin) +obj=ref.get("object", {}) +if obj.get("type") == "tag" and obj.get("sha"): + print(obj["sha"])' + )" || return 1 + [ -n "$tag_ref" ] || return 1 + github_get "repos/$REPOSITORY/git/tags/$tag_ref" \ + | python3 -c 'import json,re,sys +tag,commit=sys.argv[1:] +payload=json.load(sys.stdin) +if payload.get("tag") != tag or payload.get("object", {}).get("type") != "commit": + raise SystemExit(1) +if payload.get("object", {}).get("sha") != commit: + raise SystemExit(1) +fields={} +for line in payload.get("message", "").splitlines(): + if ": " not in line: + continue + key,value=line.split(": ", 1) + if key in fields: + raise SystemExit(1) + fields[key]=value +required={ + "Channel", "Release-Run", "Release-Run-Attempt", "Requested-By", + "Requested-By-ID", "Sealed-Commit", "Workflow-Commit", + "Allocation-Fingerprint", +} +if not required.issubset(fields): + raise SystemExit(1) +if fields["Sealed-Commit"] != commit or fields["Workflow-Commit"] != commit: + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run-Attempt"]): + raise SystemExit(1) +if not re.fullmatch(r"[1-9][0-9]*", fields["Requested-By-ID"]): + raise SystemExit(1) +if not fields["Requested-By"] or not re.fullmatch(r"[0-9a-f]{64}", fields["Allocation-Fingerprint"]): + raise SystemExit(1) +is_beta="-beta." in tag +if fields["Channel"] != ("prerelease" if is_beta else "stable"): + raise SystemExit(1) +from_beta=fields.get("From-Beta", "") +if is_beta: + if from_beta: + raise SystemExit(1) +else: + core=re.escape(tag) + if not re.fullmatch(core + r"-beta\.[1-9][0-9]*", from_beta): + raise SystemExit(1) +print("\t".join([ + fields["Release-Run"], + fields["Release-Run-Attempt"], + fields["Requested-By"], + fields["Requested-By-ID"], + fields["Workflow-Commit"], +]))' "$TAG" "$EXPECTED_COMMIT" +} + +verify_cloud_delivery() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ -n "$cloud_run_id" ] && [ -n "$cloud_run_attempt" ] && + [ -n "$cloud_actor" ] && [ -n "$cloud_actor_id" ] && + [ -n "$cloud_workflow_sha" ] || return 1 + + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] || + return 1 + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] || return 1 + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] || + return 1 + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-cloud-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )" || { + rm -rf "$jobs_dir" + return 1 + } + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$cloud_workflow_sha" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +workflow_sha, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +required = ( + "Plan next cloud release", + "Seal cloud release tag", + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", + "Publish immutable GitHub Release", + "Publish npm and mirrors", + "Release delivery gate", +) +for name in required: + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + raise SystemExit(1) + job = matches[0] + if ( + job.get("head_sha") != workflow_sha + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + raise SystemExit(1) + +seal = next(job for job in jobs if job.get("name") == "Seal cloud release tag") +steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + raise SystemExit(1) +PY + rm -rf "$jobs_dir" + return "$result" +} + +verify_failed_cloud_delivery_identity() { + cloud_identity="$1" + cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)" + cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)" + cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)" + cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)" + cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)" + [ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1 + cloud_run_state="$( + github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \ + | python3 -c 'import json,sys +r=json.load(sys.stdin) +print("\t".join(str(value) for value in ( + r.get("id", ""), + r.get("run_attempt", ""), + r.get("repository", {}).get("full_name", ""), + r.get("path", ""), + r.get("event", ""), + r.get("status", ""), + r.get("conclusion", ""), + r.get("head_branch", ""), + r.get("head_sha", ""), + r.get("actor", {}).get("login", ""), + r.get("actor", {}).get("id", ""), +)))' + )" || return 1 + expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tfailure\tmain\t%s' \ + "$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")" + [ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] && + [ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] && + [ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] +} + find_failed_push_delivery() { matches="" page=1 @@ -275,6 +481,112 @@ PY return "$result" } +verify_npm_repair_delivery() { + run_id="$1" + run_attempt="$2" + require_cloud_seal="$3" + jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-npm-repair-jobs.XXXXXX")" + page=1 + while :; do + jobs_page="$jobs_dir/jobs-$page.json" + if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \ + >"$jobs_page"; then + rm -rf "$jobs_dir" + return 1 + fi + if ! page_count="$( + python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \ + "$jobs_page" + )"; then + rm -rf "$jobs_dir" + return 1 + fi + [ "$page_count" -eq 100 ] || break + page=$((page + 1)) + done + + result=0 + python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$require_cloud_seal" "$jobs_dir"/jobs-*.json <<'PY' || result=$? +import json +import sys + +commit, run_id, run_attempt, tag, require_cloud_seal, *pages = sys.argv[1:] +jobs = [] +for page in pages: + with open(page, encoding="utf-8") as handle: + jobs.extend(json.load(handle).get("jobs", [])) + +def fail(message): + print( + f"Release run {run_id} attempt {run_attempt} is not safe npm-repair " + f"authority for {tag}: {message}", + file=sys.stderr, + ) + raise SystemExit(1) + +def one_job(name): + matches = [job for job in jobs if job.get("name") == name] + if len(matches) != 1: + fail(f"expected exactly one job {name!r}, found {len(matches)}") + job = matches[0] + if ( + job.get("head_sha") != commit + or job.get("status") != "completed" + or job.get("conclusion") != "success" + ): + fail(f"required job {name!r} did not succeed at {commit}") + return job + +for name in ( + "release-contract", + "Build signed release artifacts", + "Verify Apple Developer ID signatures", +): + one_job(name) + +if require_cloud_seal == "true": + one_job("Plan next cloud release") + seal = one_job("Seal cloud release tag") + seal_steps = [ + step for step in seal.get("steps", []) + if step.get("name") == "Create one immutable annotated release tag" + ] + if ( + len(seal_steps) != 1 + or seal_steps[0].get("status") != "completed" + or seal_steps[0].get("conclusion") != "success" + ): + fail("cloud release seal step did not succeed") +elif require_cloud_seal != "false": + fail(f"invalid cloud seal requirement {require_cloud_seal!r}") + +published = one_job("Publish immutable GitHub Release") +steps = [ + step for step in published.get("steps", []) + if step.get("name") == "Require immutable published GitHub Release" +] +if ( + len(steps) != 1 + or steps[0].get("status") != "completed" + or steps[0].get("conclusion") != "success" +): + fail("immutable GitHub Release verification step did not succeed") + +channels = [job for job in jobs if job.get("name") == "Publish npm and mirrors"] +if len(channels) != 1: + fail(f"expected exactly one npm publication job, found {len(channels)}") +channel = channels[0] +if ( + channel.get("head_sha") != commit + or channel.get("status") != "completed" + or channel.get("conclusion") not in {"success", "failure"} +): + fail("npm publication job is not a completed success/failure at the release commit") +PY + rm -rf "$jobs_dir" + return "$result" +} + push_delivery="$(find_push_delivery || true)" if [ -n "$push_delivery" ]; then printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \ @@ -282,6 +594,15 @@ if [ -n "$push_delivery" ]; then exit 0 fi +cloud_delivery_identity="$(find_cloud_delivery_identity || true)" +if [ -n "$cloud_delivery_identity" ] && + verify_cloud_delivery "$cloud_delivery_identity"; then + cloud_delivery_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + printf 'Release workflow delivery verified through cloud release run %s: %s -> %s\n' \ + "$cloud_delivery_run" "$TAG" "$EXPECTED_COMMIT" + exit 0 +fi + if [ "$MODE" = "channel-repair" ]; then failed_push_identity="$(find_failed_push_delivery || true)" failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)" @@ -292,6 +613,38 @@ if [ "$MODE" = "channel-repair" ]; then "$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" exit 0 fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_channel_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt"; then + printf 'Release %s channel-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$CHANNEL_REPAIR_TARGET" "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi +fi + +if [ "$MODE" = "npm-repair" ]; then + failed_push_identity="$(find_failed_push_delivery || true)" + failed_push_run="$(printf '%s\n' "$failed_push_identity" | cut -f1)" + failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)" + if [ -n "$failed_push_run" ] && + verify_npm_repair_delivery "$failed_push_run" "$failed_push_attempt" false; then + printf 'Release npm-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \ + "$failed_push_run" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + if [ -n "$cloud_delivery_identity" ] && + verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then + failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)" + failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)" + if verify_npm_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt" true; then + printf 'Release npm-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \ + "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT" + exit 0 + fi + fi fi find_recovery_identity() { @@ -325,7 +678,7 @@ for run in runs: recovery_identity="$(find_recovery_identity || true)" [ -n "$recovery_identity" ] || { - printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \ + printf 'Release workflow did not deliver %s at %s through a tag push, cloud release, or protected recovery\n' \ "$TAG" "$EXPECTED_COMMIT" >&2 exit 1 } diff --git a/scripts/release/withdraw-release.sh b/scripts/release/withdraw-release.sh new file mode 100755 index 00000000..6af3097c --- /dev/null +++ b/scripts/release/withdraw-release.sh @@ -0,0 +1,1186 @@ +#!/usr/bin/env bash +# Withdraw one already-published DWS release from every distribution channel +# without pretending that installed clients can be downgraded. +# +# The withdrawal is deliberately resumable. A permanent annotated tombstone is +# created before any channel mutation. Reruns accept only that exact tombstone; +# the ref is never updated or deleted. + +set -euo pipefail + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/release/release-lib.sh +. "$SCRIPT_DIR/release-lib.sh" + +VERSION="${1:-}" +REASON="${2:-}" +CONFIRMATION="${3:-}" + +OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" +PACKAGE_NAME="${DWS_NPM_PACKAGE:-dingtalk-workspace-cli}" +DEFAULT_BRANCH="${DWS_DEFAULT_BRANCH:-${GITHUB_EVENT_DEFAULT_BRANCH:-}}" +TOMBSTONE="withdrawn/${VERSION}" +GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" +GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" +OSS_PREFIX="${OSS_PREFIX:-dws}" +DELIVERY_VERIFIER="${DWS_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-release-workflow-delivery.sh}" +STABLE_DELIVERY_VERIFIER="${DWS_STABLE_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-delivered-stable.sh}" +GITHUB_DOWNLOAD_HELPER="${DWS_GITHUB_DOWNLOAD_HELPER:-$SCRIPT_DIR/download-github-release-assets.sh}" +ARTIFACT_VERIFY_HELPER="${DWS_ARTIFACT_VERIFY_HELPER:-$SCRIPT_DIR/verify-release-artifacts.sh}" +GITEE_SYNC_HELPER="${DWS_GITEE_SYNC_HELPER:-$SCRIPT_DIR/sync-to-gitee.sh}" + +err() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +say() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" +} + +need_env() { + local name="$1" value="$2" + [ -n "$value" ] || err "missing required environment variable: $name" +} + +sha256_text() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} + +github_api() { + gh api -H 'X-GitHub-Api-Version: 2026-03-10' "$@" +} + +github_optional() { + local error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-optional.XXXXXX")" + set +e + github_api "$@" 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + return 4 + fi + cat "$error_file" >&2 + rm -f "$error_file" + return "$status" +} + +github_expect_404() { + local endpoint="$1" error_file + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-404.XXXXXX")" + if github_api "$endpoint" >/dev/null 2>"$error_file"; then + rm -f "$error_file" + err "GitHub resource still exists after deletion: $endpoint" + fi + if ! grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + cat "$error_file" >&2 + rm -f "$error_file" + err "could not prove GitHub resource deletion: $endpoint" + fi + rm -f "$error_file" +} + +github_delete_if_present() { + local endpoint="$1" description="$2" error_file status + error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-delete.XXXXXX")" + set +e + github_api --method DELETE "$endpoint" >/dev/null 2>"$error_file" + status=$? + set -e + if [ "$status" -eq 0 ]; then + rm -f "$error_file" + return 0 + fi + if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then + rm -f "$error_file" + say "$description was already absent." + return 0 + fi + cat "$error_file" >&2 + rm -f "$error_file" + err "could not delete $description" +} + +release_json() { + github_api "repos/${OFFICIAL_REPOSITORY}/releases/tags/$1" +} + +release_field() { + local version="$1" expression="$2" + release_json "$version" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +path = sys.argv[1].split(".") +value = data +for item in path: + if item: + value = value.get(item) if isinstance(value, dict) else None +if value is True: + print("true") +elif value is False: + print("false") +elif value is not None: + print(value) +' "$expression" +} + +tag_commit() { + git rev-parse --verify "$1^{commit}" 2>/dev/null +} + +tag_object() { + git rev-parse --verify "refs/tags/$1" 2>/dev/null +} + +has_tombstone() { + git show-ref --verify --quiet "refs/tags/withdrawn/$1" +} + +expected_prerelease() { + if [ "$1" = prerelease ]; then + printf '%s\n' true + else + printf '%s\n' false + fi +} + +npm_exact_version() { + npm view "${PACKAGE_NAME}@${1#v}" version --registry=https://registry.npmjs.org 2>/dev/null +} + +npm_deprecation() { + npm view "${PACKAGE_NAME}@${1#v}" deprecated --registry=https://registry.npmjs.org 2>/dev/null +} + +release_is_eligible_rollback() { + local candidate="$1" channel="$2" state commit deprecation immutable + + release_validate_version_channel "$channel" "$candidate" >/dev/null 2>&1 || return 1 + has_tombstone "$candidate" && return 1 + [ "$(git cat-file -t "$(tag_object "$candidate")" 2>/dev/null || true)" = tag ] || return 1 + commit="$(tag_commit "$candidate" 2>/dev/null || true)" + printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$' || return 1 + + state="$( + release_json "$candidate" 2>/dev/null | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + str(bool(d.get("draft"))).lower(), + str(bool(d.get("prerelease"))).lower(), + "withdrawn" if "" + end_marker="" + original_body="$(release_field "$VERSION" body)" + block="${begin_marker} +## Withdrawn + +This release has been withdrawn from distribution. + +- Reason: ${REASON} +- Tombstone: \`${TOMBSTONE}\` + +Already-installed clients cannot be remotely downgraded. Install the current ${CHANNEL} channel or a later fixed version. +${end_marker}" + notes="$( + ORIGINAL_BODY="$original_body" WITHDRAW_BLOCK="$block" \ + BEGIN_MARKER="$begin_marker" END_MARKER="$end_marker" python3 - <<'PY' +import os + +body = os.environ["ORIGINAL_BODY"] +block = os.environ["WITHDRAW_BLOCK"] +begin = os.environ["BEGIN_MARKER"] +end = os.environ["END_MARKER"] +if begin in body and end in body and body.index(begin) < body.index(end): + prefix, remainder = body.split(begin, 1) + _, suffix = remainder.split(end, 1) + print(prefix + block + suffix, end="") +else: + separator = "\n\n" if body else "" + print(body + separator + block, end="") +PY + )" + + payload="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-release-json.XXXXXX")" + NOTES="$notes" python3 - "$VERSION" >"$payload" <<'PY' +import json +import os +import sys + +print(json.dumps({ + "name": f"[WITHDRAWN] {sys.argv[1]}", + "body": os.environ["NOTES"], +})) +PY + github_api --method PATCH \ + "repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}" \ + --input "$payload" >/dev/null + rm -f "$payload" + + state="$( + release_json "$VERSION" | + python3 -c 'import json,sys +d=json.load(sys.stdin) +print("%s\t%s\t%s" % ( + d.get("name", ""), + "marked" if "