Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dcc796f4c | ||
|
|
3e792b1c86 | ||
|
|
b9c822d49d | ||
|
|
f9b9b83f48 | ||
|
|
aa9e67e7c8 | ||
|
|
16ff02903a | ||
|
|
65d3f2959c | ||
|
|
cb3087ba9b | ||
|
|
5068cfdab8 | ||
|
|
3c81e5d47d | ||
|
|
d93925a892 | ||
|
|
faab9e0282 | ||
|
|
76d301268d | ||
|
|
7fddace8df | ||
|
|
99e5a3cceb | ||
|
|
7e31043875 | ||
|
|
55d7fbf59a | ||
|
|
c0f4d21c05 | ||
|
|
660c908585 | ||
|
|
377ebc5e85 | ||
|
|
076d77da8e | ||
|
|
2eca203e74 | ||
|
|
6e070a7e24 |
@@ -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' ||
|
||||
|
||||
+26
-32
@@ -1,4 +1,4 @@
|
||||
name: Code Admission — PR 合入门禁
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -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)
|
||||
@@ -714,9 +707,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'
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Main Integration — Wukong Overlay
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Code Admission — PR 合入门禁
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
+674
-128
File diff suppressed because it is too large
Load Diff
@@ -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 and every tag-enabled/configured mirror are rolled back, 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"
|
||||
@@ -6,6 +6,22 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.53-beta.6] - 2026-07-21
|
||||
|
||||
This beta validates guarded local release compatibility and tag-bound OSS deferral so an unprovisioned mirror cannot block the primary release channels.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tag-bound optional OSS release mirror** — Official cloud Release runs no longer block GitHub, npm, and Homebrew delivery when an OSS bucket has not been provisioned. Cloud tags immutably record `OSS-Mirror: enabled|deferred`; publication, repair, and withdrawal consume that sealed policy instead of the current repository variable. Enabled releases remain fail-closed, while deferred releases skip the nonexistent channel and cannot be backfilled without a future audited repair proof.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Guarded local release compatibility** — The tag-push Release workflow now accepts the `Channel`-only annotated tags created by the guarded local release entry while continuing to reject any partial cloud-only seal metadata.
|
||||
|
||||
## [1.0.53-beta.5] - 2026-07-21
|
||||
|
||||
This beta validates long-running access-token recovery and the faster, recoverable guarded release path introduced after v1.0.53-beta.4.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fast guarded beta and stable releases** — successful local release checks now leave a six-hour proof bound to the exact version, commit, repository identity, remote `main`, and stable baseline, so the subsequent guarded `--publish` invocation revalidates authority without repeating tests and packaging. A default-branch governance smoke uses the same dedicated immutable-release credential as the tag workflow before any tag is allocated.
|
||||
@@ -13,6 +29,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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,13 +16,15 @@ 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=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - 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=<ref> - Check the complete Schema contract against the 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)"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+27
-15
@@ -108,21 +108,33 @@ 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.
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
is provisioned; GitHub, npm, and Homebrew delivery can then complete without
|
||||
running the OSS step. Once enabled, missing credentials, an invalid Bucket, or
|
||||
an upload failure remains fail-closed. The cloud tag immutably records the
|
||||
decision as `OSS-Mirror: enabled|deferred`; publication and withdrawal consume
|
||||
that sealed value instead of the variable's later state. Deferred releases
|
||||
cannot use `repair_oss_version`; enabling OSS applies to later release tags
|
||||
until an audited immutable repair marker is implemented.
|
||||
|
||||
If an immutable GitHub Release and npm package were delivered but an enabled
|
||||
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 fully successful exact release,
|
||||
or 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. OSS repair additionally requires
|
||||
the tag's sealed policy to be `enabled`. It then downloads and re-verifies the
|
||||
immutable assets before invoking only the selected mirror. For a failed
|
||||
release, 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
|
||||
|
||||
|
||||
+11
-5
@@ -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
|
||||
|
||||
+70
-18
@@ -1,10 +1,54 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `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`。云端和本地入口都会在封 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 被再次触发。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
## 自动版本规则
|
||||
|
||||
- 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 <version>`,例如 `WITHDRAW v1.0.53`。
|
||||
|
||||
该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行:
|
||||
|
||||
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
|
||||
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
|
||||
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;只有目标 tag 封存了 `OSS-Mirror: enabled` 时,OSS 才会先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
|
||||
4. npm 以及目标 tag 启用或发布时配置的镜像渠道均已验证安全后,删除 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 +73,7 @@ main 上的候选代码 + beta CHANGELOG
|
||||
→ vX.Y.Z(正式发布)
|
||||
```
|
||||
|
||||
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
|
||||
## 预发发布
|
||||
|
||||
@@ -68,7 +112,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,20 +130,20 @@ 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 指向或复用版本号。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- 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。
|
||||
|
||||
OSS/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
|
||||
已启用的 OSS 或 Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
|
||||
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
|
||||
repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、
|
||||
repair 会精确绑定失败 tag run 的最新 attempt,且 OSS repair 要求 tag 的 sealed policy 为 `enabled`;contract、构建、Developer ID 签名、
|
||||
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
|
||||
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
|
||||
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
|
||||
@@ -110,25 +154,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 <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
|
||||
命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
|
||||
|
||||
- 输入精确绑定原 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 或门禁跳过。
|
||||
- 恢复复用正常的 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,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
@@ -136,8 +182,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 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
// 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 TestCrossPlatformCoverageNewEventSourceWiresForceRefreshRejectedToken(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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken asserts the
|
||||
// personal stream source receives a ForceRefreshToken callback that forwards
|
||||
// the rejected token into the app-level compare-and-refresh chain.
|
||||
func TestCrossPlatformCoverageNewPersonalStreamSourceWiresForceRefreshRejectedToken(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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -237,12 +237,21 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200))
|
||||
// Preserve structured HTTP status semantics even when the response
|
||||
// body is truncated. The body is diagnostic-only here, so read it
|
||||
// best-effort and classify retryability from the status code.
|
||||
if readErr != nil {
|
||||
data = nil
|
||||
}
|
||||
return nil, &HTTPStatusError{
|
||||
StatusCode: resp.StatusCode,
|
||||
responseBody: truncateBody(data, 200),
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", readErr)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type postJSONRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f postJSONRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type oauthBrokenBody struct{}
|
||||
|
||||
func (oauthBrokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (oauthBrokenBody) Close() error { return nil }
|
||||
|
||||
func TestCrossPlatformCoveragePostJSONTruncatedErrorBodyKeepsHTTPStatus(t *testing.T) {
|
||||
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
|
||||
})}}
|
||||
|
||||
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
|
||||
var statusErr *HTTPStatusError
|
||||
if !errors.As(err, &statusErr) || statusErr.StatusCode != status {
|
||||
t.Fatalf("postJSON() error = %v, want HTTPStatusError %d", err, status)
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("HTTP status error should not expose diagnostic body read failure: %v", err)
|
||||
}
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePostJSONOKTruncatedBodyIsTransient(t *testing.T) {
|
||||
provider := &OAuthProvider{httpClient: &http.Client{Transport: postJSONRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: oauthBrokenBody{}, Header: make(http.Header)}, nil
|
||||
})}}
|
||||
|
||||
_, err := provider.postJSON(context.Background(), "https://oauth.test/token", map[string]string{"grantType": "refresh_token"})
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("postJSON() error = %v, want io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
if got := ClassifyRefreshFailure(err); got != RefreshFailureTransient {
|
||||
t.Fatalf("ClassifyRefreshFailure() = %s, want transient", got)
|
||||
}
|
||||
}
|
||||
@@ -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, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", exchangeErr.Error())
|
||||
_, _ = fmt.Fprintf(w, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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"
|
||||
"io"
|
||||
"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
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"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},
|
||||
{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) {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -43,6 +44,7 @@ const (
|
||||
type PersonalConfig struct {
|
||||
AccessToken string
|
||||
AccessTokenProvider AccessTokenProvider
|
||||
ForceRefreshToken ForceRefreshTokenFn
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
SourceID string
|
||||
@@ -57,6 +59,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
|
||||
@@ -197,8 +206,29 @@ 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.
|
||||
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 +240,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 +251,50 @@ 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))
|
||||
}
|
||||
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, retryPersonal(err)
|
||||
return nil, resp.StatusCode, retryPersonal(err)
|
||||
}
|
||||
return nil, 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, 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) {
|
||||
@@ -381,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"):
|
||||
@@ -398,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 ||
|
||||
|
||||
@@ -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.
|
||||
@@ -42,12 +46,45 @@ type PortalTicketConfig struct {
|
||||
TicketURL string
|
||||
AccessToken string
|
||||
AccessTokenProvider AccessTokenProvider
|
||||
ForceRefreshToken ForceRefreshTokenFn
|
||||
SourceID string
|
||||
Mode string
|
||||
ClientID string
|
||||
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 {
|
||||
@@ -97,48 +134,102 @@ 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 {
|
||||
@@ -147,12 +238,12 @@ func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.Em
|
||||
resp, _ := handler(ctx, df)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +255,33 @@ 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
|
||||
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 {
|
||||
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.
|
||||
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 +294,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 +305,34 @@ 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, &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{}, fmt.Errorf("source: portal ticket HTTP %d: %s",
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Preserve HTTP status semantics before touching the body. A truncated
|
||||
// 401 body must not become a retryable read error that bypasses the
|
||||
// single token-refresh guard; the body is only best-effort diagnostics.
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
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
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return portalStreamTicket{}, resp.StatusCode, &portalStageError{
|
||||
stage: "ticket_request",
|
||||
status: resp.StatusCode,
|
||||
retryable: true,
|
||||
cause: fmt.Errorf("source: portal ticket read: %w", readErr),
|
||||
}
|
||||
}
|
||||
|
||||
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 +342,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) {
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 TestCrossPlatformCoveragePortalStart401RefreshRetryEndToEnd(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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestCrossPlatformCoverageRequestPortalTicketRetryUsesRotatedTokenDirectly(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketRefreshFailureKeepsBothErrors asserts a failing
|
||||
// refresh neither retries nor drops the refresh error or the original 401.
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal covers backward
|
||||
// compatibility: nil ForceRefreshToken keeps the single-attempt fatal 401.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketWithoutRefreshCallback401StaysFatal(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal guards against refresh loops: the
|
||||
// controlled retry happens exactly once even if the rotated token is also
|
||||
// rejected.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketSecond401IsFatal(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal asserts an empty rotated
|
||||
// token is rejected instead of being sent to the server.
|
||||
func TestCrossPlatformCoverageRequestPortalTicketRefreshEmptyTokenIsFatal(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry mirrors the portal behavior for the
|
||||
// personal stream ticket path.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshRetry(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal asserts a failed refresh
|
||||
// keeps the 401 fatal (not retryable) and wraps the refresh error.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
func TestCrossPlatformCoveragePortalTicketNon2xxTruncatedBodyKeepsStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
retryable bool
|
||||
}{
|
||||
{status: http.StatusUnauthorized},
|
||||
{status: http.StatusTooManyRequests, retryable: true},
|
||||
{status: http.StatusServiceUnavailable, retryable: true},
|
||||
} {
|
||||
t.Run(http.StatusText(tc.status), func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: tc.status, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.test",
|
||||
SourceID: "source",
|
||||
}, client, "token")
|
||||
if status != tc.status || err == nil || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", tc.status)) {
|
||||
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want HTTP %d", status, err, tc.status)
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("non-2xx status error should not expose diagnostic body read failure: %v", err)
|
||||
}
|
||||
var stageErr *portalStageError
|
||||
if tc.retryable {
|
||||
if !errors.As(err, &stageErr) || stageErr.status != tc.status || !stageErr.retryable {
|
||||
t.Fatalf("retryable status should return retryable portalStageError, got %T %v", err, err)
|
||||
}
|
||||
} else if errors.As(err, &stageErr) && stageErr.retryable {
|
||||
t.Fatalf("fatal status should not become retryable stage error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePortalTicket200TruncatedBodyIsRetryable(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: brokenBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
_, status, err := requestPortalTicketAttempt(context.Background(), &PortalTicketConfig{
|
||||
TicketURL: "https://ticket.test",
|
||||
SourceID: "source",
|
||||
}, client, "token")
|
||||
if status != http.StatusOK || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("requestPortalTicketAttempt() status=%d err=%v, want 200 + io.ErrUnexpectedEOF", status, err)
|
||||
}
|
||||
var stageErr *portalStageError
|
||||
if !errors.As(err, &stageErr) || !stageErr.retryable || stageErr.stage != "ticket_request" {
|
||||
t.Fatalf("2xx truncated body should be retryable ticket_request stage, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestCrossPlatformCoveragePersonalFetchTicket401TruncatedBodyStaysFatal(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable pins the existing
|
||||
// behavior for success responses: a body read failure on 2xx is a transient
|
||||
// transport problem and remains retryable.
|
||||
func TestCrossPlatformCoveragePersonalFetchTicket200TruncatedBodyStaysRetryable(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
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 {
|
||||
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 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()
|
||||
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",
|
||||
// Min above max exercises the reconnect clamp.
|
||||
ReconnectMin: 2 * 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 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{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@
|
||||
// 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义)
|
||||
// 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id <ID>` 续等
|
||||
// 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl
|
||||
// ↓
|
||||
// FAILED → 返回 error
|
||||
// ↓
|
||||
// FAILED → 返回 error
|
||||
// 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘
|
||||
package asynctask
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-3
@@ -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 <main-ref> --stable-ref <latest-GA-tag>`, `./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
|
||||
|
||||
Executable
+121
@@ -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 <app|generators|helpers|remaining|release-scripts>" \
|
||||
" $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
|
||||
@@ -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"
|
||||
|
||||
+5
-6
@@ -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..."
|
||||
|
||||
@@ -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/...
|
||||
|
||||
@@ -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 <ref> [--changed-only] [--scope-buildable] [--overall-profile <file>] [--additional-profile <file>] [--baseline-profile <file>] [--diff-profile <file>]" >&2
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> [--changed-only] [--scope-buildable] [--overall-profile <file>] [--additional-diff-profile <file>] [--baseline-profile <file>] [--diff-profile <file>]" >&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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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{
|
||||
|
||||
Executable
+203
@@ -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 <beta|prerelease|stable> [options]
|
||||
|
||||
Options:
|
||||
--bump <patch|minor|major> Core bump when starting a new beta line (default: patch)
|
||||
--repo-root <path> Override repository root (primarily for tests)
|
||||
|
||||
Output:
|
||||
release_version=<vX.Y.Z[-beta.N]>
|
||||
from_beta=<vX.Y.Z-beta.N or empty>
|
||||
channel=<prerelease|stable>
|
||||
base=<latest allocated stable tag or empty>
|
||||
|
||||
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:-<empty>}" >&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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
[ -n "$TAG" ] || {
|
||||
printf 'usage: release-tag-oss-mode.sh <tag>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
tag_ref="refs/tags/$TAG"
|
||||
git rev-parse --verify --quiet "$tag_ref" >/dev/null || {
|
||||
printf 'release tag is not available: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
tag_object="$(git rev-parse "$tag_ref")"
|
||||
[ "$(git cat-file -t "$tag_object")" = tag ] || {
|
||||
printf 'release tag must be annotated: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
tag_contents="$(git cat-file tag "$tag_object")" || {
|
||||
printf 'release tag could not be read: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mode="$(
|
||||
printf '%s\n' "$tag_contents" |
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
sub(/\r$/, "", line)
|
||||
}
|
||||
line ~ /^OSS-Mirror: / {
|
||||
count++
|
||||
value = substr(line, length("OSS-Mirror: ") + 1)
|
||||
}
|
||||
END {
|
||||
if (count > 1) exit 2
|
||||
if (count == 0) print "enabled"
|
||||
else print value
|
||||
}
|
||||
'
|
||||
)" || {
|
||||
printf 'release tag contains duplicate OSS-Mirror metadata: %s\n' "$TAG" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$mode" in
|
||||
enabled|deferred) printf '%s\n' "$mode" ;;
|
||||
*)
|
||||
printf 'release tag contains invalid OSS-Mirror metadata: %s (%s)\n' "$TAG" "$mode" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <oss|gitee>] <tag> <commit>\n' >&2
|
||||
printf 'usage: verify-release-workflow-delivery.sh [--channel-repair <oss|gitee> | --npm-repair] <tag> <commit>\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
|
||||
}
|
||||
|
||||
Executable
+1215
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func TestRootHelpHidesRecoveredModules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := app.NewRootCommand()
|
||||
var out strings.Builder
|
||||
cmd.SetOut(&out)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,14 @@ 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`,
|
||||
"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)
|
||||
@@ -451,6 +459,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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -758,7 +758,7 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
|
||||
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
|
||||
`ref: ${{ inputs.governance_preflight_commit }}`,
|
||||
`ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}`,
|
||||
"persist-credentials: false",
|
||||
"governance preflight cannot be combined with npm repair",
|
||||
} {
|
||||
@@ -792,6 +792,9 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
for _, required := range []string{
|
||||
"needs: dispatch-contract",
|
||||
"needs.dispatch-contract.outputs.mode == 'repair_npm'",
|
||||
`ref: ` + "`tags/withdrawn/${version}`",
|
||||
"was withdrawn and cannot be repaired",
|
||||
"verify-release-workflow-delivery.sh --npm-repair",
|
||||
} {
|
||||
if !strings.Contains(repair, required) {
|
||||
t.Errorf("npm repair dispatch contract is missing %q", required)
|
||||
@@ -799,6 +802,158 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
planStart := strings.Index(workflow, " release-plan:\n")
|
||||
sealStart := strings.Index(workflow, " seal-release:\n")
|
||||
if planStart == -1 || sealStart == -1 || planStart >= sealStart {
|
||||
t.Fatal("cloud release plan and seal jobs are missing or out of order")
|
||||
}
|
||||
plan := workflow[planStart:sealStart]
|
||||
seal := workflow[sealStart:]
|
||||
|
||||
for _, required := range []string{
|
||||
"release_operation:",
|
||||
"- none",
|
||||
"- plan",
|
||||
"- publish",
|
||||
"release_channel:",
|
||||
"release_bump:",
|
||||
"release_confirmation:",
|
||||
`release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery`,
|
||||
`echo "mode=plan_release"`,
|
||||
`echo "mode=create_release"`,
|
||||
`release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL`,
|
||||
`needs.dispatch-contract.outputs.mode == 'plan_release'`,
|
||||
`needs.governance-preflight.result == 'success'`,
|
||||
"actions: read",
|
||||
"contents: read",
|
||||
`github.event.repository.default_branch`,
|
||||
`GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY`,
|
||||
`GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH`,
|
||||
`ref: ${{ github.sha }}`,
|
||||
"persist-credentials: false",
|
||||
`refs/remotes/origin/main)" = "$GITHUB_SHA`,
|
||||
"next-release-version.sh",
|
||||
"refs/tags/withdrawn/v",
|
||||
"refs_fingerprint",
|
||||
"Validate the candidate release contract before sealing",
|
||||
"release-contract.sh",
|
||||
"Require delivered previous stable baseline before sealing",
|
||||
"Require delivered beta before sealing stable",
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("cloud release contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plan, "contents: write") {
|
||||
t.Error("cloud release planning must remain read-only")
|
||||
}
|
||||
|
||||
for _, required := range []string{
|
||||
"name: Seal cloud release tag",
|
||||
"contents: write",
|
||||
"name: Create one immutable annotated release tag",
|
||||
`branch.data.commit.sha !== commit`,
|
||||
`actualFingerprint !== expectedFingerprint`,
|
||||
`github.rest.git.createTag`,
|
||||
`github.rest.git.createRef`,
|
||||
`ref: ` + "`refs/tags/${version}`",
|
||||
"`Release-Run: ${context.runId}`",
|
||||
"`Requested-By: ${context.actor}`",
|
||||
"`Requested-By-ID: ${context.payload.sender?.id || \"\"}`",
|
||||
"`Sealed-Commit: ${commit}`",
|
||||
"`Workflow-Commit: ${context.sha}`",
|
||||
"`Allocation-Fingerprint: ${expectedFingerprint}`",
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("cloud release seal is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout",
|
||||
"github.rest.git.updateRef",
|
||||
"github.rest.git.deleteRef",
|
||||
"git push",
|
||||
"--force",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("write-capable cloud seal must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowAcceptsGuardedLocalTagMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
|
||||
for _, required := range []string{
|
||||
`const cloudOnlyKeys = [`,
|
||||
`const cloudKeys = ["Channel", ...cloudOnlyKeys];`,
|
||||
`const hasAnyCloudMetadata = cloudOnlyKeys.some((key) => tagFields.has(key));`,
|
||||
`const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));`,
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("local tag metadata compatibility is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(workflow, `const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key));`) {
|
||||
t.Error("Channel-only guarded local tags must not be classified as partial cloud seals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRequiresOSSOnlyWhenMirrorIsEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
releaseContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
||||
targetAuthority := releaseWorkflowSection(
|
||||
t,
|
||||
releaseContract,
|
||||
" - name: Resolve and verify exact release target\n",
|
||||
"\n - name: Check out repository\n",
|
||||
)
|
||||
ossStep := releaseWorkflowSection(
|
||||
t,
|
||||
workflow,
|
||||
" - name: Sync release artifacts to China OSS mirror\n",
|
||||
"\n mirror-gitee-release:\n",
|
||||
)
|
||||
|
||||
for _, required := range []string{
|
||||
`if: ${{ needs.release-contract.outputs.oss_mirror == 'enabled' }}`,
|
||||
`run: ./scripts/release/sync-to-oss.sh`,
|
||||
`DWS_REQUIRE_OSS: "1"`,
|
||||
} {
|
||||
if !strings.Contains(ossStep, required) {
|
||||
t.Errorf("opt-in OSS publication is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`OSS_MIRROR: ${{ vars.ENABLE_OSS_MIRROR == 'true' && 'enabled' || 'deferred' }}`,
|
||||
`OSS-Mirror: ${ossMirror}`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(workflow, required) {
|
||||
t.Errorf("immutable OSS release policy is missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ossStep, "vars.ENABLE_OSS_MIRROR") {
|
||||
t.Error("channel publication must use the immutable tag policy, not the current repository variable")
|
||||
}
|
||||
for _, required := range []string{
|
||||
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
||||
`? tagFields.get("OSS-Mirror")`,
|
||||
`: "enabled";`,
|
||||
`!["enabled", "deferred"].includes(ossMirror)`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(targetAuthority, required) {
|
||||
t.Errorf("release target OSS policy authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
@@ -807,7 +962,17 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the channel repair job")
|
||||
}
|
||||
repair := workflow[start:]
|
||||
end := strings.Index(workflow[start:], "\n release-plan:\n")
|
||||
if end == -1 {
|
||||
t.Fatal("release workflow channel repair job is missing its end marker")
|
||||
}
|
||||
repair := workflow[start : start+end]
|
||||
authority := releaseWorkflowSection(
|
||||
t,
|
||||
repair,
|
||||
" - name: Verify immutable release authority\n",
|
||||
"\n - name: Require sealed OSS policy for repair\n",
|
||||
)
|
||||
tagAuthority := releaseWorkflowSection(
|
||||
t,
|
||||
repair,
|
||||
@@ -826,7 +991,7 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
"oss_repair=0",
|
||||
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
|
||||
`test -z "$REPAIR_OSS_VERSION" || oss_repair=1`,
|
||||
"npm_repair + gitee_repair + oss_repair + governance + recovery",
|
||||
"release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery",
|
||||
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
|
||||
`echo "mode=repair_oss" >> "$GITHUB_OUTPUT"`,
|
||||
} {
|
||||
@@ -847,6 +1012,8 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
"persist-credentials: false",
|
||||
"release_is_stable_version",
|
||||
"release_is_prerelease_version",
|
||||
`ref: ` + "`tags/withdrawn/${version}`",
|
||||
"was withdrawn and cannot be repaired",
|
||||
`ref: ` + "`tags/${version}`",
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
"!release.data.immutable",
|
||||
@@ -854,6 +1021,8 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
"assetNames.length !== expectedAssets.size",
|
||||
"new Set(assetNames).size !== expectedAssets.size",
|
||||
`core.setOutput("tag_object", ref.data.object.sha)`,
|
||||
"Require sealed OSS policy for repair",
|
||||
"OSS repair is unavailable because this immutable release deferred the OSS channel.",
|
||||
`ref: ${{ steps.authority.outputs.commit_sha }}`,
|
||||
"path: release-source",
|
||||
"verify-github-tag-authority.sh",
|
||||
@@ -884,6 +1053,25 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Errorf("channel repair authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
`const tagFields = new Map();`,
|
||||
`const ossMirror = tagFields.has("OSS-Mirror")`,
|
||||
`? tagFields.get("OSS-Mirror")`,
|
||||
`: "enabled";`,
|
||||
`!["enabled", "deferred"].includes(ossMirror)`,
|
||||
`core.setOutput("oss_mirror", ossMirror);`,
|
||||
} {
|
||||
if !strings.Contains(authority, required) {
|
||||
t.Errorf("channel repair tag policy authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
npmRepair := releaseWorkflowSection(t, workflow, " repair-npm:\n", "\n release-delivery-gate:\n")
|
||||
if strings.Contains(npmRepair, `const ossMirror`) || strings.Contains(npmRepair, `core.setOutput("oss_mirror"`) {
|
||||
t.Error("npm repair must not parse or export the channel-only OSS policy")
|
||||
}
|
||||
if strings.Contains(repair, "ENABLE_OSS_MIRROR") {
|
||||
t.Error("OSS repair must use the immutable tag policy, not the current repository variable")
|
||||
}
|
||||
for _, asset := range []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
@@ -942,12 +1130,14 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
"protected_branches !== true",
|
||||
"can_admins_bypass !== false",
|
||||
`run.path !== ".github/workflows/release.yml"`,
|
||||
`run.event !== "push"`,
|
||||
`const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"`,
|
||||
`run.event !== expectedEvent`,
|
||||
`tagFields.get("Release-Run") !== failedRunId`,
|
||||
`"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}"`,
|
||||
"attempt_number: Number(failedRunAttempt)",
|
||||
"run.run_attempt !== Number(failedRunAttempt)",
|
||||
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
|
||||
`run.head_branch !== version`,
|
||||
`run.head_branch !== expectedBranch`,
|
||||
`run.head_sha !== commit`,
|
||||
`tagObject !== expectedTagObject`,
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
@@ -960,7 +1150,7 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
`ref: sha`,
|
||||
`path: tmp/trusted-release-tooling`,
|
||||
`ref: ${{ github.sha }}`,
|
||||
`step.name === "Require immutable published GitHub Release"`,
|
||||
"verify-release-workflow-delivery.sh",
|
||||
"Require a clean sealed source before GoReleaser",
|
||||
`git status --porcelain --untracked-files=all`,
|
||||
} {
|
||||
@@ -1105,7 +1295,11 @@ func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
|
||||
"actions/runs/$find_run_id/attempts/$find_attempt",
|
||||
`select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\")`,
|
||||
"Release run %s has no failed attempt",
|
||||
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv`,
|
||||
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv`,
|
||||
`Release-Run`,
|
||||
`Release-Run-Attempt`,
|
||||
`expected_attempt_event="workflow_dispatch"`,
|
||||
`is not bound by the cloud seal`,
|
||||
"actions/runs/%s/attempts/%s",
|
||||
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
|
||||
} {
|
||||
@@ -1129,7 +1323,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
|
||||
name: "release contract",
|
||||
start: " release-contract:\n",
|
||||
end: "\n release:\n",
|
||||
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}`,
|
||||
condition: `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')) }}`,
|
||||
},
|
||||
{
|
||||
name: "build",
|
||||
@@ -1192,7 +1386,11 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"- mirror-gitee-release",
|
||||
"- repair-npm",
|
||||
"- repair-channel",
|
||||
"- release-plan",
|
||||
"- seal-release",
|
||||
`REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`,
|
||||
`RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`,
|
||||
`SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`,
|
||||
"require_publication",
|
||||
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
||||
`require_result release "$RELEASE_RESULT" success`,
|
||||
@@ -1200,6 +1398,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
|
||||
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
|
||||
"workflow_dispatch:recover_release",
|
||||
"workflow_dispatch:create_release",
|
||||
"workflow_dispatch:plan_release",
|
||||
"workflow_dispatch:governance_preflight",
|
||||
"workflow_dispatch:repair_npm",
|
||||
"workflow_dispatch:repair_gitee",
|
||||
|
||||
@@ -141,6 +141,136 @@ func TestReleaseVersionOrdering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextReleaseVersion(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "next-release-version.sh")
|
||||
|
||||
newRepo := func(t *testing.T, tags ...string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
mustRun(t, root, "git", "init", "-b", "main")
|
||||
mustRun(t, root, "git", "config", "user.name", "Release Test")
|
||||
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("release allocator\n"), 0o644)
|
||||
mustRun(t, root, "git", "add", "seed.txt")
|
||||
mustRun(t, root, "git", "commit", "-m", "seed")
|
||||
for _, tag := range tags {
|
||||
mustRun(t, root, "git", "tag", "-a", tag, "-m", "Allocate "+tag)
|
||||
}
|
||||
return root
|
||||
}
|
||||
run := func(t *testing.T, root string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{script, "--repo-root", root}, args...)
|
||||
output, err := exec.Command("sh", commandArgs...).CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
t.Run("continues highest open beta core", func(t *testing.T) {
|
||||
root := newRepo(t,
|
||||
"v1.0.52",
|
||||
"v1.0.53-beta.1",
|
||||
"v1.0.53-beta.2",
|
||||
"v1.0.53-beta.3",
|
||||
"v1.0.53-beta.4",
|
||||
)
|
||||
output, err := run(t, root, "--channel", "beta")
|
||||
if err != nil {
|
||||
t.Fatalf("next beta error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "release_version=v1.0.53-beta.5\nfrom_beta=\nchannel=prerelease\nbase=v1.0.52\n"
|
||||
if output != want {
|
||||
t.Fatalf("next beta output:\ngot:\n%s\nwant:\n%s", output, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("withdrawn beta remains allocated", func(t *testing.T) {
|
||||
root := newRepo(t,
|
||||
"v1.0.52",
|
||||
"v1.0.53-beta.4",
|
||||
"withdrawn/v1.0.53-beta.5",
|
||||
)
|
||||
output, err := run(t, root, "--channel", "prerelease")
|
||||
if err != nil {
|
||||
t.Fatalf("next beta after withdrawal error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "release_version=v1.0.53-beta.6\n") {
|
||||
t.Fatalf("withdrawn beta version was reused:\n%s", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts requested bumped beta core", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
bump string
|
||||
want string
|
||||
}{
|
||||
{bump: "patch", want: "v1.2.4-beta.1"},
|
||||
{bump: "minor", want: "v1.3.0-beta.1"},
|
||||
{bump: "major", want: "v2.0.0-beta.1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.bump, func(t *testing.T) {
|
||||
root := newRepo(t, "v1.2.3")
|
||||
output, err := run(t, root, "--channel", "beta", "--bump", test.bump)
|
||||
if err != nil {
|
||||
t.Fatalf("new beta line error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "release_version="+test.want+"\n") {
|
||||
t.Fatalf("new beta line output:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("promotes latest beta from highest open core", func(t *testing.T) {
|
||||
root := newRepo(t,
|
||||
"v1.0.0",
|
||||
"v1.1.0-beta.3",
|
||||
"v1.2.0-beta.1",
|
||||
"v1.2.0-beta.2",
|
||||
)
|
||||
output, err := run(t, root, "--channel", "stable")
|
||||
if err != nil {
|
||||
t.Fatalf("stable allocation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "release_version=v1.2.0\nfrom_beta=v1.2.0-beta.2\nchannel=stable\nbase=v1.0.0\n"
|
||||
if output != want {
|
||||
t.Fatalf("stable allocation output:\ngot:\n%s\nwant:\n%s", output, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("withdrawn stable closes its core", func(t *testing.T) {
|
||||
root := newRepo(t,
|
||||
"v1.0.0",
|
||||
"v1.0.1-beta.1",
|
||||
"withdrawn/v1.0.1",
|
||||
)
|
||||
output, err := run(t, root, "--channel", "beta")
|
||||
if err != nil {
|
||||
t.Fatalf("beta after withdrawn stable error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
want := "release_version=v1.0.2-beta.1\nfrom_beta=\nchannel=prerelease\nbase=v1.0.1\n"
|
||||
if output != want {
|
||||
t.Fatalf("withdrawn stable allocation output:\ngot:\n%s\nwant:\n%s", output, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("withdrawn latest beta cannot be promoted", func(t *testing.T) {
|
||||
root := newRepo(t,
|
||||
"v1.0.0",
|
||||
"v1.0.1-beta.1",
|
||||
"withdrawn/v1.0.1-beta.2",
|
||||
)
|
||||
output, err := run(t, root, "--channel", "stable")
|
||||
if err == nil || !strings.Contains(output, "latest beta v1.0.1-beta.2 is withdrawn") {
|
||||
t.Fatalf("withdrawn beta promotion was not rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseLibHelpersPreserveCallerVariables(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -674,6 +804,179 @@ esac
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowDeliveryAcceptsOnlyTagBoundCloudRelease(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := filepath.Join(binDir, "curl")
|
||||
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
for argument in "$@"; do endpoint="$argument"; done
|
||||
case "$endpoint" in
|
||||
*event=push*)
|
||||
printf '{"workflow_runs":[]}\n'
|
||||
;;
|
||||
*/git/ref/tags/*)
|
||||
printf '{"object":{"type":"tag","sha":"%s"}}\n' "$TAG_OBJECT"
|
||||
;;
|
||||
*/git/tags/*)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
message = "\n".join([
|
||||
f"Release {os.environ['TAG']}",
|
||||
"",
|
||||
"Channel: prerelease",
|
||||
"Release-Run: 42",
|
||||
"Release-Run-Attempt: 1",
|
||||
"Requested-By: release-user",
|
||||
"Requested-By-ID: 1234",
|
||||
f"Sealed-Commit: {os.environ['RELEASE_COMMIT']}",
|
||||
f"Workflow-Commit: {os.environ['RELEASE_COMMIT']}",
|
||||
f"Allocation-Fingerprint: {'d' * 64}",
|
||||
])
|
||||
print(json.dumps({
|
||||
"tag": os.environ["TAG"],
|
||||
"message": message,
|
||||
"object": {"type": "commit", "sha": os.environ["RELEASE_COMMIT"]},
|
||||
}))
|
||||
PY
|
||||
;;
|
||||
*/actions/runs/42/attempts/1/jobs*)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
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",
|
||||
]
|
||||
if os.environ.get("MISSING_SEAL") == "1":
|
||||
required.remove("Seal cloud release tag")
|
||||
jobs = []
|
||||
for name in required:
|
||||
job = {
|
||||
"name": name,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"head_sha": os.environ["RELEASE_COMMIT"],
|
||||
"steps": [],
|
||||
}
|
||||
if name == "Seal cloud release tag":
|
||||
job["steps"] = [{
|
||||
"name": "Create one immutable annotated release tag",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}]
|
||||
elif name == "Publish immutable GitHub Release":
|
||||
job["steps"] = [{
|
||||
"name": "Require immutable published GitHub Release",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}]
|
||||
if os.environ.get("PUBLISH_RELEASE_FAILURE") == "1":
|
||||
job["conclusion"] = "failure"
|
||||
elif name == "Publish npm and mirrors":
|
||||
job["conclusion"] = os.environ.get("CHANNEL_CONCLUSION", "success")
|
||||
jobs.append(job)
|
||||
print(json.dumps({"jobs": jobs}))
|
||||
PY
|
||||
;;
|
||||
*/actions/runs/42/attempts/1)
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
print(json.dumps({
|
||||
"id": 42,
|
||||
"run_attempt": 1,
|
||||
"repository": {"full_name": "owner/repo"},
|
||||
"path": ".github/workflows/release.yml",
|
||||
"event": "workflow_dispatch",
|
||||
"status": "completed",
|
||||
"conclusion": os.environ.get("RUN_CONCLUSION", "success"),
|
||||
"head_branch": "main",
|
||||
"head_sha": os.environ["RELEASE_COMMIT"],
|
||||
"actor": {
|
||||
"login": os.environ.get("RUN_ACTOR", "release-user"),
|
||||
"id": int(os.environ.get("RUN_ACTOR_ID", "1234")),
|
||||
},
|
||||
}))
|
||||
PY
|
||||
;;
|
||||
*event=workflow_dispatch*)
|
||||
printf '{"workflow_runs":[]}\n'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
|
||||
tag := "v1.2.3-beta.1"
|
||||
commit := strings.Repeat("a", 40)
|
||||
tagObject := strings.Repeat("b", 40)
|
||||
runWithArgs := func(arguments []string, overrides ...string) (string, error) {
|
||||
cmd := exec.Command("sh", script, tag, commit)
|
||||
cmd.Args = append([]string{"sh", script}, arguments...)
|
||||
cmd.Env = append([]string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
|
||||
"TAG=" + tag,
|
||||
"TAG_OBJECT=" + tagObject,
|
||||
"RELEASE_COMMIT=" + commit,
|
||||
}, overrides...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
run := func(overrides ...string) (string, error) {
|
||||
return runWithArgs([]string{tag, commit}, overrides...)
|
||||
}
|
||||
|
||||
if output, err := run(); err != nil || !strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("tag-bound cloud delivery was rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := runWithArgs(
|
||||
[]string{"--channel-repair", "oss", tag, commit},
|
||||
); err != nil || !strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("OSS repair could not use a successful release with the mirror enabled: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := run("RUN_ACTOR=renamed-release-user"); err != nil ||
|
||||
!strings.Contains(output, "cloud release run 42") {
|
||||
t.Fatalf("cloud delivery broke after a harmless login rename: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := run("RUN_ACTOR_ID=9999"); err == nil ||
|
||||
!strings.Contains(output, "did not deliver") {
|
||||
t.Fatalf("cloud delivery with the wrong stable actor ID passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := run("MISSING_SEAL=1"); err == nil ||
|
||||
!strings.Contains(output, "did not deliver") {
|
||||
t.Fatalf("cloud delivery without the seal job passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := runWithArgs(
|
||||
[]string{"--npm-repair", tag, commit},
|
||||
"RUN_CONCLUSION=failure",
|
||||
"CHANNEL_CONCLUSION=failure",
|
||||
); err != nil || !strings.Contains(output, "npm-repair authority verified") {
|
||||
t.Fatalf("npm repair could not use a sealed immutable release after npm failure: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
if output, err := runWithArgs(
|
||||
[]string{"--npm-repair", tag, commit},
|
||||
"RUN_CONCLUSION=failure",
|
||||
"CHANNEL_CONCLUSION=failure",
|
||||
"PUBLISH_RELEASE_FAILURE=1",
|
||||
); err == nil || strings.Contains(output, "npm-repair authority verified") {
|
||||
t.Fatalf("npm repair accepted a failed immutable GitHub publication: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowDeliveryChannelRepairRequiresLatestAttemptCoreDelivery(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -1031,6 +1334,109 @@ func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseContractTreatsWithdrawnVersionsAsPermanentlyAllocated(t *testing.T) {
|
||||
t.Run("continues after withdrawn beta", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
section := "## [1.0.1-beta.3] - 2026-07-11\n\n### Changed\n\n- Replace the withdrawn beta.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
r.commitAndPush(t, "prepare replacement beta")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.2", "-m", "Withdraw v1.0.1-beta.2")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.3",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("replacement beta was rejected: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("never reuses exact withdrawn version", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
section := "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Must not reuse this version.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
r.commitAndPush(t, "prepare withdrawn version")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.1", "-m", "Withdraw v1.0.1-beta.1")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "can never be reused") {
|
||||
t.Fatalf("withdrawn version was reusable: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("withdrawn beta cannot be promoted", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
r.seedBeta(t)
|
||||
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.1", "-m", "Withdraw v1.0.1-beta.1")
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
|
||||
r.commitAndPush(t, "prepare blocked stable")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "stable",
|
||||
"--version", "v1.0.1",
|
||||
"--from-beta", "v1.0.1-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "beta baseline was withdrawn") {
|
||||
t.Fatalf("withdrawn beta was promoted: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("withdrawn stable is a permanent version floor", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v2.0.0", "-m", "Withdraw v2.0.0")
|
||||
section := "## [1.1.0-beta.1] - 2026-07-11\n\n### Changed\n\n- Must remain above every allocated stable.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
r.commitAndPush(t, "prepare invalid lower beta")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.1.0-beta.1",
|
||||
"--remote", "origin",
|
||||
)
|
||||
if err == nil || !strings.Contains(output, "must be greater than latest allocated stable v2.0.0") {
|
||||
t.Fatalf("release below withdrawn stable floor passed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tombstone excludes stale ordinary tag from delivered baseline", func(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1")
|
||||
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1", "-m", "Withdraw v1.0.1")
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Continue after the withdrawn stable.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
r.commitAndPush(t, "prepare beta after withdrawn stable")
|
||||
metadata := filepath.Join(t.TempDir(), "metadata")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, r.contract,
|
||||
"--repo-root", r.root,
|
||||
"--channel", "prerelease",
|
||||
"--version", "v1.0.2-beta.1",
|
||||
"--remote", "origin",
|
||||
"--metadata-output", metadata,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release after stale withdrawn tag failed: err=%v\noutput:\n%s", err, output)
|
||||
}
|
||||
assertFileContains(t, metadata, "previous_stable=v1.0.0")
|
||||
if content, err := os.ReadFile(metadata); err != nil {
|
||||
t.Fatalf("ReadFile(metadata) error = %v", err)
|
||||
} else if strings.Contains(string(content), "previous_stable=v1.0.1") {
|
||||
t.Fatalf("withdrawn stale tag remained the delivered baseline:\n%s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseContractRejectsBadChangelogSections(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -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 <duration>] [--jobs <n>]
|
||||
#
|
||||
# Options:
|
||||
# --verbose Print full test output for every suite (default: only on failure)
|
||||
# --timeout <dur> Per-suite go test timeout (default: 120s)
|
||||
# --jobs <n> 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/<safe_label>/{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
|
||||
@@ -0,0 +1,943 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithdrawReleaseWorkflowIsProtectedAndFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "withdraw-release.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(withdraw workflow) error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
|
||||
}
|
||||
workflow := string(content)
|
||||
|
||||
for _, want := range []string{
|
||||
"workflow_dispatch:",
|
||||
"version:",
|
||||
"reason:",
|
||||
"confirmation:",
|
||||
"group: dws-release-publication",
|
||||
"cancel-in-progress: false",
|
||||
"environment: release-withdrawal",
|
||||
"prevent_self_review !== true",
|
||||
"deployment_branch_policy?.protected_branches !== true",
|
||||
"can_admins_bypass !== false",
|
||||
`const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"`,
|
||||
"context.ref !== `refs/heads/${defaultBranch}`",
|
||||
"branch.data.object.sha !== context.sha",
|
||||
"contents: write",
|
||||
"persist-credentials: false",
|
||||
"NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
|
||||
"DWS_GITEE_ENABLED:",
|
||||
"HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}",
|
||||
"./scripts/release/withdraw-release.sh",
|
||||
"already-installed clients cannot be remotely downgraded.",
|
||||
"this run remains failed until that PR is independently reviewed",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("withdraw workflow missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"\n push:",
|
||||
"\n schedule:",
|
||||
"npm unpublish",
|
||||
"cancel-in-progress: true",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("withdraw workflow contains forbidden trigger/action %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(withdraw script) error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
||||
}
|
||||
script := string(content)
|
||||
|
||||
for _, want := range []string{
|
||||
`TOMBSTONE="withdrawn/${VERSION}"`,
|
||||
`github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/tags"`,
|
||||
`github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/refs"`,
|
||||
"existing tombstone $TOMBSTONE has different immutable withdrawal metadata",
|
||||
`github_api --method PATCH`,
|
||||
`npm deprecate "${PACKAGE_NAME}@${VERSION#v}"`,
|
||||
`npm dist-tag add "${PACKAGE_NAME}@${ROLLBACK_VERSION#v}"`,
|
||||
`TARGET_OSS_MODE="$("$SCRIPT_DIR/release-tag-oss-mode.sh" "$VERSION")"`,
|
||||
`printf 'OSS-Mirror: %s\n' "$TARGET_OSS_MODE"`,
|
||||
`oss_mode = fields.get("OSS-Mirror", "enabled")`,
|
||||
`if [ "$OSS_ENABLED" = true ]; then`,
|
||||
`*) err "could not resolve immutable OSS policy for $VERSION" ;;`,
|
||||
`"$OSSUTIL" rm -rf`,
|
||||
`curl -fsS -X DELETE`,
|
||||
`git push "$GITEE_GIT_REMOTE" ":refs/tags/${VERSION}"`,
|
||||
`)" || return 1`,
|
||||
`err "could not verify Gitee tag deletion for $VERSION"`,
|
||||
`disabled_gitee_release_id="$(gitee_release_id "$VERSION")"`,
|
||||
`DWS_TAP_PR_TITLE="revert: withdraw ${VERSION} and restore ${ROLLBACK_VERSION}"`,
|
||||
"Homebrew rollback PR requires independent review and merge",
|
||||
`"repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}"`,
|
||||
`"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${VERSION}"`,
|
||||
`github_expect_404 "repos/${OFFICIAL_REPOSITORY}/releases/tags/${VERSION}"`,
|
||||
`github_expect_404 "repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${VERSION}"`,
|
||||
"Already-installed clients cannot be remotely downgraded",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("withdraw script missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"npm unpublish",
|
||||
`"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${TOMBSTONE}"`,
|
||||
`--force "refs/tags/${TOMBSTONE}`,
|
||||
`make_latest`,
|
||||
} {
|
||||
if strings.Contains(script, forbidden) {
|
||||
t.Errorf("withdraw script contains evidence-destroying operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
tombstone := strings.LastIndex(script, "\ncreate_tombstone\n")
|
||||
githubMutation := strings.LastIndex(script, "\nupdate_github_release\n")
|
||||
npmMutation := strings.LastIndex(script, "\nupdate_npm_channel\n")
|
||||
ossMutation := strings.LastIndex(script, `update_oss_channel "$OSS_POINTER_NAME"`)
|
||||
homebrewGate := strings.LastIndex(script, "\nupdate_homebrew\n")
|
||||
githubDelete := strings.LastIndex(script, "\ndelete_github_release_and_tag\n")
|
||||
if tombstone < 0 || githubMutation < 0 || npmMutation < 0 || ossMutation < 0 ||
|
||||
homebrewGate < 0 || githubDelete < 0 {
|
||||
t.Fatalf("could not locate withdrawal mutation sequence")
|
||||
}
|
||||
if !(tombstone < homebrewGate && homebrewGate < githubMutation &&
|
||||
githubMutation < npmMutation && npmMutation < ossMutation && ossMutation < githubDelete) {
|
||||
t.Fatalf("unsafe withdrawal order: tombstone=%d github-mark=%d npm=%d oss=%d homebrew=%d github-delete=%d",
|
||||
tombstone, githubMutation, npmMutation, ossMutation, homebrewGate, githubDelete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseTagOSSModeIsImmutableAndFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release-tag-oss-mode.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(OSS mode script) error = %v", err)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
mustRun(t, repo, "git", "config", "user.name", "OSS Mode Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "oss-mode@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("fixture\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "tracked")
|
||||
mustRun(t, repo, "git", "commit", "-m", "fixture")
|
||||
|
||||
tag := func(version, message string) {
|
||||
t.Helper()
|
||||
messagePath := filepath.Join(repo, version+".message")
|
||||
mustWriteFile(t, messagePath, []byte(message), 0o644)
|
||||
mustRun(t, repo, "git", "tag", "-a", version, "-F", messagePath)
|
||||
}
|
||||
tag("v1.0.1", "Release v1.0.1\n")
|
||||
tag("v1.0.2", "Release v1.0.2\n\nOSS-Mirror: enabled\n")
|
||||
tag("v1.0.3", "Release v1.0.3\n\nOSS-Mirror: deferred\n")
|
||||
tag("v1.0.4", "Release v1.0.4\n\nOSS-Mirror: invalid\n")
|
||||
tag("v1.0.5", "Release v1.0.5\n\nOSS-Mirror: enabled\nOSS-Mirror: deferred\n")
|
||||
mustRun(t, repo, "git", "tag", "v1.0.6")
|
||||
rawTag := func(version, message string) {
|
||||
t.Helper()
|
||||
commit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
|
||||
payload := strings.Join([]string{
|
||||
"object " + commit,
|
||||
"type commit",
|
||||
"tag " + version,
|
||||
"tagger OSS Mode Test <oss-mode@example.com> 1700000000 +0000",
|
||||
"",
|
||||
message,
|
||||
}, "\n")
|
||||
cmd := exec.Command("git", "mktag")
|
||||
cmd.Dir = repo
|
||||
cmd.Stdin = strings.NewReader(payload)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git mktag %s error = %v\noutput:\n%s", version, err, output)
|
||||
}
|
||||
mustRun(t, repo, "git", "update-ref", "refs/tags/"+version, strings.TrimSpace(string(output)))
|
||||
}
|
||||
rawTag("v1.0.7", "Release v1.0.7\n\nOSS-Mirror: \n")
|
||||
rawTag("v1.0.8", "Release v1.0.8\r\n\r\nOSS-Mirror: deferred\r\n")
|
||||
|
||||
for _, test := range []struct {
|
||||
version string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{version: "v1.0.1", want: "enabled", ok: true},
|
||||
{version: "v1.0.2", want: "enabled", ok: true},
|
||||
{version: "v1.0.3", want: "deferred", ok: true},
|
||||
{version: "v1.0.4", want: "invalid OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.5", want: "duplicate OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.6", want: "must be annotated", ok: false},
|
||||
{version: "v1.0.7", want: "invalid OSS-Mirror metadata", ok: false},
|
||||
{version: "v1.0.8", want: "deferred", ok: true},
|
||||
} {
|
||||
t.Run(test.version, func(t *testing.T) {
|
||||
cmd := exec.Command(scriptPath, test.version)
|
||||
cmd.Dir = repo
|
||||
output, err := cmd.CombinedOutput()
|
||||
if test.ok && err != nil {
|
||||
t.Fatalf("release-tag-oss-mode.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !test.ok && err == nil {
|
||||
t.Fatalf("release-tag-oss-mode.sh unexpectedly accepted %s", test.version)
|
||||
}
|
||||
if !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("release-tag-oss-mode.sh output missing %q:\n%s", test.want, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseDefersOSSRequirementUntilImmutablePolicyResolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(withdraw script) error = %v", err)
|
||||
}
|
||||
cmd := exec.Command("bash", scriptPath,
|
||||
"v1.2.3",
|
||||
"critical startup regression",
|
||||
"WITHDRAW v1.2.3",
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GITHUB_TOKEN=test-github-token",
|
||||
"NODE_AUTH_TOKEN=test-npm-token",
|
||||
"GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"GITHUB_REF_NAME=main",
|
||||
"GITHUB_SHA="+strings.Repeat("a", 40),
|
||||
"GITHUB_RUN_ID=1",
|
||||
"GITHUB_ACTOR=test-operator",
|
||||
"GITHUB_EVENT_DEFAULT_BRANCH=main",
|
||||
"GITHUB_ACTIONS=false",
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("withdraw-release.sh unexpectedly ran outside GitHub Actions:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "withdrawal may run only inside GitHub Actions") {
|
||||
t.Fatalf("withdrawal did not reach the protected execution gate before resolving OSS policy:\n%s", output)
|
||||
}
|
||||
if strings.Contains(string(output), "missing required environment variable OSS_") {
|
||||
t.Fatalf("withdrawal required OSS credentials before resolving immutable tag policy:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseRejectsInvalidInputsBeforeMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(withdraw script) error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
reason string
|
||||
confirmation string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid version",
|
||||
version: "1.2.3",
|
||||
reason: "critical startup regression",
|
||||
confirmation: "WITHDRAW 1.2.3",
|
||||
want: "version must be exactly",
|
||||
},
|
||||
{
|
||||
name: "wrong confirmation",
|
||||
version: "v1.2.3",
|
||||
reason: "critical startup regression",
|
||||
confirmation: "v1.2.3",
|
||||
want: "confirmation must be exactly: WITHDRAW v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "multiline reason",
|
||||
version: "v1.2.3",
|
||||
reason: "critical\nregression",
|
||||
confirmation: "WITHDRAW v1.2.3",
|
||||
want: "reason must be a trimmed, printable single line",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := exec.Command("bash", scriptPath, test.version, test.reason, test.confirmation)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("withdraw-release.sh unexpectedly accepted invalid input:\n%s", string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), test.want) {
|
||||
t.Fatalf("withdraw-release.sh output missing %q:\n%s", test.want, string(output))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ossDeferred bool
|
||||
}{
|
||||
{name: "legacy tag defaults OSS to enabled"},
|
||||
{name: "deferred OSS survives tombstone retry", ossDeferred: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t, test.ossDeferred)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T, ossDeferred bool) {
|
||||
root := t.TempDir()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(source root) error = %v", err)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"scripts/release/withdraw-release.sh",
|
||||
"scripts/release/release-tag-oss-mode.sh",
|
||||
"scripts/release/release-lib.sh",
|
||||
"build/homebrew-release.rb.tmpl",
|
||||
} {
|
||||
copyTestFile(t, filepath.Join(sourceRoot, rel), filepath.Join(root, rel), 0o755)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-delivery"), []byte(`#!/bin/sh
|
||||
printf 'delivery %s %s\n' "$1" "$2" >> "$CALL_LOG"
|
||||
exit 0
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "download-release-assets"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
version="$1"
|
||||
dist="$2"
|
||||
mkdir -p "$dist"
|
||||
for asset in \
|
||||
dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip dws-windows-arm64.zip \
|
||||
dws-skills.zip; do
|
||||
printf '%s %s\n' "$version" "$asset" > "$dist/$asset"
|
||||
done
|
||||
{
|
||||
for asset in \
|
||||
dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip dws-windows-arm64.zip \
|
||||
dws-skills.zip; do
|
||||
printf '%064d %s\n' 0 "$asset"
|
||||
done
|
||||
} > "$dist/checksums.txt"
|
||||
printf 'download-assets %s\n' "$version" >> "$CALL_LOG"
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-release-assets"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
test -f "$DWS_PACKAGE_DIST_DIR/checksums.txt"
|
||||
printf 'verify-assets %s\n' "$1" >> "$CALL_LOG"
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "sync-gitee"), []byte(`#!/bin/sh
|
||||
set -eu
|
||||
git push "$GITEE_GIT_REMOTE" "refs/tags/$VERSION:refs/tags/$VERSION" >/dev/null
|
||||
printf '%s\n' "$VERSION" > "$MOCK_STATE/gitee-release-$VERSION"
|
||||
printf 'gitee-sync %s\n' "$VERSION" >> "$CALL_LOG"
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "publish-homebrew-formula.sh"), []byte(`#!/bin/sh
|
||||
cp "$DWS_FORMULA_SOURCE" "$MOCK_STATE/homebrew-formula"
|
||||
printf 'homebrew-pr %s %s\n' "$DWS_TAP_PR_TITLE" "$DWS_TAP_PR_BRANCH" >> "$CALL_LOG"
|
||||
exit 0
|
||||
`), 0o755)
|
||||
mustWriteFile(t, filepath.Join(root, "scripts", "release", "unexpected-ossutil"), []byte(`#!/bin/sh
|
||||
printf 'unexpected-oss-call\n' >> "$CALL_LOG"
|
||||
exit 99
|
||||
`), 0o755)
|
||||
|
||||
stateDir := filepath.Join(root, "state")
|
||||
fakeBin := filepath.Join(root, "bin")
|
||||
if err := os.MkdirAll(stateDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(state) error = %v", err)
|
||||
}
|
||||
callLog := filepath.Join(stateDir, "calls.log")
|
||||
writeWithdrawMocks(t, fakeBin)
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula
|
||||
version "1.0.51"
|
||||
end
|
||||
`), 0o644)
|
||||
mustRun(t, root, "git", "init", "-b", "main")
|
||||
mustRun(t, root, "git", "config", "user.name", "Withdrawal Test")
|
||||
mustRun(t, root, "git", "config", "user.email", "withdrawal@example.com")
|
||||
mustRun(t, root, "git", "add", ".")
|
||||
mustRun(t, root, "git", "commit", "-m", "candidate")
|
||||
mustRun(t, root, "git", "tag", "-a", "v1.0.51", "-m", "Release v1.0.51")
|
||||
|
||||
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula
|
||||
version "1.0.52"
|
||||
end
|
||||
`), 0o644)
|
||||
mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb")
|
||||
mustRun(t, root, "git", "commit", "-m", "target")
|
||||
tagArgs := []string{"tag", "-a", "v1.0.52", "-m", "Release v1.0.52"}
|
||||
if ossDeferred {
|
||||
tagArgs = append(tagArgs, "-m", "OSS-Mirror: deferred")
|
||||
}
|
||||
mustRun(t, root, "git", tagArgs...)
|
||||
targetCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
|
||||
targetTagObject := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "refs/tags/v1.0.52"))
|
||||
|
||||
origin := filepath.Join(root, "origin.git")
|
||||
gitee := filepath.Join(root, "gitee.git")
|
||||
mustRun(t, root, "git", "init", "--bare", origin)
|
||||
mustRun(t, root, "git", "init", "--bare", gitee)
|
||||
mustRun(t, root, "git", "remote", "add", "origin", origin)
|
||||
mustRun(t, root, "git", "push", "origin", "main", "v1.0.51", "v1.0.52")
|
||||
mustRun(t, root, "git", "push", gitee, "v1.0.52")
|
||||
|
||||
writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.51.json"), map[string]any{
|
||||
"id": 51, "tag_name": "v1.0.51", "name": "v1.0.51", "body": "candidate",
|
||||
"draft": false, "prerelease": false, "immutable": true,
|
||||
})
|
||||
writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.52.json"), map[string]any{
|
||||
"id": 52, "tag_name": "v1.0.52", "name": "v1.0.52", "body": "target",
|
||||
"draft": false, "prerelease": false, "immutable": true,
|
||||
})
|
||||
mustWriteFile(t, filepath.Join(stateDir, "latest"), []byte("v1.0.52\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(stateDir, "npm-latest"), []byte("1.0.52\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(stateDir, "oss-latest"), []byte("v1.0.52\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(stateDir, "gitee-release-v1.0.52"), []byte("752\n"), 0o644)
|
||||
mustWriteFile(t, filepath.Join(stateDir, "github-tag-v1.0.52"), []byte(targetTagObject+"\n"), 0o644)
|
||||
|
||||
scriptPath := filepath.Join(root, "scripts", "release", "withdraw-release.sh")
|
||||
cmd := exec.Command("bash", scriptPath,
|
||||
"v1.0.52",
|
||||
"critical startup regression",
|
||||
"WITHDRAW v1.0.52",
|
||||
)
|
||||
cmd.Dir = root
|
||||
withdrawEnv := append(os.Environ(),
|
||||
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"MOCK_STATE="+stateDir,
|
||||
"CALL_LOG="+callLog,
|
||||
"GITHUB_ACTIONS=true",
|
||||
"GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"GITHUB_REF_NAME=main",
|
||||
"GITHUB_SHA="+targetCommit,
|
||||
"GITHUB_RUN_ID=12345",
|
||||
"GITHUB_ACTOR=release-operator",
|
||||
"GITHUB_EVENT_DEFAULT_BRANCH=main",
|
||||
"GITHUB_TOKEN=github-token",
|
||||
"NODE_AUTH_TOKEN=npm-token",
|
||||
"DWS_GITEE_ENABLED=true",
|
||||
"GITEE_TOKEN=gitee-token",
|
||||
"GITEE_USER=gitee-user",
|
||||
"GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli",
|
||||
"GITEE_GIT_REMOTE="+gitee,
|
||||
"GITEE_PUBLIC_GIT_REMOTE="+gitee,
|
||||
"HOMEBREW_PR_TOKEN=homebrew-token",
|
||||
"DWS_DELIVERY_VERIFIER="+filepath.Join(root, "scripts", "release", "verify-delivery"),
|
||||
"DWS_GITHUB_DOWNLOAD_HELPER="+filepath.Join(root, "scripts", "release", "download-release-assets"),
|
||||
"DWS_ARTIFACT_VERIFY_HELPER="+filepath.Join(root, "scripts", "release", "verify-release-assets"),
|
||||
"DWS_GITEE_SYNC_HELPER="+filepath.Join(root, "scripts", "release", "sync-gitee"),
|
||||
"ORIGIN_GIT="+origin,
|
||||
)
|
||||
if ossDeferred {
|
||||
withdrawEnv = append(withdrawEnv,
|
||||
"OSS_ACCESS_KEY_ID=",
|
||||
"OSS_ACCESS_KEY_SECRET=",
|
||||
"OSS_ENDPOINT=",
|
||||
"OSS_BUCKET=",
|
||||
"OSSUTIL="+filepath.Join(root, "scripts", "release", "unexpected-ossutil"),
|
||||
)
|
||||
} else {
|
||||
withdrawEnv = append(withdrawEnv,
|
||||
"OSS_ACCESS_KEY_ID=oss-id",
|
||||
"OSS_ACCESS_KEY_SECRET=oss-secret",
|
||||
"OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"OSS_BUCKET=dws-test",
|
||||
"OSSUTIL="+filepath.Join(fakeBin, "ossutil"),
|
||||
)
|
||||
}
|
||||
cmd.Env = withdrawEnv
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("withdrawal must remain failed while Homebrew PR is pending:\n%s", string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), "Homebrew rollback PR requires independent review and merge") {
|
||||
t.Fatalf("withdrawal did not report the Homebrew manual gate:\n%s", string(output))
|
||||
}
|
||||
|
||||
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
|
||||
assertFileEquals(t, filepath.Join(stateDir, "npm-latest"), "1.0.51")
|
||||
assertFileContains(t, filepath.Join(stateDir, "npm-deprecated"), "WITHDRAWN v1.0.52")
|
||||
if ossDeferred {
|
||||
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.52")
|
||||
} else {
|
||||
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.51")
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "oss-removed")); err != nil {
|
||||
t.Fatalf("OSS withdrawn prefix was not removed: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "gitee-release-v1.0.52")); !os.IsNotExist(err) {
|
||||
t.Fatalf("Gitee release still exists, stat error = %v", err)
|
||||
}
|
||||
if refs := mustOutput(t, root, "git", "ls-remote", gitee, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" {
|
||||
t.Fatalf("Gitee still exposes withdrawn tag:\n%s", refs)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("GitHub problem release still exists while Homebrew review is pending, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) {
|
||||
t.Fatalf("GitHub problem tag still exists while Homebrew review is pending, stat error = %v", err)
|
||||
}
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Commit: "+targetCommit)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Tag-Object: "+targetTagObject)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Release-ID: 52")
|
||||
ossMode := "enabled"
|
||||
if ossDeferred {
|
||||
ossMode = "deferred"
|
||||
}
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "OSS-Mirror: "+ossMode)
|
||||
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Reason: critical startup regression")
|
||||
assertFileContains(t, callLog, "homebrew-pr revert: withdraw v1.0.52 and restore v1.0.51")
|
||||
|
||||
calls, err := os.ReadFile(callLog)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(call log) error = %v", err)
|
||||
}
|
||||
logText := string(calls)
|
||||
tombstoneIndex := strings.Index(logText, "tombstone-ref")
|
||||
githubIndex := strings.Index(logText, "github-withdraw")
|
||||
npmIndex := strings.Index(logText, "npm-deprecate")
|
||||
ossIndex := strings.Index(logText, "oss-remove")
|
||||
if tombstoneIndex < 0 || githubIndex < 0 || npmIndex < 0 || (!ossDeferred && ossIndex < 0) {
|
||||
t.Fatalf("missing mutation audit entries:\n%s", logText)
|
||||
}
|
||||
homebrewIndex := strings.Index(logText, "homebrew-pr")
|
||||
if !(tombstoneIndex < homebrewIndex && homebrewIndex < githubIndex && githubIndex < npmIndex) ||
|
||||
(!ossDeferred && npmIndex >= ossIndex) {
|
||||
t.Fatalf("tombstone was not durable before channel mutations:\n%s", logText)
|
||||
}
|
||||
deleteReleaseIndex := strings.Index(logText, "github-delete-release")
|
||||
deleteTagIndex := strings.Index(logText, "github-delete-tag")
|
||||
channelsCompleteIndex := npmIndex
|
||||
if !ossDeferred {
|
||||
channelsCompleteIndex = ossIndex
|
||||
}
|
||||
if deleteReleaseIndex < 0 || deleteTagIndex < 0 || homebrewIndex < 0 ||
|
||||
!(homebrewIndex < channelsCompleteIndex && channelsCompleteIndex < deleteReleaseIndex && deleteReleaseIndex < deleteTagIndex) {
|
||||
t.Fatalf("GitHub problem release was not removed before the Homebrew review pause:\n%s", logText)
|
||||
}
|
||||
if ossDeferred {
|
||||
if strings.Contains(logText, "unexpected-oss-call") || ossIndex >= 0 {
|
||||
t.Fatalf("deferred withdrawal invoked OSS tooling:\n%s", logText)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"OSS mirroring is disabled; no OSS rollback is required.",
|
||||
"OSS did not contain v1.0.52 because mirroring was disabled",
|
||||
} {
|
||||
if !strings.Contains(string(output), want) {
|
||||
t.Fatalf("deferred withdrawal output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ossDeferred {
|
||||
tombstonePath := filepath.Join(stateDir, "tombstone-message")
|
||||
tombstoneMessage, err := os.ReadFile(tombstonePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(legacy tombstone) error = %v", err)
|
||||
}
|
||||
legacyMessage := strings.Replace(string(tombstoneMessage), "OSS-Mirror: enabled\n", "", 1)
|
||||
if legacyMessage == string(tombstoneMessage) {
|
||||
t.Fatal("could not convert tombstone fixture to the legacy format")
|
||||
}
|
||||
mustWriteFile(t, tombstonePath, []byte(legacyMessage), 0o644)
|
||||
}
|
||||
|
||||
mergedFormula, err := os.ReadFile(filepath.Join(stateDir, "homebrew-formula"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(rendered Homebrew rollback) error = %v", err)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), mergedFormula, 0o644)
|
||||
mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb")
|
||||
mustRun(t, root, "git", "commit", "-m", "merge Homebrew rollback")
|
||||
mustRun(t, root, "git", "push", "origin", "main")
|
||||
retryCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
|
||||
retryEnv := replaceTestEnv(withdrawEnv,
|
||||
"GITHUB_SHA", retryCommit,
|
||||
"GITHUB_RUN_ID", "12346",
|
||||
)
|
||||
retry := exec.Command("bash", scriptPath,
|
||||
"v1.0.52",
|
||||
"critical startup regression",
|
||||
"WITHDRAW v1.0.52",
|
||||
)
|
||||
retry.Dir = root
|
||||
retry.Env = retryEnv
|
||||
retryOutput, retryErr := retry.CombinedOutput()
|
||||
if retryErr != nil {
|
||||
t.Fatalf("withdrawal retry after Homebrew merge error = %v\noutput:\n%s", retryErr, string(retryOutput))
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Resuming withdrawal for v1.0.52 from exact permanent tombstone metadata.",
|
||||
"Permanent tombstone withdrawn/v1.0.52 already exists",
|
||||
"GitHub Release v1.0.52 was already absent.",
|
||||
"GitHub tag v1.0.52 was already absent.",
|
||||
"Withdrawal completed for all configured distribution channels.",
|
||||
"Already-installed clients cannot be remotely downgraded",
|
||||
} {
|
||||
if !strings.Contains(string(retryOutput), want) {
|
||||
t.Fatalf("withdrawal retry output missing %q:\n%s", want, string(retryOutput))
|
||||
}
|
||||
}
|
||||
if ossDeferred && !strings.Contains(string(retryOutput), "OSS mirroring is disabled; no OSS rollback is required.") {
|
||||
t.Fatalf("deferred tombstone retry did not restore the sealed OSS policy:\n%s", retryOutput)
|
||||
}
|
||||
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("GitHub problem release still exists, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) {
|
||||
t.Fatalf("GitHub problem tag still exists, stat error = %v", err)
|
||||
}
|
||||
if refs := mustOutput(t, root, "git", "ls-remote", origin, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" {
|
||||
t.Fatalf("GitHub origin still exposes withdrawn tag:\n%s", refs)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stateDir, "tombstone-ref")); err != nil {
|
||||
t.Fatalf("permanent withdrawal tombstone is missing: %v", err)
|
||||
}
|
||||
assertFileContains(t, callLog, "github-delete-release")
|
||||
assertFileContains(t, callLog, "github-delete-tag")
|
||||
}
|
||||
|
||||
func copyTestFile(t *testing.T, source, destination string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", source, err)
|
||||
}
|
||||
mustWriteFile(t, destination, content, mode)
|
||||
}
|
||||
|
||||
func writeJSONFile(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(%s) error = %v", path, err)
|
||||
}
|
||||
mustWriteFile(t, path, append(content, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func assertFileEquals(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(content)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Fatalf("%s missing %q:\n%s", path, want, string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func replaceTestEnv(env []string, replacements ...string) []string {
|
||||
result := append([]string{}, env...)
|
||||
for index := 0; index < len(replacements); index += 2 {
|
||||
key := replacements[index]
|
||||
value := replacements[index+1]
|
||||
prefix := key + "="
|
||||
replaced := false
|
||||
for envIndex, entry := range result {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
result[envIndex] = prefix + value
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
result = append(result, prefix+value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeWithdrawMocks(t *testing.T, fakeBin string) {
|
||||
t.Helper()
|
||||
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
state = pathlib.Path(os.environ["MOCK_STATE"])
|
||||
log = pathlib.Path(os.environ["CALL_LOG"])
|
||||
args = sys.argv[1:]
|
||||
|
||||
def record(value):
|
||||
with log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(value + "\n")
|
||||
|
||||
def value_after(flag, default=""):
|
||||
try:
|
||||
return args[args.index(flag) + 1]
|
||||
except (ValueError, IndexError):
|
||||
return default
|
||||
|
||||
def not_found():
|
||||
print("gh: Not Found (HTTP 404)", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
if args[:2] == ["release", "download"]:
|
||||
output = pathlib.Path(value_after("--dir"))
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
assets = [
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-skills.zip",
|
||||
]
|
||||
(output / "checksums.txt").write_text(
|
||||
"".join(("a" * 64) + " " + asset + "\n" for asset in assets),
|
||||
encoding="utf-8",
|
||||
)
|
||||
record("github-download-checksums")
|
||||
raise SystemExit(0)
|
||||
|
||||
if not args or args[0] != "api":
|
||||
raise SystemExit("unsupported gh invocation: " + " ".join(args))
|
||||
|
||||
method = value_after("--method", "GET")
|
||||
skip = {"-H", "--method", "-f", "-F", "--input", "--jq"}
|
||||
endpoint = ""
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] in skip:
|
||||
i += 2
|
||||
continue
|
||||
if args[i].startswith("repos/"):
|
||||
endpoint = args[i]
|
||||
break
|
||||
i += 1
|
||||
jq = value_after("--jq")
|
||||
|
||||
if endpoint.endswith("/git/ref/heads/main"):
|
||||
payload = {"object": {"sha": os.environ["GITHUB_SHA"]}}
|
||||
elif "/releases/tags/" in endpoint:
|
||||
version = endpoint.rsplit("/", 1)[1]
|
||||
path = state / f"release-{version}.json"
|
||||
if not path.exists():
|
||||
not_found()
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
elif endpoint.endswith("/releases/latest"):
|
||||
payload = {"tag_name": (state / "latest").read_text(encoding="utf-8").strip()}
|
||||
elif "/git/ref/tags/withdrawn/" in endpoint:
|
||||
ref_path = state / "tombstone-ref"
|
||||
if not ref_path.exists():
|
||||
not_found()
|
||||
payload = {"object": {"sha": ref_path.read_text(encoding="utf-8").strip()}}
|
||||
elif endpoint.endswith("/git/ref/tags/v1.0.52") and method == "GET":
|
||||
tag_path = state / "github-tag-v1.0.52"
|
||||
if not tag_path.exists():
|
||||
not_found()
|
||||
payload = {"object": {"sha": tag_path.read_text(encoding="utf-8").strip()}}
|
||||
elif "/git/tags/" in endpoint and method == "GET":
|
||||
payload = {
|
||||
"tag": "withdrawn/v1.0.52",
|
||||
"message": (state / "tombstone-message").read_text(encoding="utf-8"),
|
||||
"object": {
|
||||
"type": "commit",
|
||||
"sha": (state / "tombstone-target").read_text(encoding="utf-8").strip(),
|
||||
},
|
||||
}
|
||||
elif endpoint.endswith("/git/tags") and method == "POST":
|
||||
fields = {}
|
||||
for index, arg in enumerate(args):
|
||||
if arg == "-f":
|
||||
key, value = args[index + 1].split("=", 1)
|
||||
fields[key] = value
|
||||
(state / "tombstone-message").write_text(fields["message"], encoding="utf-8")
|
||||
(state / "tombstone-target").write_text(fields["object"] + "\n", encoding="utf-8")
|
||||
payload = {"sha": "a" * 40}
|
||||
record("tombstone-object")
|
||||
elif endpoint.endswith("/git/refs") and method == "POST":
|
||||
(state / "tombstone-ref").write_text("a" * 40 + "\n", encoding="utf-8")
|
||||
payload = {"ref": "refs/tags/withdrawn/v1.0.52", "object": {"sha": "a" * 40}}
|
||||
record("tombstone-ref")
|
||||
elif endpoint.endswith("/releases/52") and method == "DELETE":
|
||||
release_path = state / "release-v1.0.52.json"
|
||||
if not release_path.exists():
|
||||
not_found()
|
||||
release_path.unlink()
|
||||
(state / "latest").write_text("v1.0.51\n", encoding="utf-8")
|
||||
payload = {}
|
||||
record("github-delete-release")
|
||||
elif endpoint.endswith("/git/refs/tags/v1.0.52") and method == "DELETE":
|
||||
tag_path = state / "github-tag-v1.0.52"
|
||||
if not tag_path.exists():
|
||||
not_found()
|
||||
tag_path.unlink()
|
||||
subprocess.run(
|
||||
["git", f"--git-dir={os.environ['ORIGIN_GIT']}", "update-ref", "-d", "refs/tags/v1.0.52"],
|
||||
check=True,
|
||||
)
|
||||
payload = {}
|
||||
record("github-delete-tag")
|
||||
elif "/releases/" in endpoint and method == "PATCH":
|
||||
release_id = endpoint.rsplit("/", 1)[1]
|
||||
if release_id == "52":
|
||||
path = state / "release-v1.0.52.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
input_path = value_after("--input")
|
||||
patch = json.loads(pathlib.Path(input_path).read_text(encoding="utf-8"))
|
||||
data.update({"name": patch["name"], "body": patch["body"]})
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
record("github-withdraw")
|
||||
payload = {}
|
||||
else:
|
||||
raise SystemExit("unsupported gh api endpoint: " + endpoint + " " + method)
|
||||
|
||||
if jq == ".object.sha":
|
||||
print(payload["object"]["sha"])
|
||||
elif jq == ".tag_name":
|
||||
print(payload["tag_name"])
|
||||
elif jq == ".sha":
|
||||
print(payload["sha"])
|
||||
else:
|
||||
print(json.dumps(payload))
|
||||
`), 0o755)
|
||||
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/usr/bin/env python3
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
state = pathlib.Path(os.environ["MOCK_STATE"])
|
||||
log = pathlib.Path(os.environ["CALL_LOG"])
|
||||
args = sys.argv[1:]
|
||||
|
||||
def record(value):
|
||||
with log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(value + "\n")
|
||||
|
||||
if args[0] == "view":
|
||||
spec = args[1]
|
||||
field = args[2]
|
||||
if field == "version":
|
||||
print(spec.rsplit("@", 1)[1])
|
||||
elif field == "deprecated":
|
||||
path = state / "npm-deprecated"
|
||||
if path.exists() and spec.endswith("@1.0.52"):
|
||||
print(path.read_text(encoding="utf-8").strip())
|
||||
elif field == "dist-tags.latest":
|
||||
print((state / "npm-latest").read_text(encoding="utf-8").strip())
|
||||
else:
|
||||
raise SystemExit("unsupported npm view field: " + field)
|
||||
elif args[0] == "deprecate":
|
||||
(state / "npm-deprecated").write_text(args[2] + "\n", encoding="utf-8")
|
||||
record("npm-deprecate")
|
||||
elif args[:2] == ["dist-tag", "add"]:
|
||||
version = args[2].rsplit("@", 1)[1]
|
||||
(state / "npm-latest").write_text(version + "\n", encoding="utf-8")
|
||||
record("npm-pointer")
|
||||
else:
|
||||
raise SystemExit("unsupported npm invocation: " + " ".join(args))
|
||||
`), 0o755)
|
||||
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "ossutil"), []byte(`#!/usr/bin/env python3
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
state = pathlib.Path(os.environ["MOCK_STATE"])
|
||||
log = pathlib.Path(os.environ["CALL_LOG"])
|
||||
args = sys.argv[1:]
|
||||
|
||||
if args[0] == "cp":
|
||||
source, target = args[-2:]
|
||||
if source.startswith("oss://"):
|
||||
if source.endswith("/latest.txt"):
|
||||
stored = state / "oss-latest"
|
||||
else:
|
||||
stored = state / ("oss-object-" + source.rsplit("/", 1)[1])
|
||||
pathlib.Path(target).write_bytes(stored.read_bytes())
|
||||
else:
|
||||
if target.endswith("/latest.txt"):
|
||||
stored = state / "oss-latest"
|
||||
else:
|
||||
stored = state / ("oss-object-" + target.rsplit("/", 1)[1])
|
||||
stored.write_bytes(pathlib.Path(source).read_bytes())
|
||||
elif args[0] == "rm":
|
||||
(state / "oss-removed").write_text("yes\n", encoding="utf-8")
|
||||
with log.open("a", encoding="utf-8") as handle:
|
||||
handle.write("oss-remove\n")
|
||||
elif args[0] == "ls":
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
raise SystemExit("unsupported ossutil invocation: " + " ".join(args))
|
||||
`), 0o755)
|
||||
|
||||
mustWriteFile(t, filepath.Join(fakeBin, "curl"), []byte(`#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
state = pathlib.Path(os.environ["MOCK_STATE"])
|
||||
log = pathlib.Path(os.environ["CALL_LOG"])
|
||||
args = sys.argv[1:]
|
||||
url = args[-1]
|
||||
version = "v1.0.51" if "v1.0.51" in url else "v1.0.52"
|
||||
release = state / ("gitee-release-" + version)
|
||||
if "-X" in args and args[args.index("-X") + 1] == "DELETE":
|
||||
(state / "gitee-release-v1.0.52").unlink(missing_ok=True)
|
||||
with log.open("a", encoding="utf-8") as handle:
|
||||
handle.write("gitee-delete-release\n")
|
||||
elif "/releases/tags/" in url:
|
||||
output = pathlib.Path(args[args.index("-o") + 1])
|
||||
if release.exists():
|
||||
release_id = 751 if version == "v1.0.51" else 752
|
||||
output.write_text(json.dumps({"id": release_id}), encoding="utf-8")
|
||||
print("200", end="")
|
||||
else:
|
||||
output.write_text('{"message":"Not Found"}', encoding="utf-8")
|
||||
print("404", end="")
|
||||
else:
|
||||
raise SystemExit(22)
|
||||
`), 0o755)
|
||||
}
|
||||
Reference in New Issue
Block a user