diff --git a/.github/workflows/release-sdk-python.yml b/.github/workflows/release-sdk-python.yml new file mode 100644 index 000000000..e64e70ef0 --- /dev/null +++ b/.github/workflows/release-sdk-python.yml @@ -0,0 +1,515 @@ +name: 'Release Python SDK' + +on: + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.0 for stable, v0.1.1-preview.0 for preview).' + required: false + type: 'string' + ref: + description: 'The protected branch ref to release SDK from. This privileged workflow only permits main.' + required: true + type: 'string' + default: 'main' + dry_run: + description: 'Run the release flow without publishing to PyPI or creating persistent release branches/tags.' + required: true + type: 'boolean' + default: true + create_nightly_release: + description: 'Auto-apply the nightly release tag. Input version is ignored.' + required: false + type: 'boolean' + default: false + create_preview_release: + description: 'Auto-apply the preview release tag. Input version is ignored unless explicitly provided.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Skip Python checks and smoke test. Production releases should keep this disabled.' + required: false + type: 'boolean' + default: false + +concurrency: + group: '${{ github.workflow }}-${{ github.event.inputs.create_nightly_release == ''true'' && ''nightly'' || github.event.inputs.create_preview_release == ''true'' && ''preview'' || ''stable'' }}' + cancel-in-progress: false + +jobs: + release-sdk-python: + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: '${{ github.server_url }}/${{ github.repository }}/releases' + if: |- + ${{ github.repository == 'QwenLM/qwen-code' }} + permissions: + contents: 'write' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + + steps: + - name: 'Validate release inputs' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + FORCE_SKIP_TESTS: '${{ github.event.inputs.force_skip_tests }}' + MANUAL_VERSION: '${{ inputs.version }}' + REQUESTED_REF: '${{ github.event.inputs.ref || github.sha }}' + WORKFLOW_REF: '${{ github.ref }}' + run: | + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" && "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + echo "create_nightly_release and create_preview_release cannot both be true" >&2 + exit 1 + fi + + if [[ -n "${MANUAL_VERSION}" ]]; then + if [[ ! "${MANUAL_VERSION}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-preview\.[0-9]+)?$ ]]; then + echo "Invalid version format: ${MANUAL_VERSION}" >&2 + exit 1 + fi + fi + + case "${WORKFLOW_REF}" in + refs/heads/main) + ;; + *) + echo "This privileged workflow must be launched from the protected main workflow branch." >&2 + exit 1 + ;; + esac + + case "${REQUESTED_REF}" in + main|refs/heads/main) + ;; + *) + echo "This privileged workflow must use the protected main ref." >&2 + exit 1 + ;; + esac + + if [[ "${DRY_RUN_INPUT}" != "true" ]]; then + if [[ "${FORCE_SKIP_TESTS}" == "true" ]]; then + echo "force_skip_tests cannot be used when dry_run is false" >&2 + exit 1 + fi + fi + + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + + - name: 'Set booleans for simplified logic' + env: + CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' + CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' + DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' + id: 'vars' + run: | + is_nightly="false" + if [[ "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then + is_nightly="true" + fi + echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}" + + is_preview="false" + if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then + is_preview="true" + fi + echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}" + + is_dry_run="false" + if [[ "${DRY_RUN_INPUT}" == "true" ]]; then + is_dry_run="true" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" + + - name: 'Capture checked-out commit' + id: 'checkout_sha' + run: | + echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Get the version' + id: 'version' + run: | + set -euo pipefail + VERSION_ARGS=() + if [[ "${IS_NIGHTLY}" == "true" ]]; then + VERSION_ARGS+=(--type=nightly) + elif [[ "${IS_PREVIEW}" == "true" ]]; then + VERSION_ARGS+=(--type=preview) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--preview_version_override=${MANUAL_VERSION}") + fi + else + VERSION_ARGS+=(--type=stable) + if [[ -n "${MANUAL_VERSION}" ]]; then + VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}") + fi + fi + + VERSION_JSON=$(node packages/sdk-python/scripts/get-release-version.js "${VERSION_ARGS[@]}") + + extract_field() { + local value + value=$(echo "$VERSION_JSON" | jq -r ".$1") + if [[ "${value}" == "null" ]]; then + echo "Failed to extract $1 from version JSON: ${VERSION_JSON}" >&2 + exit 1 + fi + echo "${value}" + } + + RELEASE_TAG=$(extract_field releaseTag) + RELEASE_VERSION=$(extract_field releaseVersion) + PACKAGE_VERSION=$(extract_field packageVersion) + PUBLISH_CHANNEL=$(extract_field publishChannel) + PREVIOUS_RELEASE_TAG=$(extract_field previousReleaseTag) + RESUME_EXISTING_RELEASE=$(extract_field resumeExistingRelease) + + echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" + echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> "$GITHUB_OUTPUT" + echo "PUBLISH_CHANNEL=${PUBLISH_CHANNEL}" >> "$GITHUB_OUTPUT" + echo "PREVIOUS_RELEASE_TAG=${PREVIOUS_RELEASE_TAG}" >> "$GITHUB_OUTPUT" + echo "RESUME_EXISTING_RELEASE=${RESUME_EXISTING_RELEASE}" >> "$GITHUB_OUTPUT" + + echo "========================================" + echo "Python SDK Release Version Info" + echo "========================================" + echo "Release Tag: ${RELEASE_TAG}" + echo "Release Version: ${RELEASE_VERSION}" + echo "Package Version: ${PACKAGE_VERSION}" + echo "Publish Channel: ${PUBLISH_CHANNEL}" + echo "Previous Release: ${PREVIOUS_RELEASE_TAG}" + echo "Resume Existing: ${RESUME_EXISTING_RELEASE}" + echo "========================================" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + MANUAL_VERSION: '${{ inputs.version }}' + + - name: 'Setup Python' + uses: 'actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065' # ratchet:actions/setup-python@v5 + with: + # Keep in sync with packages/sdk-python/pyproject.toml [project] requires-python. + python-version: '3.11' + + - name: 'Install Dependencies' + run: | + npm ci + python -m pip install --upgrade pip + python -m pip install -e 'packages/sdk-python[dev]' build + + - name: 'Build qwen CLI bundle' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + npm run build + npm run bundle + + - name: 'Set Python package version (local only)' + env: + PACKAGE_VERSION: '${{ steps.version.outputs.PACKAGE_VERSION }}' + run: | + python - <<'PY' + from pathlib import Path + import os + import re + + pyproject = Path('packages/sdk-python/pyproject.toml') + content = pyproject.read_text() + updated, count = re.subn( + r'^version = "[^"]+"$', + f'version = "{os.environ["PACKAGE_VERSION"]}"', + content, + flags=re.MULTILINE, + ) + if count != 1: + raise SystemExit( + f'pyproject.toml version rewrite matched {count} lines, expected 1' + ) + pyproject.write_text(updated) + PY + + - name: 'Run Python quality gates' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + run: | + python -m ruff check --config packages/sdk-python/pyproject.toml packages/sdk-python + python -m ruff format --check --config packages/sdk-python/pyproject.toml packages/sdk-python + python -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src + python -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q + + - name: 'Run real smoke test' + if: |- + ${{ github.event.inputs.force_skip_tests != 'true' }} + env: + OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' + OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' + OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' + run: | + python packages/sdk-python/scripts/smoke_real.py --qwen "${GITHUB_WORKSPACE}/dist/cli.js" --cwd "${GITHUB_WORKSPACE}" --json-only + + - name: 'Configure Git User' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: 'Create and switch to a release branch' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + id: 'release_branch' + env: + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + RESUME_EXISTING_RELEASE: '${{ steps.version.outputs.RESUME_EXISTING_RELEASE }}' + run: | + set -euo pipefail + BRANCH_NAME="release/sdk-python/${RELEASE_TAG}" + VERSIONED_PYPROJECT="$(mktemp)" + cp packages/sdk-python/pyproject.toml "${VERSIONED_PYPROJECT}" + git restore --staged --worktree packages/sdk-python/pyproject.toml + + if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then + git fetch origin "${BRANCH_NAME}" + if git show-ref --verify --quiet "refs/heads/${BRANCH_NAME}"; then + git switch "${BRANCH_NAME}" + else + git switch -c "${BRANCH_NAME}" --track "origin/${BRANCH_NAME}" + fi + git reset --hard "origin/${BRANCH_NAME}" + echo "REMOTE_BRANCH_EXISTS=true" >> "${GITHUB_OUTPUT}" + else + if [[ "${RESUME_EXISTING_RELEASE}" == "true" ]]; then + echo "Published version ${RELEASE_TAG} exists without a persisted release branch ${BRANCH_NAME}. To resolve: (1) re-trigger with a different version, or (2) manually create branch ${BRANCH_NAME} from the original release commit." >&2 + exit 1 + fi + git switch -c "${BRANCH_NAME}" + echo "REMOTE_BRANCH_EXISTS=false" >> "${GITHUB_OUTPUT}" + fi + + cp "${VERSIONED_PYPROJECT}" packages/sdk-python/pyproject.toml + rm -f "${VERSIONED_PYPROJECT}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + + - name: 'Commit and push package version' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + id: 'persist_source' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + CHECKED_OUT_SHA: '${{ steps.checkout_sha.outputs.sha }}' + REMOTE_BRANCH_EXISTS: '${{ steps.release_branch.outputs.REMOTE_BRANCH_EXISTS }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: | + set -euo pipefail + git add packages/sdk-python/pyproject.toml + if git diff --staged --quiet; then + echo "No version changes to commit" + if [[ "${REMOTE_BRANCH_EXISTS}" == "true" ]]; then + echo "HAS_PERSISTED_SOURCE=true" >> "${GITHUB_OUTPUT}" + echo "RELEASE_TARGET_SHA=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + else + echo "HAS_PERSISTED_SOURCE=false" >> "${GITHUB_OUTPUT}" + echo "RELEASE_TARGET_SHA=${CHECKED_OUT_SHA}" >> "${GITHUB_OUTPUT}" + fi + else + git commit -m "chore(release): sdk-python ${RELEASE_TAG}" + echo "HAS_PERSISTED_SOURCE=true" >> "${GITHUB_OUTPUT}" + echo "RELEASE_TARGET_SHA=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + if [[ "${REMOTE_BRANCH_EXISTS}" == "true" ]]; then + git push origin "${BRANCH_NAME}" + else + git push --set-upstream origin "${BRANCH_NAME}" + fi + fi + + - name: 'Build Python package' + working-directory: 'packages/sdk-python' + run: | + rm -rf dist + python -m build + + - name: 'Publish qwen-code-sdk to PyPI' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + uses: 'pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b' # ratchet:pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: 'packages/sdk-python/dist' + # skip-existing handles resumed releases where some artifacts were already uploaded + skip-existing: '${{ steps.version.outputs.RESUME_EXISTING_RELEASE }}' + + - name: 'Show publish artifacts for dry-run' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'true' }} + run: | + ls -la packages/sdk-python/dist + + - name: 'Create GitHub release and tag' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + PACKAGE_VERSION: '${{ steps.version.outputs.PACKAGE_VERSION }}' + PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' + RELEASE_TARGET_SHA: '${{ steps.persist_source.outputs.RELEASE_TARGET_SHA }}' + IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' + IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' + run: | + set -euo pipefail + TAG_NAME="sdk-python-${RELEASE_TAG}" + + if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + else + PRERELEASE_FLAG="" + fi + + if gh release view "${TAG_NAME}" --json tagName >/dev/null 2>&1; then + echo "::warning::GitHub release ${TAG_NAME} already exists; skipping create." + exit 0 + fi + + if git rev-parse "${TAG_NAME}" >/dev/null 2>&1; then + EXISTING_TAG_SHA="$(git rev-list -n 1 "${TAG_NAME}")" + if [[ "${EXISTING_TAG_SHA}" != "${RELEASE_TARGET_SHA}" ]]; then + echo "Existing tag ${TAG_NAME} points to ${EXISTING_TAG_SHA}, expected ${RELEASE_TARGET_SHA}." >&2 + exit 1 + fi + fi + + NOTES_FILE=$(mktemp) + { + echo "## Published Package" + echo "" + echo "- PyPI package: \`qwen-code-sdk\`" + echo "- Package version: \`${PACKAGE_VERSION}\`" + echo "" + echo "---" + echo "" + } > "${NOTES_FILE}" + + if [[ -n "${PREVIOUS_RELEASE_TAG}" ]]; then + PREVIOUS_NOTES=$(gh release view "sdk-python-${PREVIOUS_RELEASE_TAG}" --json body -q '.body' 2>&1) || { + ERR_MSG="${PREVIOUS_NOTES}" + case "${ERR_MSG}" in + *"release not found"*|*"Not Found"*|*"HTTP 404"*) + PREVIOUS_NOTES='See commit history for changes.' + ;; + *) + echo "::warning::Failed to fetch previous release notes: ${ERR_MSG}" + PREVIOUS_NOTES='See commit history for changes.' + ;; + esac + } + printf '%s\n' "${PREVIOUS_NOTES}" >> "${NOTES_FILE}" + else + echo "See commit history for changes." >> "${NOTES_FILE}" + fi + + gh release create "${TAG_NAME}" \ + --target "${RELEASE_TARGET_SHA}" \ + --title "SDK Python Release ${RELEASE_TAG}" \ + --notes-file "${NOTES_FILE}" \ + ${PRERELEASE_FLAG} + + rm -f "${NOTES_FILE}" + + - name: 'Delete prerelease release branch' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' && (steps.vars.outputs.is_nightly == 'true' || steps.vars.outputs.is_preview == 'true') }} + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then + if ! git push origin --delete "${BRANCH_NAME}"; then + echo "::warning::Failed to delete prerelease release branch ${BRANCH_NAME}; release already exists, so remove the branch manually if it still exists." + fi + else + echo "No prerelease release branch to delete for ${BRANCH_NAME}." + fi + + - name: 'Create PR to merge release branch into main' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }} + id: 'pr' + env: + GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' + RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}' + run: | + set -euo pipefail + + pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')" + if [[ -z "${pr_url}" ]]; then + pr_url="$(gh pr create \ + --base main \ + --head "${RELEASE_BRANCH}" \ + --title "chore(release): sdk-python ${RELEASE_TAG}" \ + --body "Automated release PR for sdk-python ${RELEASE_TAG}.")" + fi + + echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}" + + - name: 'Enable auto-merge for release PR' + if: |- + ${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' && steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true' }} + env: + GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' + PR_URL: '${{ steps.pr.outputs.PR_URL }}' + run: | + set -euo pipefail + gh pr merge "${PR_URL}" --merge --auto --delete-branch + + - name: 'Create issue on failure' + if: |- + ${{ failure() && github.event.inputs.dry_run != 'true' }} + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: "${{ steps.version.outputs.RELEASE_TAG || 'N/A' }}" + PACKAGE_VERSION: "${{ steps.version.outputs.PACKAGE_VERSION || 'N/A' }}" + PUBLISH_CHANNEL: "${{ steps.version.outputs.PUBLISH_CHANNEL || 'N/A' }}" + RESUME_EXISTING_RELEASE: "${{ steps.version.outputs.RESUME_EXISTING_RELEASE || 'N/A' }}" + BRANCH_NAME: "${{ steps.release_branch.outputs.BRANCH_NAME || 'N/A' }}" + HAS_PERSISTED_SOURCE: "${{ steps.persist_source.outputs.HAS_PERSISTED_SOURCE || 'N/A' }}" + RELEASE_TARGET_SHA: "${{ steps.persist_source.outputs.RELEASE_TARGET_SHA || 'N/A' }}" + PR_URL: "${{ steps.pr.outputs.PR_URL || 'N/A' }}" + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + set -euo pipefail + BODY=$(cat <=18" }, @@ -734,6 +736,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2175,6 +2178,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3597,6 +3601,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4068,6 +4073,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4078,6 +4084,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4283,6 +4290,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4528,6 +4536,7 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -4678,6 +4687,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4851,6 +4861,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5265,8 +5276,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -5814,6 +5824,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6474,7 +6485,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7552,6 +7562,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8256,7 +8267,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8318,7 +8328,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8328,7 +8337,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8338,7 +8346,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -8546,7 +8553,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8565,7 +8571,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8574,15 +8579,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9639,6 +9642,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10616,6 +10620,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11495,7 +11500,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12678,8 +12682,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -12842,7 +12845,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -12877,6 +12879,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13036,6 +13039,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13351,6 +13355,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13361,6 +13366,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13438,6 +13444,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14621,6 +14628,7 @@ "integrity": "sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -15309,6 +15317,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15508,7 +15517,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15516,6 +15526,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15674,6 +15685,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15997,7 +16009,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16040,6 +16051,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16153,6 +16165,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16166,6 +16179,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16684,6 +16698,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16854,6 +16869,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17024,6 +17040,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -17682,6 +17699,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -18076,6 +18094,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18838,6 +18857,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -19318,6 +19338,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20444,6 +20465,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21680,6 +21702,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -22653,6 +22676,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22667,6 +22691,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 445d714da..8a1477425 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "typecheck": "npm run typecheck --workspaces --if-present", "typecheck:sdk:python": "python3 -m mypy --config-file packages/sdk-python/pyproject.toml packages/sdk-python/src", "smoke:sdk:python": "python3 packages/sdk-python/scripts/smoke_real.py", + "build:sdk:python": "python3 -m build packages/sdk-python", "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run build && npm run bundle", diff --git a/packages/sdk-python/scripts/get-release-version.js b/packages/sdk-python/scripts/get-release-version.js new file mode 100644 index 000000000..8f3467805 --- /dev/null +++ b/packages/sdk-python/scripts/get-release-version.js @@ -0,0 +1,569 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PACKAGE_NAME = 'qwen-code-sdk'; +const TAG_PREFIX = 'sdk-python-'; + +function readPyprojectVersion() { + const pyprojectPath = join(__dirname, '..', 'pyproject.toml'); + const content = readFileSync(pyprojectPath, 'utf8'); + const match = content.match(/^version = "([^"]+)"$/m); + if (!match) { + throw new Error(`Could not find version in ${pyprojectPath}`); + } + return match[1]; +} + +function getArgs() { + const args = {}; + for (const arg of process.argv.slice(2)) { + if (!arg.startsWith('--')) { + continue; + } + const [key, value] = arg.slice(2).split('='); + args[key] = value === undefined ? true : value; + } + return args; +} + +function parseVersion(version) { + let match = version.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (match) { + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + stage: 'stable', + stageNumber: 0, + raw: version, + }; + } + + match = version.match(/^(\d+)\.(\d+)\.(\d+)rc(\d+)$/); + if (match) { + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + stage: 'preview', + stageNumber: Number(match[4]), + raw: version, + }; + } + + match = version.match(/^(\d+)\.(\d+)\.(\d+)\.dev(\d+)$/); + if (match) { + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + stage: 'nightly', + stageNumber: Number(match[4]), + raw: version, + }; + } + + return null; +} + +function compareVersions(a, b) { + const parsedA = parseVersion(a); + const parsedB = parseVersion(b); + if (!parsedA || !parsedB) { + throw new Error(`Cannot compare unsupported versions: ${a}, ${b}`); + } + + if (parsedA.major !== parsedB.major) { + return parsedA.major - parsedB.major; + } + if (parsedA.minor !== parsedB.minor) { + return parsedA.minor - parsedB.minor; + } + if (parsedA.patch !== parsedB.patch) { + return parsedA.patch - parsedB.patch; + } + + const stageOrder = { + nightly: 0, + preview: 1, + stable: 2, + }; + + if (stageOrder[parsedA.stage] !== stageOrder[parsedB.stage]) { + return stageOrder[parsedA.stage] - stageOrder[parsedB.stage]; + } + + return parsedA.stageNumber - parsedB.stageNumber; +} + +function sortDescending(versions) { + return [...versions].sort((a, b) => compareVersions(b, a)); +} + +function toBaseVersion(version) { + const parsed = parseVersion(version); + if (!parsed) { + throw new Error(`Unsupported version format: ${version}`); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; +} + +async function getAllVersionsFromPyPI() { + const response = await fetch(`https://pypi.org/pypi/${PACKAGE_NAME}/json`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }); + + if (response.status === 404) { + return { versions: [], allVersions: [] }; + } + + if (!response.ok) { + throw new Error( + `Failed to fetch PyPI metadata: ${response.status} ${response.statusText}`, + ); + } + + const payload = await response.json(); + const releases = payload.releases ?? {}; + const allVersions = Object.keys(releases).filter( + (version) => parseVersion(version) !== null, + ); + // Yanked versions still occupy PyPI slots (re-upload fails), so allVersions + // includes them for conflict detection. The filtered list excludes yanked + // versions so base-version computation uses only live releases. + const versions = allVersions.filter((version) => { + const files = releases[version]; + if (Array.isArray(files) && files.length > 0) { + return !files.every((file) => file.yanked === true); + } + return true; + }); + return { versions, allVersions }; +} + +function getCurrentPackageBaseVersion() { + return toBaseVersion(readPyprojectVersion()); +} + +function getLatestStableVersion(versions) { + const stableVersions = versions.filter( + (version) => parseVersion(version)?.stage === 'stable', + ); + + if (stableVersions.length === 0) { + return ''; + } + + return sortDescending(stableVersions)[0]; +} + +function getLatestPreviewBaseVersion(versions) { + const previewVersions = versions.filter( + (version) => parseVersion(version)?.stage === 'preview', + ); + + if (previewVersions.length === 0) { + return ''; + } + + return toBaseVersion(sortDescending(previewVersions)[0]); +} + +function getLatestNightlyBaseVersion(versions) { + const nightlyVersions = versions.filter( + (version) => parseVersion(version)?.stage === 'nightly', + ); + + if (nightlyVersions.length === 0) { + return ''; + } + + return toBaseVersion(sortDescending(nightlyVersions)[0]); +} + +function incrementPatchVersion(version) { + const parsed = parseVersion(version); + if (!parsed) { + throw new Error(`Unsupported baseline version: ${version}`); + } + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; +} + +function getNextBaseVersion(versions) { + const stableVersions = versions.filter( + (version) => parseVersion(version)?.stage === 'stable', + ); + const stableBaseline = sortDescending([ + ...stableVersions, + getCurrentPackageBaseVersion(), + ])[0]; + const latestPrereleaseBase = sortDescending( + [ + getLatestPreviewBaseVersion(versions), + getLatestNightlyBaseVersion(versions), + ].filter(Boolean), + )[0]; + + if ( + latestPrereleaseBase && + compareVersions(latestPrereleaseBase, stableBaseline) >= 0 + ) { + return latestPrereleaseBase; + } + + // On first release (no stable versions on PyPI), use the pyproject.toml + // version directly instead of incrementing it. + if (stableVersions.length === 0) { + return stableBaseline; + } + + return incrementPatchVersion(stableBaseline); +} + +function getUtcTimestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return [ + now.getUTCFullYear(), + pad(now.getUTCMonth() + 1), + pad(now.getUTCDate()), + pad(now.getUTCHours()), + pad(now.getUTCMinutes()), + pad(now.getUTCSeconds()), + ].join(''); +} + +function getGitShortHash() { + return execSync('git rev-parse --short HEAD').toString().trim(); +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format]?.test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function isExpectedMissingGitHubRelease(error) { + const stderr = error.stderr?.toString() ?? ''; + const stdout = error.stdout?.toString() ?? ''; + const message = `${error.message}\n${stderr}\n${stdout}`; + return message.includes('release not found') || message.includes('Not Found'); +} + +async function getReleaseState({ packageVersion, releaseTag }, allVersions) { + const state = { + packageVersionExistsOnPyPI: allVersions.includes(packageVersion), + gitTagExists: false, + githubReleaseExists: false, + }; + const fullTag = `${TAG_PREFIX}${releaseTag}`; + try { + const tagOutput = execSync(`git tag -l '${fullTag}'`).toString().trim(); + if (tagOutput === fullTag) { + state.gitTagExists = true; + } + } catch (error) { + throw new Error(`Failed to check git tags for conflicts: ${error.message}`); + } + + try { + const output = execSync( + `gh release view "${fullTag}" --json tagName --jq .tagName`, + ) + .toString() + .trim(); + if (output === fullTag) { + state.githubReleaseExists = true; + } + } catch (error) { + if (!isExpectedMissingGitHubRelease(error)) { + throw new Error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return state; +} + +function getNightlyVersion(versions) { + const baseVersion = getNextBaseVersion(versions); + const timestamp = getUtcTimestamp(); + const gitShortHash = getGitShortHash(); + + return { + releaseVersion: `${baseVersion}-nightly.${timestamp}.${gitShortHash}`, + packageVersion: `${baseVersion}.dev${timestamp}`, + publishChannel: 'nightly', + }; +} + +function getPreviewVersion(args, versions) { + if (args.preview_version_override) { + const overrideVersion = args.preview_version_override.replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + const match = overrideVersion.match(/^(\d+\.\d+\.\d+)-preview\.(\d+)$/); + if (!match) { + throw new Error(`Invalid preview override: ${overrideVersion}`); + } + return { + releaseVersion: overrideVersion, + packageVersion: `${match[1]}rc${match[2]}`, + publishChannel: 'preview', + }; + } + + const baseVersion = getNextBaseVersion(versions); + return { + releaseVersion: `${baseVersion}-preview.0`, + packageVersion: `${baseVersion}rc0`, + publishChannel: 'preview', + }; +} + +function getStableVersion(args, versions) { + if (args.stable_version_override) { + const overrideVersion = args.stable_version_override.replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + const latestStable = getLatestStableVersion(versions); + if (latestStable && compareVersions(overrideVersion, latestStable) < 0) { + throw new Error( + `stable_version_override ${overrideVersion} is older than latest stable ${latestStable}. ` + + `Publishing an older stable version is unusual — provide a newer version or contact a maintainer.`, + ); + } + return { + releaseVersion: overrideVersion, + packageVersion: overrideVersion, + publishChannel: 'latest', + }; + } + + const latestPrerelease = [ + { baseVersion: getLatestPreviewBaseVersion(versions), source: 'preview' }, + { baseVersion: getLatestNightlyBaseVersion(versions), source: 'nightly' }, + ] + .filter(({ baseVersion }) => Boolean(baseVersion)) + .sort((a, b) => compareVersions(b.baseVersion, a.baseVersion))[0]; + const latestStable = getLatestStableVersion(versions); + + if (latestPrerelease) { + if (latestPrerelease.source !== 'preview') { + console.error( + `::warning::Stable release ${latestPrerelease.baseVersion} derived from ${latestPrerelease.source} (no preview release found with this base version).`, + ); + } + if ( + latestStable && + compareVersions(latestPrerelease.baseVersion, latestStable) < 0 + ) { + throw new Error( + `Latest ${latestPrerelease.source} base ${latestPrerelease.baseVersion} is not newer than latest stable ${latestStable}. Provide stable_version_override to continue.`, + ); + } + return { + releaseVersion: latestPrerelease.baseVersion, + packageVersion: latestPrerelease.baseVersion, + publishChannel: 'latest', + source: latestPrerelease.source, + }; + } + + const releaseVersion = getCurrentPackageBaseVersion(); + return { + releaseVersion, + packageVersion: releaseVersion, + publishChannel: 'latest', + source: 'current', + }; +} + +function getConflictSources(releaseState) { + const sources = []; + if (releaseState.packageVersionExistsOnPyPI) { + sources.push('PyPI'); + } + if (releaseState.githubReleaseExists) { + sources.push('GitHub releases'); + } + if (releaseState.gitTagExists) { + sources.push('git tags'); + } + return sources.length > 0 ? sources.join(', ') : 'unknown release state'; +} + +function bumpVersion(versionData) { + const match = versionData.releaseVersion.match( + /^(\d+\.\d+\.\d+)-preview\.(\d+)$/, + ); + if (!match) { + throw new Error( + `Cannot bump preview version: ${versionData.releaseVersion}`, + ); + } + const nextNumber = Number(match[2]) + 1; + return { + ...versionData, + releaseVersion: `${match[1]}-preview.${nextNumber}`, + packageVersion: `${match[1]}rc${nextNumber}`, + }; +} + +async function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args.type || 'nightly'; + const { versions, allVersions } = await getAllVersionsFromPyPI(); + const hasManualOverride = + (type === 'preview' && Boolean(args.preview_version_override)) || + (type === 'stable' && Boolean(args.stable_version_override)); + + let versionData; + let resumeExistingRelease = false; + switch (type) { + case 'nightly': + versionData = getNightlyVersion(versions); + break; + case 'preview': + versionData = getPreviewVersion(args, versions); + break; + case 'stable': + versionData = getStableVersion(args, versions); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + while (true) { + const releaseState = await getReleaseState( + { + packageVersion: versionData.packageVersion, + releaseTag: `v${versionData.releaseVersion}`, + }, + allVersions, + ); + + const versionExists = + releaseState.packageVersionExistsOnPyPI || + releaseState.gitTagExists || + releaseState.githubReleaseExists; + if (!versionExists) { + break; + } + + if ( + !hasManualOverride && + releaseState.packageVersionExistsOnPyPI && + !releaseState.githubReleaseExists + ) { + console.error( + `PyPI version ${versionData.packageVersion} already exists without a matching GitHub release. Reusing the same release version.`, + ); + resumeExistingRelease = true; + break; + } + + if ( + !hasManualOverride && + type === 'stable' && + releaseState.packageVersionExistsOnPyPI && + releaseState.githubReleaseExists + ) { + console.error( + `Stable release ${versionData.releaseVersion} already has a matching GitHub release. Reusing the same release version for post-release recovery.`, + ); + resumeExistingRelease = true; + break; + } + + if (hasManualOverride) { + throw new Error( + `Requested ${type} release ${versionData.releaseVersion} already exists on ${getConflictSources(releaseState)}.`, + ); + } + + if (releaseState.githubReleaseExists) { + console.error( + `GitHub release ${TAG_PREFIX}v${versionData.releaseVersion} already exists.`, + ); + } else if (releaseState.gitTagExists) { + console.error( + `::warning::Orphan git tag ${TAG_PREFIX}v${versionData.releaseVersion} exists without a PyPI version or GitHub release. Skipping to next version slot.`, + ); + } else if (releaseState.packageVersionExistsOnPyPI) { + console.error( + `PyPI version ${versionData.packageVersion} already exists.`, + ); + } + + if (type === 'stable') { + if ( + versionData.source === 'preview' || + versionData.source === 'nightly' + ) { + throw new Error( + `Stable release ${versionData.releaseVersion} derived from the latest ${versionData.source} already exists.`, + ); + } + + throw new Error( + `Stable release ${versionData.releaseVersion} already exists. Provide stable_version_override to release a different stable version.`, + ); + } + + if (type === 'nightly') { + throw new Error( + `Nightly version conflict for ${versionData.packageVersion}`, + ); + } + + versionData = bumpVersion(versionData); + } + + const previousVersion = + type === 'stable' + ? getLatestStableVersion( + versions.filter((v) => v !== versionData.releaseVersion), + ) + : ''; + + return { + releaseTag: `v${versionData.releaseVersion}`, + releaseVersion: versionData.releaseVersion, + packageVersion: versionData.packageVersion, + previousReleaseTag: previousVersion ? `v${previousVersion}` : '', + publishChannel: versionData.publishChannel, + resumeExistingRelease, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const result = await getVersion(getArgs()); + console.log(JSON.stringify(result, null, 2)); +} + +export { getVersion }; diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js index 844ac3009..2852bf52f 100644 --- a/packages/sdk-typescript/scripts/get-release-version.js +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -142,6 +142,15 @@ function detectRollbackAndGetBaseline(npmDistTag) { } function doesVersionExist(version) { + const isExpectedMissingGitHubRelease = (error) => { + const stderr = error.stderr?.toString() ?? ''; + const stdout = error.stdout?.toString() ?? ''; + const message = `${error.message}\n${stderr}\n${stdout}`; + return ( + message.includes('release not found') || message.includes('Not Found') + ); + }; + // Check NPM try { const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`; @@ -168,19 +177,14 @@ function doesVersionExist(version) { // Check GitHub releases try { - const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName 2>/dev/null`; + const command = `gh release view "${TAG_PREFIX}${version}" --json tagName --jq .tagName`; const output = execSync(command).toString().trim(); if (output === `${TAG_PREFIX}${version}`) { console.error(`GitHub release ${TAG_PREFIX}${version} already exists.`); return true; } } catch (error) { - const isExpectedNotFound = - error.message.includes('release not found') || - error.message.includes('Not Found') || - error.message.includes('not found') || - error.status === 1; - if (!isExpectedNotFound) { + if (!isExpectedMissingGitHubRelease(error)) { console.error( `Failed to check GitHub releases for conflicts: ${error.message}`, ); diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 41e84d8fe..d89fddae5 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -129,6 +129,15 @@ function detectRollbackAndGetBaseline(npmDistTag) { } function doesVersionExist(version) { + const isExpectedMissingGitHubRelease = (error) => { + const stderr = error.stderr?.toString() ?? ''; + const stdout = error.stdout?.toString() ?? ''; + const message = `${error.message}\n${stderr}\n${stdout}`; + return ( + message.includes('release not found') || message.includes('Not Found') + ); + }; + // Check NPM try { const command = `npm view @qwen-code/qwen-code@${version} version 2>/dev/null`; @@ -155,19 +164,14 @@ function doesVersionExist(version) { // Check GitHub releases try { - const command = `gh release view "v${version}" --json tagName --jq .tagName 2>/dev/null`; + const command = `gh release view "v${version}" --json tagName --jq .tagName`; const output = execSync(command).toString().trim(); if (output === `v${version}`) { console.error(`GitHub release v${version} already exists.`); return true; } } catch (error) { - const isExpectedNotFound = - error.message.includes('release not found') || - error.message.includes('Not Found') || - error.message.includes('not found') || - error.status === 1; - if (!isExpectedNotFound) { + if (!isExpectedMissingGitHubRelease(error)) { console.error( `Failed to check GitHub releases for conflicts: ${error.message}`, ); diff --git a/scripts/tests/get-release-version-python-sdk.test.js b/scripts/tests/get-release-version-python-sdk.test.js new file mode 100644 index 000000000..c8b170adf --- /dev/null +++ b/scripts/tests/get-release-version-python-sdk.test.js @@ -0,0 +1,930 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const execSyncMock = vi.fn(); +const readFileSyncMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + execSync: execSyncMock, +})); + +vi.mock('node:fs', () => ({ + readFileSync: readFileSyncMock, +})); + +global.fetch = fetchMock; + +const modulePath = '../../packages/sdk-python/scripts/get-release-version.js'; + +async function loadGetVersion() { + const mod = await import(`${modulePath}?t=${Date.now()}-${Math.random()}`); + return mod.getVersion; +} + +function makeResponse({ status = 200, json = {}, statusText = 'OK' } = {}) { + return { + status, + ok: status >= 200 && status < 300, + statusText, + json: async () => json, + }; +} + +function makeExecError(message, { stderr = '', stdout = '', status } = {}) { + const error = new Error(message); + if (stderr) { + error.stderr = Buffer.from(stderr); + } + if (stdout) { + error.stdout = Buffer.from(stdout); + } + if (status !== undefined) { + error.status = status; + } + return error; +} + +function makeExecSyncMock({ + tags = {}, + tagError = null, + releases = {}, + gitHash = 'abc1234', +} = {}) { + return (command) => { + if (command === 'git rev-parse --short HEAD') { + return Buffer.from(gitHash); + } + + const tagMatch = command.match(/^git tag -l '(.+)'$/); + if (tagMatch) { + if (tagError) { + throw tagError; + } + return Buffer.from(tags[tagMatch[1]] ?? ''); + } + + const releaseMatch = command.match( + /^gh release view "(.+)" --json tagName --jq \.tagName$/, + ); + if (releaseMatch) { + const releaseName = releaseMatch[1]; + const outcome = releases[releaseName]; + if (outcome instanceof Error) { + throw outcome; + } + if (typeof outcome === 'string') { + return Buffer.from(outcome); + } + throw makeExecError('release not found', { status: 1 }); + } + + throw new Error(`Unexpected execSync command: ${command}`); + }; +} + +describe('python sdk get-release-version', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-30T03:15:16.000Z')); + readFileSyncMock.mockReturnValue('version = "0.1.0"\n'); + fetchMock.mockResolvedValue( + makeResponse({ + json: { releases: {} }, + }), + ); + execSyncMock.mockImplementation(makeExecSyncMock()); + }); + + it('returns empty previousReleaseTag for preview and nightly releases', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + '0.1.1.dev20260429010101': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseTag: 'v0.1.1-preview.1', + previousReleaseTag: '', + }); + + await expect(getVersion({ type: 'nightly' })).resolves.toMatchObject({ + releaseTag: 'v0.1.1-nightly.20260430031516.abc1234', + releaseVersion: '0.1.1-nightly.20260430031516.abc1234', + packageVersion: '0.1.1.dev20260430031516', + publishChannel: 'nightly', + previousReleaseTag: '', + }); + }); + + it('fails when an explicit override conflicts with existing PyPI version', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.1rc0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'preview', + preview_version_override: 'v0.1.1-preview.0', + }), + ).rejects.toThrow( + 'Requested preview release 0.1.1-preview.0 already exists on PyPI, GitHub releases.', + ); + }); + + it('fails when an explicit preview override only conflicts with PyPI state', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.1rc0': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'preview', + preview_version_override: 'v0.1.1-preview.0', + }), + ).rejects.toThrow( + 'Requested preview release 0.1.1-preview.0 already exists on PyPI.', + ); + }); + + it('fails when an explicit override conflicts with an existing git tag', async () => { + execSyncMock.mockImplementation( + makeExecSyncMock({ + tags: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'preview', + preview_version_override: 'v0.1.1-preview.0', + }), + ).rejects.toThrow( + 'Requested preview release 0.1.1-preview.0 already exists on git tags.', + ); + }); + + it('fails when an explicit override conflicts with an existing GitHub release', async () => { + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'preview', + preview_version_override: 'v0.1.1-preview.0', + }), + ).rejects.toThrow( + 'Requested preview release 0.1.1-preview.0 already exists on GitHub releases.', + ); + }); + + it('fails closed when git tag conflict checks error', async () => { + execSyncMock.mockImplementation( + makeExecSyncMock({ + tagError: new Error('git tag failed'), + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'Failed to check git tags for conflicts: git tag failed', + ); + }); + + it('fails if GitHub release lookup errors for reasons other than not found', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + const authError = new Error('HTTP 403 rate limited'); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': authError, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'Failed to check GitHub releases for conflicts: HTTP 403 rate limited', + ); + }); + + it('fails closed when unrelated lowercase not-found errors occur', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': makeExecError('host not found'), + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'Failed to check GitHub releases for conflicts: host not found', + ); + }); + + it('reuses a PyPI version when GitHub release finalization needs to resume', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseTag: 'v0.1.1-preview.0', + packageVersion: '0.1.1rc0', + resumeExistingRelease: true, + }); + }); + + it('reuses a stable release when only post-release recovery steps remain', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.0': 'sdk-python-v0.1.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0', + packageVersion: '0.1.0', + resumeExistingRelease: true, + }); + }); + + it('returns a manual stable override on the happy path', async () => { + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'stable', + stable_version_override: 'v0.2.0', + }), + ).resolves.toMatchObject({ + releaseVersion: '0.2.0', + packageVersion: '0.2.0', + publishChannel: 'latest', + }); + }); + + it('fails when an explicit stable override conflicts with a completed release', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.0': 'sdk-python-v0.1.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'stable', + stable_version_override: 'v0.1.0', + }), + ).rejects.toThrow( + 'Requested stable release 0.1.0 already exists on PyPI, GitHub releases.', + ); + }); + + it('fails when the latest preview base is not newer than the latest stable', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.2.0': [{}], + '0.1.1rc1': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).rejects.toThrow( + 'Latest preview base 0.1.1 is not newer than latest stable 0.2.0.', + ); + }); + + it('uses the latest nightly base for stable releases when no preview exists', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0.dev20260429010101': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.2.0', + releaseVersion: '0.2.0', + packageVersion: '0.2.0', + previousReleaseTag: 'v0.1.0', + publishChannel: 'latest', + }); + }); + + it('prefers nightly base over preview when nightly is higher for stable releases', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + '0.2.0.dev20260429010101': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.2.0', + releaseVersion: '0.2.0', + packageVersion: '0.2.0', + previousReleaseTag: 'v0.1.0', + publishChannel: 'latest', + }); + }); + + it('fails instead of patch-bumping a stable release derived from preview when its tag already exists', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + tags: { + 'sdk-python-v0.1.1': 'sdk-python-v0.1.1', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).rejects.toThrow( + 'Stable release 0.1.1 derived from the latest preview already exists.', + ); + }); + + it('fails instead of patch-bumping a stable release derived from nightly when its tag already exists', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0.dev20260429010101': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + tags: { + 'sdk-python-v0.2.0': 'sdk-python-v0.2.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).rejects.toThrow( + 'Stable release 0.2.0 derived from the latest nightly already exists.', + ); + }); + + it('fails instead of patch-bumping the current stable version when its tag already exists', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.0.9': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + tags: { + 'sdk-python-v0.1.0': 'sdk-python-v0.1.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).rejects.toThrow( + 'Stable release 0.1.0 already exists. Provide stable_version_override to release a different stable version.', + ); + }); + + it('returns the previous stable tag for stable releases', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.1.1', + previousReleaseTag: 'v0.1.0', + }); + }); + + it('maps preview versions to PEP 440 package versions', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'preview', + preview_version_override: 'v0.1.1-preview.2', + }), + ).resolves.toMatchObject({ + releaseVersion: '0.1.1-preview.2', + packageVersion: '0.1.1rc2', + }); + }); + + it('continues the highest prerelease base for preview releases', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0rc0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.2.0-preview.0': 'sdk-python-v0.2.0-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseVersion: '0.2.0-preview.1', + packageVersion: '0.2.0rc1', + }); + }); + + it('continues the highest nightly-only prerelease base for preview releases', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0.dev20260429010101': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.2.0-preview.0': 'sdk-python-v0.2.0-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseVersion: '0.2.0-preview.1', + packageVersion: '0.2.0rc1', + }); + }); + + it('keeps bumping preview slots until it finds an unused iteration', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.1.1rc0': [{}], + '0.1.1rc1': [{}], + '0.1.1rc2': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + 'sdk-python-v0.1.1-preview.1': 'sdk-python-v0.1.1-preview.1', + 'sdk-python-v0.1.1-preview.2': 'sdk-python-v0.1.1-preview.2', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseVersion: '0.1.1-preview.3', + packageVersion: '0.1.1rc3', + resumeExistingRelease: false, + }); + }); + + it('throws on nightly conflicts instead of silently changing the timestamped version', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.1.dev20260430031516': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.1.1-nightly.20260430031516.abc1234': + 'sdk-python-v0.1.1-nightly.20260430031516.abc1234', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'nightly' })).rejects.toThrow( + 'Nightly version conflict for 0.1.1.dev20260430031516', + ); + }); + + it('throws when PyPI metadata fetch is not ok', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + status: 503, + statusText: 'Service Unavailable', + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).rejects.toThrow( + 'Failed to fetch PyPI metadata: 503 Service Unavailable', + ); + }); + + it('treats a PyPI 404 as a first-release scenario', async () => { + fetchMock.mockResolvedValue(makeResponse({ status: 404 })); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0', + releaseVersion: '0.1.0', + packageVersion: '0.1.0', + previousReleaseTag: '', + publishChannel: 'latest', + }); + }); + + it('ignores fully yanked PyPI versions', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{ yanked: true }], + '0.0.9': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0', + releaseVersion: '0.1.0', + packageVersion: '0.1.0', + previousReleaseTag: 'v0.0.9', + }); + }); + + it('detects yanked versions as conflicts on PyPI', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{ yanked: true }], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + // 0.1.0 is yanked so base-version computation ignores it and derives 0.1.0 + // from pyproject.toml, but conflict detection sees it on PyPI. Since it has + // no GitHub release, the script resumes the existing release. + await expect(getVersion({ type: 'stable' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0', + packageVersion: '0.1.0', + resumeExistingRelease: true, + }); + }); + + it('rejects stable_version_override that is older than latest stable', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.5.0': [{}], + }, + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect( + getVersion({ + type: 'stable', + stable_version_override: 'v0.1.0', + }), + ).rejects.toThrow( + 'stable_version_override 0.1.0 is older than latest stable 0.5.0', + ); + }); + + it('allows stable_version_override equal to latest stable', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.5.0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.5.0': 'sdk-python-v0.5.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + // Equal version already exists, so the override conflict check fires + await expect( + getVersion({ + type: 'stable', + stable_version_override: 'v0.5.0', + }), + ).rejects.toThrow( + 'Requested stable release 0.5.0 already exists on PyPI, GitHub releases.', + ); + }); + + it('uses pyproject.toml version for first preview release when PyPI has no versions', async () => { + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0-preview.0', + releaseVersion: '0.1.0-preview.0', + packageVersion: '0.1.0rc0', + }); + }); + + it('uses pyproject.toml version for first nightly release when PyPI has no versions', async () => { + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'nightly' })).resolves.toMatchObject({ + releaseTag: 'v0.1.0-nightly.20260430031516.abc1234', + releaseVersion: '0.1.0-nightly.20260430031516.abc1234', + packageVersion: '0.1.0.dev20260430031516', + }); + }); + + it('continues the prerelease base when it equals the stable baseline', async () => { + readFileSyncMock.mockReturnValue('version = "0.2.0"\n'); + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0rc0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.2.0-preview.0': 'sdk-python-v0.2.0-preview.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseVersion: '0.2.0-preview.1', + packageVersion: '0.2.0rc1', + }); + }); + + it('emits a warning when skipping a preview slot due to an orphan git tag', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + tags: { + 'sdk-python-v0.1.1-preview.0': 'sdk-python-v0.1.1-preview.0', + }, + }), + ); + + const consoleSpy = vi.spyOn(console, 'error'); + const getVersion = await loadGetVersion(); + + await expect(getVersion({ type: 'preview' })).resolves.toMatchObject({ + releaseVersion: '0.1.1-preview.1', + packageVersion: '0.1.1rc1', + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('::warning::Orphan git tag'), + ); + consoleSpy.mockRestore(); + }); + + it('excludes current release from previousReleaseTag on resume', async () => { + fetchMock.mockResolvedValue( + makeResponse({ + json: { + releases: { + '0.1.0': [{}], + '0.2.0': [{}], + '0.2.0rc0': [{}], + }, + }, + }), + ); + execSyncMock.mockImplementation( + makeExecSyncMock({ + releases: { + 'sdk-python-v0.2.0': 'sdk-python-v0.2.0', + }, + }), + ); + + const getVersion = await loadGetVersion(); + + const result = await getVersion({ type: 'stable' }); + expect(result).toMatchObject({ + releaseTag: 'v0.2.0', + previousReleaseTag: 'v0.1.0', + resumeExistingRelease: true, + }); + }); +});