name: 'Release' on: schedule: # Keep these strings in sync with run-release-step.sh. # Runs every day at 21:00 UTC for the nightly release. - cron: '0 21 * * *' # Runs every Tuesday at 17:00 UTC for the preview release. - cron: '0 17 * * 2' workflow_dispatch: inputs: version: description: 'The version to release (e.g., v0.1.11 or v0.1.11-preview.0). Required for manual patch releases.' required: false type: 'string' ref: description: 'The branch or ref (full git sha) to release from.' required: true type: 'string' default: 'main' dry_run: description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' 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: 'Create a preview release. If version is X.Y.Z-preview.N, use it as-is. If version is X.Y.Z, derive X.Y.Z-preview.0.' required: false type: 'boolean' default: false force_skip_tests: description: 'Skip the release validation jobs ("quality", "integration_none", and "integration_docker"), allowing publish to proceed without them. Prod releases should run validation.' required: false type: 'boolean' default: false concurrency: # Serialize scheduled releases; manual runs remain independent. group: "${{ github.event_name == 'schedule' && 'release-scheduled-validation' || format('release-{0}', github.run_id) }}" cancel-in-progress: false jobs: # The dedicated hk4 label isolates release validation from regular CI. prepare: name: 'Prepare Release Metadata' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 30 if: |- ${{ github.repository == 'QwenLM/qwen-code' }} permissions: contents: 'read' actions: 'read' outputs: release_sha: '${{ steps.source.outputs.release_sha }}' audio_prebuild_key: '${{ steps.audio_key.outputs.cache_key }}' audio_artifact_id: '${{ steps.audio_reuse.outputs.artifact_id }}' audio_run_id: '${{ steps.audio_reuse.outputs.run_id }}' release_tag: '${{ steps.version.outputs.RELEASE_TAG }}' release_version: '${{ steps.version.outputs.RELEASE_VERSION }}' npm_tag: '${{ steps.version.outputs.NPM_TAG }}' previous_release_tag: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}' is_nightly: '${{ steps.vars.outputs.is_nightly }}' is_preview: '${{ steps.vars.outputs.is_preview }}' is_dry_run: '${{ steps.vars.outputs.is_dry_run }}' steps: - &restore_release_workspace name: 'Restore workspace ownership' if: "${{ runner.environment == 'self-hosted' }}" uses: '$/.github/actions/prepare-release-runner' - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - &checkout_release_workflow_scripts name: 'Checkout release workflow scripts' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ github.workflow_sha }}' fetch-depth: 1 persist-credentials: false sparse-checkout: |- /.github/scripts /.github/workflows/audio-capture-prebuilds.yml /scripts/assert-release-version.mjs /scripts/check-release-tarball-exec-bits.mjs /scripts/release-packages.mjs /scripts/workspaces.js /package.json /packages/*/package.json /packages/channels/*/package.json /integrations/*/package.json sparse-checkout-cone-mode: false path: '.release-workflow' - name: 'Set booleans for simplified logic' id: 'vars' env: CREATE_NIGHTLY_RELEASE: '${{ github.event.inputs.create_nightly_release }}' CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}' CRON: '${{ github.event.schedule }}' DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}' run: '.release-workflow/.github/scripts/run-release-step.sh set-flags' - name: 'Resolve release commit' id: 'source' run: '.release-workflow/.github/scripts/run-release-step.sh resolve-commit' - name: 'Set up Node.js (hosted)' if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' package-manager-cache: false # ECS uses its pre-installed Node because nodejs.org may be unreachable. - name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' # Fail fast on a saturated pool instance instead of dying on ENOSPC # mid-job (#10035): a re-run lands on an instance with headroom. The # script rides the selected ref, so a ref that predates it skips. - &release_disk_floor_gate name: 'Disk floor gate (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" run: |- if [ -f .github/scripts/check-disk-floor.sh ]; then bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}" fi - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' run: |- corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only npm run postinstall npm run generate - &reset_release_workflow_scripts name: 'Reset release workflow scripts' run: 'rm -rf -- .release-workflow' - *checkout_release_workflow_scripts - name: 'Fingerprint audio build inputs' id: 'audio_key' continue-on-error: true env: RELEASE_SHA: '${{ steps.source.outputs.release_sha }}' run: |- directory=$(mktemp -d "${RUNNER_TEMP}/audio-reuse.XXXXXX") echo "directory=$directory" >> "$GITHUB_OUTPUT" node .release-workflow/.github/scripts/audio-prebuilds.mjs key "$RELEASE_SHA" .release-workflow >> "$GITHUB_OUTPUT" - name: 'Find reusable audio prebuilds' id: 'audio_candidate' continue-on-error: true uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 env: AUDIO_KEY: '${{ steps.audio_key.outputs.cache_key }}' with: script: |- const { findAudioArtifact } = await import(process.env.GITHUB_WORKSPACE + '/.release-workflow/.github/scripts/audio-prebuilds.mjs'); const artifact = await findAudioArtifact(github, context.repo, process.env.AUDIO_KEY, core.warning); for (const [key, value] of Object.entries(artifact ?? {})) core.setOutput(key, value); - name: 'Try reusable audio prebuilds' id: 'audio_download' if: "${{ steps.audio_candidate.outputs.artifact_id != '' }}" continue-on-error: true uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: artifact-ids: '${{ steps.audio_candidate.outputs.artifact_id }}' run-id: '${{ steps.audio_candidate.outputs.run_id }}' github-token: '${{ github.token }}' merge-multiple: true path: '${{ steps.audio_key.outputs.directory }}' - name: 'Accept complete audio prebuilds' id: 'audio_reuse' if: "${{ steps.audio_download.outcome == 'success' }}" continue-on-error: true env: AUDIO_DIRECTORY: '${{ steps.audio_key.outputs.directory }}' AUDIO_ARTIFACT_ID: '${{ steps.audio_candidate.outputs.artifact_id }}' AUDIO_RUN_ID: '${{ steps.audio_candidate.outputs.run_id }}' run: |- node .release-workflow/.github/scripts/audio-prebuilds.mjs validate "$AUDIO_DIRECTORY" echo "artifact_id=$AUDIO_ARTIFACT_ID" >> "$GITHUB_OUTPUT" echo "run_id=$AUDIO_RUN_ID" >> "$GITHUB_OUTPUT" - name: 'Remove trial audio download' if: "${{ always() && steps.audio_key.outputs.directory != '' }}" env: AUDIO_DIRECTORY: '${{ steps.audio_key.outputs.directory }}' run: 'rm -rf -- "$AUDIO_DIRECTORY"' - name: 'Get the version' id: 'version' env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}' IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}' MANUAL_VERSION: '${{ inputs.version }}' run: '.release-workflow/.github/scripts/run-release-step.sh resolve-version' quality_static: name: 'Quality Checks (Static)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' # Retunable for contention on the dedicated runner; values are minutes. timeout-minutes: "${{ fromJSON(vars.QWEN_RELEASE_STATIC_TIMEOUT_MINUTES || '60') }}" needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: &release_test_env OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' steps: - *restore_release_workspace - name: 'Report timeout budget' env: BUDGET: "${{ fromJSON(vars.QWEN_RELEASE_STATIC_TIMEOUT_MINUTES || '60') }}" VARIABLE_SET: "${{ vars.QWEN_RELEASE_STATIC_TIMEOUT_MINUTES != '' }}" run: |- echo "::notice::quality_static timeout budget: ${BUDGET} minutes (QWEN_RELEASE_STATIC_TIMEOUT_MINUTES set=${VARIABLE_SET})" - &checkout_release_validation name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ needs.prepare.outputs.release_sha }}' fetch-depth: 1 - &setup_release_node_hosted name: 'Set up Node.js (hosted)' if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' package-manager-cache: false # ECS uses its pre-installed Node because nodejs.org may be unreachable. - &setup_release_node_self_hosted name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' - *release_disk_floor_gate - &install_release_dependencies name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' run: |- corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only npm run postinstall npm run generate - name: 'Run Lint' run: 'npm run lint:ci' quality_build: name: 'Quality Checks (Build)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' # Same contention knob as quality_static. timeout-minutes: "${{ fromJSON(vars.QWEN_RELEASE_BUILD_TIMEOUT_MINUTES || '45') }}" needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - name: 'Report timeout budget' env: BUDGET: "${{ fromJSON(vars.QWEN_RELEASE_BUILD_TIMEOUT_MINUTES || '45') }}" VARIABLE_SET: "${{ vars.QWEN_RELEASE_BUILD_TIMEOUT_MINUTES != '' }}" run: |- echo "::notice::quality_build timeout budget: ${BUDGET} minutes (QWEN_RELEASE_BUILD_TIMEOUT_MINUTES set=${VARIABLE_SET})" - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies # This materializes the repo-root dist packed below. - name: 'Check Serve Fast Path Bundle' run: 'npm run check:serve-fast-path-bundle' - name: 'Build Project' run: 'npm run build' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Pack Build Outputs' run: '.release-workflow/.github/scripts/run-release-step.sh pack-build' - name: 'Upload Build Outputs' uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 with: name: 'release-quality-build' path: '${{ runner.temp }}/release-build.tgz' retention-days: 3 if-no-files-found: 'error' overwrite: true # Fail package guards here, while keeping the uploaded build unchanged. - name: 'Verify Prepared Package' run: '.release-workflow/.github/scripts/run-release-step.sh verify-package' quality_typecheck: name: 'Quality Checks (Typecheck)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 30 needs: - 'prepare' - 'quality_build' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies - &download_release_build name: 'Download Build Outputs' uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: name: 'release-quality-build' path: '${{ runner.temp }}/release-quality-build' - &unpack_release_build name: 'Unpack Build Outputs' run: 'tar -xzf "${RUNNER_TEMP}/release-quality-build/release-build.tgz"' - name: 'Typecheck Project' run: 'npm run typecheck' workspace_tests: name: 'Workspace Tests (${{ matrix.shard }}/3)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' # Retunable for contention on the dedicated runner. timeout-minutes: "${{ fromJSON(vars.QWEN_RELEASE_WORKSPACE_TIMEOUT_MINUTES || '45') }}" needs: - 'prepare' - 'quality_build' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} strategy: fail-fast: false matrix: shard: [1, 2, 3] permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies - *download_release_build - *unpack_release_build - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Run Workspace Tests' shell: 'bash' env: VITEST_MAX_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}" VITEST_MIN_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}" VITEST_MAX_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}" VITEST_MIN_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}" VITEST_RETRY: "${{ vars.QWEN_RELEASE_VITEST_RETRY || '2' }}" NO_COLOR: 'true' # Same fleet predicate as ci.yml's unit lane (#10870): millisecond # budgets measure the neighbours on this pool, not the code. QWEN_SKIP_LATENCY_BUDGETS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}" run: '.release-workflow/.github/scripts/run-release-workspace-tests.sh "${{ matrix.shard }}"' quality_scripts: name: 'Quality Checks (Scripts)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 30 needs: - 'prepare' - 'quality_build' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies - *download_release_build - *unpack_release_build # Same host-wide archive cache as ci.yml's test job, which runs the same # standalone runtime regression; this job also runs on a persistent host. - name: 'Configure persistent standalone runtime cache (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" run: |- cache_dir="${HOME}/.cache/qwen-code/standalone-runtimes" echo "QWEN_STANDALONE_RUNTIME_CACHE=${cache_dir}" >> "${GITHUB_ENV}" echo "Using persistent standalone runtime cache at ${cache_dir}" du -sh "${cache_dir}" 2>/dev/null || true - name: 'Run Script Tests' env: VITEST_MAX_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}" VITEST_MIN_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}" VITEST_MAX_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}" VITEST_MIN_FORKS: "${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}" run: 'npm run test:scripts' quality: name: 'Quality Checks' # Same pool as the checks it aggregates. On 2026-10-15 this hosted-only # gate job queued 53 minutes behind PR automation while every ECS machine # it depends on sat idle, and it gates publish. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 5 needs: - 'prepare' - 'quality_static' - 'quality_build' - 'quality_typecheck' - 'workspace_tests' - 'quality_scripts' # Cancelled runs stay cancelled; completed validation fails closed. if: |- ${{ !cancelled() && needs.prepare.result == 'success' && github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' steps: - *restore_release_workspace - name: 'Verify quality results' env: STATIC_RESULT: '${{ needs.quality_static.result }}' BUILD_RESULT: '${{ needs.quality_build.result }}' TYPECHECK_RESULT: '${{ needs.quality_typecheck.result }}' WORKSPACE_TEST_RESULT: '${{ needs.workspace_tests.result }}' SCRIPT_TEST_RESULT: '${{ needs.quality_scripts.result }}' run: |- for result in \ "${STATIC_RESULT}" \ "${BUILD_RESULT}" \ "${TYPECHECK_RESULT}" \ "${WORKSPACE_TEST_RESULT}" \ "${SCRIPT_TEST_RESULT}"; do if [[ "${result}" != 'success' ]]; then echo "::error::A release quality check did not succeed." exit 1 fi done integration_none: name: 'Integration Tests (No Sandbox)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 120 needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies - name: 'Build Bundle' run: 'npm run build && npm run bundle' - name: 'Run CLI Integration Tests' env: RUNNER_ENVIRONMENT: '${{ runner.environment }}' run: 'npm run test:integration:cli:sandbox:none' - name: 'Run Interactive Integration Tests' env: RUNNER_ENVIRONMENT: '${{ runner.environment }}' run: 'npm run test:integration:interactive:sandbox:none' integration_docker: name: 'Integration Tests (Docker)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 120 needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} permissions: contents: 'read' env: *release_test_env steps: - *restore_release_workspace - name: 'Check docker daemon' run: |- if ! docker_info_output="$(docker info 2>&1)"; then echo "::error::docker daemon is not reachable on this runner; docker integration tests cannot run." printf '%s\n' "$docker_info_output" exit 1 fi - *checkout_release_validation - *setup_release_node_hosted - *setup_release_node_self_hosted - *release_disk_floor_gate - *install_release_dependencies - name: 'Build Bundle' run: 'npm run build && npm run bundle' - name: 'Set up Docker' if: "${{ runner.environment != 'self-hosted' }}" uses: 'docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5' # ratchet:docker/setup-buildx-action@v4 - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Run Docker Integration Tests' env: QWEN_SANDBOX: 'docker' BUILD_SANDBOX_FLAGS: '--label org.qwen-code.ci.sandbox=true' RELEASE_CONTAINER_OWNER: '${{ github.run_id }}-${{ github.run_attempt }}-release' SANDBOX_FLAGS: '--label org.qwen-code.ci.owner=${RELEASE_CONTAINER_OWNER}' RUNNER_ENVIRONMENT: '${{ runner.environment }}' run: '.release-workflow/.github/scripts/run-release-docker-integration.sh' - name: 'Remove job-owned release containers' if: "${{ always() && runner.environment == 'self-hosted' }}" env: RELEASE_CONTAINER_OWNER: '${{ github.run_id }}-${{ github.run_attempt }}-release' run: '.release-workflow/.github/scripts/run-release-docker-integration.sh cleanup' audio_capture_prebuilds: name: 'Audio Capture Prebuilds' needs: 'prepare' if: |- ${{ github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.audio_artifact_id == '' }} uses: './.github/workflows/audio-capture-prebuilds.yml' with: ref: '${{ needs.prepare.outputs.release_sha }}' cache_key: '${{ needs.prepare.outputs.audio_prebuild_key }}' build_release: name: 'Build Release Artifacts' # Reserve release-labelled runner slots before enabling this lane. runs-on: ['self-hosted', 'linux', 'ecs-qwen-release-host'] timeout-minutes: 90 needs: [ 'prepare', 'quality', 'integration_none', 'integration_docker', 'audio_capture_prebuilds', ] if: |- ${{ always() && github.repository == 'QwenLM/qwen-code' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == 'true' && needs.prepare.result == 'success' && ( needs.audio_capture_prebuilds.result == 'success' || (needs.audio_capture_prebuilds.result == 'skipped' && needs.prepare.outputs.audio_artifact_id != '') ) && ( github.event.inputs.force_skip_tests == 'true' || ( needs.quality.result == 'success' && needs.integration_none.result == 'success' && needs.integration_docker.result == 'success' ) ) }} permissions: contents: 'read' actions: 'read' outputs: release_commit: '${{ steps.handoff.outputs.release_commit }}' artifact_id: '${{ steps.upload.outputs.artifact-id }}' steps: - *restore_release_workspace - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: ref: '${{ needs.prepare.outputs.release_sha }}' fetch-depth: 0 persist-credentials: false - *release_disk_floor_gate - name: 'Use pre-installed Node.js (self-hosted)' uses: './.github/actions/self-hosted-node' - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' run: |- corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only npm run postinstall - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Prepare release branch' env: RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: '.release-workflow/.github/scripts/run-release-step.sh prepare-release-branch' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Commit release version locally' env: RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: '.release-workflow/.github/scripts/run-release-step.sh commit-release-branch' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - &verify_reused_audio name: 'Verify reusable audio source' if: "${{ needs.prepare.outputs.audio_artifact_id != '' }}" uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 env: RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}' AUDIO_ARTIFACT_ID: '${{ needs.prepare.outputs.audio_artifact_id }}' with: script: |- const { audioPrebuildKey, verifyAudioArtifact } = await import(process.env.GITHUB_WORKSPACE + '/.release-workflow/.github/scripts/audio-prebuilds.mjs'); const key = audioPrebuildKey(process.env.GITHUB_WORKSPACE, process.env.RELEASE_SHA, process.env.GITHUB_WORKSPACE + '/.release-workflow'); const { data: artifact } = await github.rest.actions.getArtifact({ ...context.repo, artifact_id: Number(process.env.AUDIO_ARTIFACT_ID) }); await verifyAudioArtifact(github, context.repo, artifact, key); - &download_reused_audio name: 'Download reusable audio prebuilds' if: "${{ needs.prepare.outputs.audio_artifact_id != '' }}" uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: artifact-ids: '${{ needs.prepare.outputs.audio_artifact_id }}' run-id: '${{ needs.prepare.outputs.audio_run_id }}' github-token: '${{ github.token }}' merge-multiple: true path: 'packages/audio-capture/prebuilds' - &download_fresh_audio name: 'Download audio capture prebuilds' if: |- ${{ github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.audio_artifact_id == '' }} uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: name: 'audio-capture-prebuilds' path: 'packages/audio-capture/prebuilds' - &validate_audio name: 'Validate complete audio prebuilds' if: "${{ github.repository == 'QwenLM/qwen-code' }}" run: 'node .release-workflow/.github/scripts/audio-prebuilds.mjs validate packages/audio-capture/prebuilds' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Build Bundle and Prepare Package' env: QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1' run: '.release-workflow/.github/scripts/run-release-step.sh build-package' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Build Standalone Archives' env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: '1' QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD: '1' OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}" run: '.release-workflow/.github/scripts/run-release-step.sh build-archives' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Verify Standalone Archives' env: OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}" run: '.release-workflow/.github/scripts/run-release-step.sh verify-archives' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Pack release handoff' id: 'handoff' env: RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}' BRANCH_NAME: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}" run: '.release-workflow/.github/scripts/run-release-step.sh pack-release' - name: 'Upload release handoff' id: 'upload' uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1 with: name: 'release-publish-build' path: '${{ runner.temp }}/release-publish-build/' if-no-files-found: 'error' overwrite: true retention-days: 3 publish: name: 'Publish Release' # npm trusted publishing and provenance require GitHub-hosted runners. runs-on: 'ubuntu-latest' needs: - 'prepare' - 'quality' - 'integration_none' - 'integration_docker' - 'audio_capture_prebuilds' - 'build_release' if: |- ${{ always() && needs.prepare.result == 'success' && ( needs.build_release.result == 'success' || (needs.build_release.result == 'skipped' && (github.repository != 'QwenLM/qwen-code' || vars.ECS_RELEASE_HOST_RUNNER_ENABLED != 'true')) ) && ( github.repository != 'QwenLM/qwen-code' || needs.audio_capture_prebuilds.result == 'success' || (needs.audio_capture_prebuilds.result == 'skipped' && needs.prepare.outputs.audio_artifact_id != '') ) && ( github.event.inputs.force_skip_tests == 'true' || ( needs.quality.result == 'success' && needs.integration_none.result == 'success' && needs.integration_docker.result == 'success' ) ) }} outputs: version_refusal: '${{ steps.push_release_branch.outputs.version_refusal }}' # Serialize each real/dry release tag; never cancel an active publish. concurrency: group: 'release-publish-${{ needs.prepare.outputs.release_tag }}-${{ needs.prepare.outputs.is_dry_run }}' cancel-in-progress: false timeout-minutes: 90 environment: name: 'production-release' url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.release_tag }}' permissions: contents: 'write' actions: 'read' issues: 'write' packages: 'write' id-token: 'write' pull-requests: 'write' steps: - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: # Read-only fetch with the job token; the bot PAT is only exported # to the write steps. token: '${{ github.token }}' ref: '${{ needs.prepare.outputs.release_sha }}' fetch-depth: 0 persist-credentials: false - *checkout_release_workflow_scripts - name: 'Download release handoff' if: "${{ needs.build_release.result == 'success' }}" uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1 with: artifact-ids: '${{ needs.build_release.outputs.artifact_id }}' merge-multiple: true path: '${{ runner.temp }}/release-publish-build' - name: 'Restore release commit and outputs' if: "${{ needs.build_release.result == 'success' }}" env: RELEASE_SHA: '${{ needs.prepare.outputs.release_sha }}' RELEASE_COMMIT: '${{ needs.build_release.outputs.release_commit }}' RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: '.release-workflow/.github/scripts/run-release-step.sh restore-release' - name: 'Setup Node.js (hosted)' uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' package-manager-cache: false registry-url: 'https://registry.npmjs.org' scope: '@qwen-code' - name: 'Install npm 11' run: 'npm install --global npm@11.19.0' - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' run: |- corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only npm run postinstall - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Prepare release branch' if: "${{ needs.build_release.result != 'success' }}" id: 'release_branch' env: RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: '.release-workflow/.github/scripts/run-release-step.sh prepare-release-branch' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Conditionally push release branch' id: 'push_release_branch' env: GITHUB_TOKEN: '${{ github.token }}' BRANCH_NAME: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}" IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}' run: '.release-workflow/.github/scripts/run-release-step.sh push-release-branch' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - *verify_reused_audio - *download_reused_audio - *download_fresh_audio - *validate_audio - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Build Bundle and Prepare Package' if: "${{ needs.build_release.result != 'success' }}" env: QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}" run: '.release-workflow/.github/scripts/run-release-step.sh build-package' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Build Standalone Archives' if: "${{ needs.build_release.result != 'success' }}" env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}" QWEN_STANDALONE_REQUIRE_NODE_PTY_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}" OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}" run: '.release-workflow/.github/scripts/run-release-step.sh build-archives' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Publish npm packages' env: RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' PUBLISH_AUDIO_CAPTURE: "${{ github.repository == 'QwenLM/qwen-code' }}" PUBLISH_EXTERNAL_CONTEXT_MEM0: "${{ github.repository == 'QwenLM/qwen-code' && vars.NPM_EXTERNAL_CONTEXT_MEM0_TRUSTED_PUBLISHING_ENABLED == 'true' }}" run: '.release-workflow/.github/scripts/run-release-step.sh publish-packages' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Verify Standalone Archives' env: OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}" run: '.release-workflow/.github/scripts/run-release-step.sh verify-archives' - *reset_release_workflow_scripts - *checkout_release_workflow_scripts - name: 'Auto-label internal CI PRs for release notes exclusion' continue-on-error: true if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: GITHUB_TOKEN: '${{ github.token }}' PREVIOUS_RELEASE_TAG: '${{ needs.prepare.outputs.previous_release_tag }}' run: '.release-workflow/.github/scripts/run-release-step.sh label-release-prs' - name: 'Create GitHub Release and Tag' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: # CI_BOT_PAT required: GITHUB_TOKEN events cannot trigger downstream release-event workflows. GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' RELEASE_BRANCH: "${{ format('release/{0}', needs.prepare.outputs.release_tag) }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' PREVIOUS_RELEASE_TAG: '${{ needs.prepare.outputs.previous_release_tag }}' IS_NIGHTLY: '${{ needs.prepare.outputs.is_nightly }}' IS_PREVIEW: '${{ needs.prepare.outputs.is_preview }}' run: '.release-workflow/.github/scripts/run-release-step.sh create-github-release' - name: 'Trigger ECS runner qwen update' if: |- ${{ github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.npm_tag == 'latest' }} continue-on-error: true env: GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}' RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' run: '.release-workflow/.github/scripts/run-release-step.sh dispatch-update' notify_failure: name: 'Notify Release Failure' # Hosted so failure reporting survives self-hosted pool failures. runs-on: 'ubuntu-latest' timeout-minutes: 10 needs: - 'prepare' - 'quality' - 'integration_none' - 'integration_docker' - 'audio_capture_prebuilds' - 'build_release' - 'publish' if: |- ${{ always() && ( github.event_name == 'schedule' || github.event.inputs.dry_run != 'true' ) && ( needs.prepare.result == 'failure' || needs.audio_capture_prebuilds.result == 'failure' || needs.build_release.result == 'failure' || needs.quality.result == 'failure' || needs.integration_none.result == 'failure' || needs.integration_docker.result == 'failure' || ( needs.publish.result == 'failure' && needs.publish.outputs.version_refusal != 'true' ) ) }} permissions: actions: 'write' contents: 'read' issues: 'write' steps: - *checkout_release_workflow_scripts # The notifier is the one job that must survive whatever broke the # others, and the extraction gave it a dependency it did not have at # base: a checkout of `github.workflow_sha`, plus a shell script that a # syntax error or a dropped exec bit can take out wholesale. Neither the # checkout nor the runner is allowed to swallow the report, so this step # runs regardless, absorbs its own failure, and hands off to the inline # fallback below. - name: 'Create Issue on Failure' id: 'notify' if: '${{ always() }}' continue-on-error: true env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GH_REPO: '${{ github.repository }}' RELEASE_TAG: "${{ needs.prepare.outputs.release_tag || 'N/A' }}" DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' BUG_LABEL: 'type/bug' READY_FOR_AGENT_LABEL: 'status/ready-for-agent' AUTOFIX_APPROVED_LABEL: 'autofix/approved' PREPARE_RESULT: '${{ needs.prepare.result }}' QUALITY_RESULT: '${{ needs.quality.result }}' INTEGRATION_NONE_RESULT: '${{ needs.integration_none.result }}' INTEGRATION_DOCKER_RESULT: '${{ needs.integration_docker.result }}' AUDIO_PREBUILDS_RESULT: '${{ needs.audio_capture_prebuilds.result }}' BUILD_RELEASE_RESULT: '${{ needs.build_release.result }}' PUBLISH_RESULT: '${{ needs.publish.result }}' run: '.release-workflow/.github/scripts/run-release-step.sh notify-failure' # Inline on purpose: this runs precisely when the trusted checkout or the # extracted runner is what failed, so it may not depend on either. It # files a human-triage issue with only the bug label, no reuse or autofix. - name: 'File a fallback failure issue' if: "${{ always() && steps.notify.outcome != 'success' && !steps.notify.outputs.issue_url }}" env: GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GH_REPO: '${{ github.repository }}' RELEASE_TAG: "${{ needs.prepare.outputs.release_tag || 'N/A' }}" DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' run: |- echo "::warning::Primary notifier did not succeed; filing a fallback issue." gh issue create --repo "${GH_REPO}" \ --title "Release Failed for ${RELEASE_TAG} on $(date -u +%Y-%m-%d)" \ --label 'type/bug' \ --body "The release workflow failed and the primary failure notifier did not run to completion. Run: ${DETAILS_URL}" reconcile_failure: name: 'Close Stale Release Failure Issue' # Success-gated sibling of notify_failure: that job is skipped the moment a # re-run passes, which is exactly why the issue it filed outlives the # failure (#15705). Hosted for the same reason as the notifier. runs-on: 'ubuntu-latest' timeout-minutes: 10 needs: - 'prepare' - 'quality' - 'integration_none' - 'integration_docker' - 'audio_capture_prebuilds' - 'build_release' - 'publish' if: |- ${{ always() && ( github.event_name == 'schedule' || github.event.inputs.dry_run != 'true' ) && needs.publish.result == 'success' && needs.prepare.result != 'failure' && needs.audio_capture_prebuilds.result != 'failure' && needs.build_release.result != 'failure' && needs.quality.result != 'failure' && needs.integration_none.result != 'failure' && needs.integration_docker.result != 'failure' }} permissions: contents: 'read' issues: 'write' steps: - *checkout_release_workflow_scripts - name: 'Close the issue an earlier attempt filed' continue-on-error: true env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GH_REPO: '${{ github.repository }}' RELEASE_TAG: "${{ needs.prepare.outputs.release_tag || 'N/A' }}" DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' RUN_ATTEMPT: '${{ github.run_attempt }}' run: '.release-workflow/.github/scripts/run-release-step.sh reconcile-failure'