qwen-code/.github/workflows/release-sdk-python.yml
Salman Chishti 70eecdbdf4
Upgrade GitHub Actions for Node 24 compatibility (#1876)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-05-12 15:02:33 +08:00

515 lines
21 KiB
YAML

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 <<BODY
The Python SDK release workflow failed.
| Field | Value |
|-------|-------|
| Release Tag | \`${RELEASE_TAG}\` |
| Package Version | \`${PACKAGE_VERSION}\` |
| Publish Channel | \`${PUBLISH_CHANNEL}\` |
| Resume Existing | \`${RESUME_EXISTING_RELEASE}\` |
| Release Branch | \`${BRANCH_NAME}\` |
| Persisted Source | \`${HAS_PERSISTED_SOURCE}\` |
| Target SHA | \`${RELEASE_TARGET_SHA}\` |
| PR URL | ${PR_URL} |
See the full run for details: ${DETAILS_URL}
BODY
)
gh issue create \
--repo "${GITHUB_REPOSITORY}" \
--title "Python SDK release failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
--body "${BODY}"