name: "CD: Bump Version" on: workflow_dispatch: inputs: service: description: "Service/Package to bump" required: true type: choice options: - pypi/cua - pypi/agent - pypi/auto - pypi/bench - pypi/bench-ui - pypi/cli - pypi/computer - pypi/computer-server - pypi/cloud - pypi/core - pypi/mcp-server - pypi/sandbox - pypi/sandbox-apps - pypi/som - pypi/train - npm/cli - npm/computer - npm/core - npm/playground - npm/cuabot - lume - cua-driver - cua-driver-rs - docker/cuabot - docker/kasm - docker/xfce - docker/lumier - docker/qemu-android - docker/qemu-linux - docker/qemu-windows bump_type: description: "Version bump type" required: true type: choice options: - patch - minor - major target_branch: description: "Branch to push the bump commit + tag to" required: false type: string default: "main" permissions: contents: write jobs: bump-version: runs-on: ubuntu-latest steps: - name: Set package directory and type id: package run: | case "${{ inputs.service }}" in "pypi/cua") echo "directory=libs/python/cua" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/agent") echo "directory=libs/python/agent" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/auto") echo "directory=libs/python/cua-auto" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/bench") echo "directory=libs/cua-bench" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/bench-ui") echo "directory=libs/python/bench-ui" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/cli") echo "directory=libs/python/cua-cli" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/computer") echo "directory=libs/python/computer" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/cloud") echo "directory=libs/python/cua-cloud" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/computer-server") echo "directory=libs/python/computer-server" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/core") echo "directory=libs/python/core" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/mcp-server") echo "directory=libs/python/mcp-server" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/sandbox") echo "directory=libs/python/cua-sandbox" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/sandbox-apps") echo "directory=libs/python/cua-sandbox-apps" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/som") echo "directory=libs/python/som" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "pypi/train") echo "directory=libs/python/cua-train" >> $GITHUB_OUTPUT echo "type=python" >> $GITHUB_OUTPUT ;; "npm/cli") echo "directory=libs/typescript/cua-cli" >> $GITHUB_OUTPUT echo "type=npm" >> $GITHUB_OUTPUT ;; "npm/computer") echo "directory=libs/typescript/computer" >> $GITHUB_OUTPUT echo "type=npm" >> $GITHUB_OUTPUT ;; "npm/core") echo "directory=libs/typescript/core" >> $GITHUB_OUTPUT echo "type=npm" >> $GITHUB_OUTPUT ;; "npm/playground") echo "directory=libs/typescript/playground" >> $GITHUB_OUTPUT echo "type=npm" >> $GITHUB_OUTPUT ;; "npm/cuabot") echo "directory=libs/cuabot" >> $GITHUB_OUTPUT echo "type=npm" >> $GITHUB_OUTPUT ;; "lume") echo "directory=libs/lume" >> $GITHUB_OUTPUT echo "type=lume" >> $GITHUB_OUTPUT ;; "cua-driver") echo "directory=libs/cua-driver" >> $GITHUB_OUTPUT echo "type=cua-driver" >> $GITHUB_OUTPUT ;; "cua-driver-rs") echo "directory=libs/cua-driver/rust" >> $GITHUB_OUTPUT echo "type=cua-driver-rs" >> $GITHUB_OUTPUT ;; "docker/cuabot") echo "directory=libs/cuabot" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT echo "bumpversion_config=.bumpversion.docker.cfg" >> $GITHUB_OUTPUT ;; "docker/kasm") echo "directory=libs/kasm" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; "docker/xfce") echo "directory=libs/xfce" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; "docker/lumier") echo "directory=libs/lumier" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; "docker/qemu-android") echo "directory=libs/qemu-docker/android" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; "docker/qemu-linux") echo "directory=libs/qemu-docker/linux" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; "docker/qemu-windows") echo "directory=libs/qemu-docker/windows" >> $GITHUB_OUTPUT echo "type=docker" >> $GITHUB_OUTPUT ;; *) echo "Unknown service: ${{ inputs.service }}" exit 1 ;; esac - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ inputs.target_branch || 'main' }} token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install bump2version run: pip install bump2version - name: Configure Git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Reconcile cua-driver-rs bump base with Cargo.toml (drift guard) if: ${{ inputs.service == 'cua-driver-rs' }} run: | # Cargo.toml is the single source of truth for the cua-driver-rs # version. bump2version keeps a duplicate current_version in # .bumpversion.cfg, and that copy has silently drifted before and cut # a release at the wrong version (binary reported a stale version, # tag could downgrade). Reconcile the cfg to Cargo.toml here, before # the dry-run tag-clean and the real bump both read it, so a bump can # never start from a stale base again no matter what the committed # cfg says. cd "${{ steps.package.outputs.directory }}" CARGO_VERSION=$(grep -m1 '^version[[:space:]]*=' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') CFG_VERSION=$(grep -m1 '^current_version[[:space:]]*=' .bumpversion.cfg | sed -E 's/.*=[[:space:]]*//') if [ -z "$CARGO_VERSION" ]; then echo "::error::could not read version from libs/cua-driver/rust/Cargo.toml" exit 1 fi if [ "$CARGO_VERSION" != "$CFG_VERSION" ]; then echo "::warning::.bumpversion.cfg current_version ($CFG_VERSION) drifted from Cargo.toml ($CARGO_VERSION); reconciling to Cargo.toml before bump." sed -i.bak -E "s/^current_version[[:space:]]*=.*/current_version = ${CARGO_VERSION}/" .bumpversion.cfg rm -f .bumpversion.cfg.bak fi echo "cua-driver-rs bump base: $(grep -m1 '^current_version' .bumpversion.cfg)" - name: Clean existing tags if present run: | # Function to clean a tag for a given package directory clean_tag() { local dir=$1 local config_file=${2:-.bumpversion.cfg} cd "$dir" NEW_VERSION=$(bump2version --config-file "$config_file" --dry-run --list ${{ inputs.bump_type }} 2>/dev/null | grep -m1 '^new_version=' | sed 's/new_version=//') TAG_NAME=$(grep -m1 '^tag_name' "$config_file" | sed 's/tag_name *= *//' | sed "s|{new_version}|$NEW_VERSION|") echo "Checking for existing tag: $TAG_NAME" git tag -d "$TAG_NAME" 2>/dev/null || true git push origin ":refs/tags/$TAG_NAME" 2>/dev/null || true cd - > /dev/null } # Clean tag for the primary package BUMPVERSION_CONFIG="${{ steps.package.outputs.bumpversion_config }}" clean_tag "${{ steps.package.outputs.directory }}" "${BUMPVERSION_CONFIG:-.bumpversion.cfg}" # No cascade tag cleaning — each package is bumped independently. # Dependent packages use version ranges and resolve deps at publish time. - name: Run bump2version run: | cd ${{ steps.package.outputs.directory }} CONFIG_FLAG="" if [ -n "${{ steps.package.outputs.bumpversion_config }}" ]; then CONFIG_FLAG="--config-file ${{ steps.package.outputs.bumpversion_config }}" fi bump2version $CONFIG_FLAG ${{ inputs.bump_type }} - name: Sync Cargo.lock into the cua-driver-rs bump commit # bump2version edits only `[workspace.package] version` in Cargo.toml, # leaving Cargo.lock's workspace-member versions stale. The next plain # `cargo build` anyone runs then re-locks them and diverges from the # committed lockfile — which used to break the nix build's cargoHash. # Re-lock the members now and fold the change into the bump commit so the # lockfile never ships out of sync with the manifest. (cargo is # preinstalled on ubuntu runners; `--workspace` touches members only, not # registry deps, so this never silently bumps dependencies.) if: ${{ inputs.service == 'cua-driver-rs' }} run: | cd ${{ steps.package.outputs.directory }} cargo update --workspace if ! git diff --quiet Cargo.lock; then TAG=$(git tag --points-at HEAD | head -1) git add Cargo.lock git commit --amend --no-edit # bump2version tagged the pre-amend commit; move the tag onto the # amended commit so the release pipeline builds the synced lockfile. [ -n "$TAG" ] && git tag -f "$TAG" HEAD echo "Re-locked Cargo.lock and moved tag ${TAG:-} to the amended bump commit." else echo "Cargo.lock already in sync; nothing to fold in." fi - name: Collect created tag id: collect_tags run: | # Only one tag is created per bump (no cascades) ALL_TAGS=$(git tag --points-at HEAD | tr '\n' ' ' | xargs) echo "Tags to push: $ALL_TAGS" echo "tags=$ALL_TAGS" >> $GITHUB_OUTPUT - name: Capture bumped cua version if: ${{ inputs.service == 'pypi/cua' }} id: cua_version run: | cd libs/python/cua VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "cua version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped agent version if: ${{ inputs.service == 'pypi/agent' }} id: agent_version run: | cd libs/python/agent VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Agent version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped auto version if: ${{ inputs.service == 'pypi/auto' }} id: auto_version run: | cd libs/python/cua-auto VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Auto version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped bench version if: ${{ inputs.service == 'pypi/bench' }} id: bench_version run: | cd libs/cua-bench VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Bench version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped bench-ui version if: ${{ inputs.service == 'pypi/bench-ui' }} id: bench_ui_version run: | cd libs/python/bench-ui VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Bench-ui version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped cli version if: ${{ inputs.service == 'pypi/cli' }} id: cli_version run: | cd libs/python/cua-cli VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "CLI version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped computer version if: ${{ inputs.service == 'pypi/computer' }} id: computer_version run: | cd libs/python/computer VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Computer version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped cloud version if: ${{ inputs.service == 'pypi/cloud' }} id: cloud_version run: | cd libs/python/cua-cloud VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Cloud version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped computer-server version if: ${{ inputs.service == 'pypi/computer-server' }} id: computer_server_version run: | cd libs/python/computer-server VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Computer-server version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped core version if: ${{ inputs.service == 'pypi/core' }} id: core_version run: | cd libs/python/core VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Core version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped mcp-server version if: ${{ inputs.service == 'pypi/mcp-server' }} id: mcp_server_version run: | cd libs/python/mcp-server VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "MCP-server version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped sandbox version if: ${{ inputs.service == 'pypi/sandbox' }} id: sandbox_version run: | cd libs/python/cua-sandbox VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Sandbox version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped sandbox-apps version if: ${{ inputs.service == 'pypi/sandbox-apps' }} id: sandbox_apps_version run: | cd libs/python/cua-sandbox-apps VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Sandbox-apps version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped som version if: ${{ inputs.service == 'pypi/som' }} id: som_version run: | cd libs/python/som VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "SOM version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped train version if: ${{ inputs.service == 'pypi/train' }} id: train_version run: | cd libs/python/cua-train VERSION=$(python -c "import tomllib; from pathlib import Path; data = tomllib.loads(Path('pyproject.toml').read_text()); print(data['project']['version'])") echo "Train version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped npm/cli version if: ${{ inputs.service == 'npm/cli' }} id: npm_cli_version run: | VERSION=$(grep -oP '"version": "\K[^"]+' libs/typescript/cua-cli/package.json) echo "npm/cli version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped npm/computer version if: ${{ inputs.service == 'npm/computer' }} id: npm_computer_version run: | VERSION=$(grep -oP '"version": "\K[^"]+' libs/typescript/computer/package.json) echo "npm/computer version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped npm/core version if: ${{ inputs.service == 'npm/core' }} id: npm_core_version run: | VERSION=$(grep -oP '"version": "\K[^"]+' libs/typescript/core/package.json) echo "npm/core version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped npm/playground version if: ${{ inputs.service == 'npm/playground' }} id: npm_playground_version run: | VERSION=$(grep -oP '"version": "\K[^"]+' libs/typescript/playground/package.json) echo "npm/playground version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped lume version if: ${{ inputs.service == 'lume' }} id: lume_version run: | VERSION=$(grep -oP 'static let current: String = "\K[^"]+' libs/lume/src/Main.swift) echo "lume version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped cua-driver version if: ${{ inputs.service == 'cua-driver' }} id: cua_driver_version run: | VERSION=$(grep -oP 'public static let version = "\K[^"]+' libs/cua-driver/Sources/CuaDriverCore/CuaDriverCore.swift) echo "cua-driver version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped cua-driver-rs version if: ${{ inputs.service == 'cua-driver-rs' }} id: cua_driver_rs_version run: | VERSION=$(grep -m1 -oP '^version = "\K[^"]+' libs/cua-driver/rust/Cargo.toml) echo "cua-driver-rs version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Capture bumped cuabot version if: ${{ inputs.service == 'npm/cuabot' }} id: cuabot_version run: | VERSION=$(grep -oP '"version": "\K[^"]+' libs/cuabot/package.json) echo "cuabot version: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} - name: Push release to target branch env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TARGET_BRANCH: ${{ inputs.target_branch || 'main' }} run: | # Configure git to use the app token git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" # Verify the token works echo "Verifying GitHub App token..." gh auth status # Get all tags created by bump2version ALL_TAGS="${{ steps.collect_tags.outputs.tags }}" echo "Tags to push: $ALL_TAGS" # Use the first tag for naming the temp branch FIRST_TAG=$(echo "$ALL_TAGS" | awk '{print $1}') TEMP_BRANCH="temp-release-${FIRST_TAG}" echo "Temp branch: $TEMP_BRANCH" # Retry loop for handling concurrent bumps MAX_RETRIES=5 RETRY_COUNT=0 while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do RETRY_COUNT=$((RETRY_COUNT + 1)) echo "" echo "=== Attempt $RETRY_COUNT/$MAX_RETRIES ===" # Fetch latest target branch to check if we need to rebase git fetch origin "${TARGET_BRANCH}" # Check if our HEAD is a descendant of origin/ (fast-forward possible) if ! git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD; then echo "Branch ${TARGET_BRANCH} has moved forward, rebasing..." # Rebase our commit(s) onto the latest branch git rebase "origin/${TARGET_BRANCH}" # Update the tag to point to the rebased commit for tag in $ALL_TAGS; do git tag -f "$tag" HEAD echo "Updated tag $tag to rebased HEAD" done fi # Get the current commit SHA (may have changed after rebase) COMMIT_SHA=$(git rev-parse HEAD) echo "Commit SHA: $COMMIT_SHA" # Push to temp branch echo "Pushing to temp branch..." git push origin "HEAD:refs/heads/${TEMP_BRANCH}" --force # Try to update target branch via API echo "Updating ${TARGET_BRANCH} branch via API..." if gh api -X PATCH "/repos/${{ github.repository }}/git/refs/heads/${TARGET_BRANCH}" \ -f sha="${COMMIT_SHA}" \ -F force=false 2>&1; then echo "Successfully updated ${TARGET_BRANCH} branch!" # Create tag via API (only after main is successfully updated) echo "Creating tag via API..." for tag in $ALL_TAGS; do echo "Creating tag: $tag" gh api -X DELETE /repos/${{ github.repository }}/git/refs/tags/${tag} 2>/dev/null || true gh api /repos/${{ github.repository }}/git/refs \ -f ref="refs/tags/${tag}" \ -f sha="${COMMIT_SHA}" echo "Tag $tag created successfully!" done # Clean up temp branch echo "Cleaning up temp branch..." git push origin --delete "${TEMP_BRANCH}" || true exit 0 fi echo "Failed to update ${TARGET_BRANCH} (not a fast-forward), will retry..." sleep 2 done echo "ERROR: Failed to update ${TARGET_BRANCH} branch after $MAX_RETRIES attempts" exit 1 # NOTE: Publishing is handled by tag-triggered workflows (cd-py-*.yml, cd-ts-*.yml, cd-swift-lume.yml) # When bump-version pushes a tag (e.g., agent-v0.7.10), the corresponding CD workflow # is automatically triggered by the tag push event. No need to call them here.