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@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 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@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0 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 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}" GH_RELEASE_ARGS=() if [[ -n "${PREVIOUS_RELEASE_TAG}" ]]; then PREVIOUS_TAG_NAME="sdk-python-${PREVIOUS_RELEASE_TAG}" # Verify the previous tag exists in Git before using --notes-start-tag. # If a prior release published to PyPI but failed to create a GitHub # release/tag, the tag won't exist — fall back to static notes to # avoid failing gh release create after PyPI publish. if git rev-parse "${PREVIOUS_TAG_NAME}" >/dev/null 2>&1; then GH_RELEASE_ARGS+=(--generate-notes --notes-start-tag "${PREVIOUS_TAG_NAME}") else echo "::warning::Previous tag ${PREVIOUS_TAG_NAME} not found; skipping --generate-notes." echo "See commit history for changes." >> "${NOTES_FILE}" fi else # PREVIOUS_RELEASE_TAG is empty for preview/nightly (not computed) # and for the very first stable release (no prior stable on PyPI). # Skip --generate-notes to avoid including non-SDK commits. echo "See commit history for changes." >> "${NOTES_FILE}" fi if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then GH_RELEASE_ARGS+=(--prerelease) fi gh release create "${TAG_NAME}" \ --target "${RELEASE_TARGET_SHA}" \ --title "SDK Python Release ${RELEASE_TAG}" \ --notes-file "${NOTES_FILE}" \ "${GH_RELEASE_ARGS[@]}" 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 <