Files
812a8c7ccb fix(release): use the job token for the publish checkout (#15730)
* fix(release): use the job token for the publish checkout (#15727)

The publish checkout only reads the release SHA and disables persisted credentials, so the bot PAT it asked for was never used. Gating a read-only checkout on that secret turns any checkout-time auth failure into a failed release before any work runs; the push step already re-authenticates with the PAT it needs.

* fix(release): probe the bot PAT on dry runs (#15727)

The publish checkout no longer touches the bot PAT, so a dry-run rehearsal
stopped exercising the credential and a rotated PAT surfaced only on the
next live release. Probe it in a dry-run-only, non-blocking step placed
after the push step, where the live path legitimately holds it.

Widen the package-scripts pin so it catches any secret on the read-only
checkout and any CI_BOT_PAT consumer before the push step, not just the
exact step name and secret spelling. Bump release.yml's size baseline for
the added step.

* fix(release): check the bot PAT's push capability on dry runs (#15727)

* fix(release): drop the ineffective dry-run PAT probe (#15727)

The probe only ran on dry runs, where no release step consumes the bot
PAT, and its `|| echo` turned every failure into a green warning. Make
the publish checkout's job token explicit instead, and keep the pins
that no secret or bot PAT reaches it before the push step.

* test(release): pin the bot PAT out of the publish checkout (#15727)

The PR's Reviewer Test Plan names expect(checkoutStep).not.toContain('CI_BOT_PAT'), but neither test file contained it. Add the assertion before the job-token pin so the documented mutation fails on exactly the named assertion.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-10-31 20:51:11 +08:00

1072 lines
44 KiB
YAML

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 [email protected]'
- 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'