qwen-code/.github/workflows/release.yml
易良 e48999ddb2
fix(ci): require maintainer-applied autofix/approved label for tier-1 fast-path (#6276)
* fix(ci): require maintainer-applied `autofix/approved` label for tier-1 fast-path

The scheduled scan and `issues:labeled` event paths in `qwen-autofix.yml`
previously trusted the `status/ready-for-agent` label alone, which is
applied by an LLM reading untrusted issue text. An attacker could craft
issue content to trick the triage LLM into labeling it ready, bypassing
human review.

Introduce an `autofix/approved` label that only maintainers (write+
permission) can apply. Both the cron scan and event-triggered paths now
require this label alongside `status/ready-for-agent` before entering the
autonomous fix pipeline (dual-factor: LLM signal + human confirmation).

Also:
- `release.yml` automatically adds `autofix/approved` on workflow-created
  release-failure issues (trusted source, preserves existing automation)
- Forced-issue path (non-dispatch) also checks for the new label
- Claim step strips `autofix/approved` to keep processed issues clean

Closes #5634

* fix(ci): address autofix approval review

* fix(ci): address autofix approval follow-ups

* test(ci): cover autofix approval gate

* fix(ci): harden autofix approval revalidation

* fix(ci): avoid autofix approval retry loop

* fix(ci): harden autofix claim handling

* fix(ci): clarify autofix reapproval label

* fix(ci): make autofix claim label update non-destructive
2026-07-04 08:06:27 +00:00

724 lines
28 KiB
YAML

name: 'Release'
on:
schedule:
# Runs every day at midnight UTC for the nightly release.
- cron: '0 0 * * *'
# Runs every Tuesday at 23:59 UTC for the preview release.
- cron: '59 23 * * 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
jobs:
prepare:
name: 'Prepare Release Metadata'
runs-on: 'ubuntu-latest'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: 'read'
outputs:
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:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Set booleans for simplified logic'
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: |-
is_nightly="false"
if [[ "${CRON}" == "0 0 * * *" || "${CREATE_NIGHTLY_RELEASE}" == "true" ]]; then
is_nightly="true"
fi
echo "is_nightly=${is_nightly}" >> "${GITHUB_OUTPUT}"
is_preview="false"
if [[ "${CRON}" == "59 23 * * 2" || "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
is_preview="true"
fi
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
is_dry_run="false"
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
is_dry_run="true"
fi
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
- name: 'Setup Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --no-audit --progress=false
- 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: |-
VERSION_ARGS=()
if [[ "${IS_NIGHTLY}" == "true" ]]; then
VERSION_ARGS+=(--type=nightly)
elif [[ "${IS_PREVIEW}" == "true" ]]; then
VERSION_ARGS+=(--type=preview)
if [[ -n "${MANUAL_VERSION}" ]]; then
MANUAL_CLEAN="${MANUAL_VERSION#v}"
if [[ "${MANUAL_CLEAN}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-preview\.[0-9]+$ ]]; then
VERSION_ARGS+=("--preview_version_override=${MANUAL_CLEAN}")
elif [[ "${MANUAL_CLEAN}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
VERSION_ARGS+=("--preview_version_override=${MANUAL_CLEAN}-preview.0")
else
echo "::error::For preview releases, version must be X.Y.Z or X.Y.Z-preview.N; got ${MANUAL_VERSION}"
exit 1
fi
fi
else
VERSION_ARGS+=(--type=stable)
if [[ -n "${MANUAL_VERSION}" ]]; then
VERSION_ARGS+=("--stable_version_override=${MANUAL_VERSION}")
fi
fi
VERSION_JSON=$(node scripts/get-release-version.js "${VERSION_ARGS[@]}")
echo "RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .releaseTag)" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=$(echo "$VERSION_JSON" | jq -r .releaseVersion)" >> "$GITHUB_OUTPUT"
echo "NPM_TAG=$(echo "$VERSION_JSON" | jq -r .npmTag)" >> "$GITHUB_OUTPUT"
echo "PREVIOUS_RELEASE_TAG=$(echo "$VERSION_JSON" | jq -r .previousReleaseTag)" >> "$GITHUB_OUTPUT"
quality:
name: 'Quality Checks'
runs-on: 'ubuntu-latest'
needs: 'prepare'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
permissions:
contents: 'read'
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --no-audit --progress=false
- name: 'Format Project'
run: |-
npm run format
- name: 'Run Lint'
run: |-
npm run lint:ci
- name: 'Check Serve Fast Path Bundle'
run: |-
npm run check:serve-fast-path-bundle
- name: 'Build Project'
run: |-
npm run build
- name: 'Typecheck Project'
run: |-
npm run typecheck
- name: 'Run Workspace Tests'
run: |-
npm run test:release
integration_none:
name: 'Integration Tests (No Sandbox)'
runs-on: 'ubuntu-latest'
needs: 'prepare'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
permissions:
contents: 'read'
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --no-audit --progress=false
- name: 'Build Bundle'
run: |-
npm run build
npm run bundle
- name: 'Run CLI Integration Tests'
run: |-
npm run test:integration:cli:sandbox:none
- name: 'Run Interactive Integration Tests'
run: |-
npm run test:integration:interactive:sandbox:none
integration_docker:
name: 'Integration Tests (Docker)'
runs-on: 'ubuntu-latest'
needs: 'prepare'
if: |-
${{ github.event.inputs.force_skip_tests != 'true' }}
permissions:
contents: 'read'
env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --no-audit --progress=false
- name: 'Build Bundle'
run: |-
npm run build
npm run bundle
- name: 'Set up Docker'
uses: 'docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5' # ratchet:docker/setup-buildx-action@v4
- name: 'Build Sandbox'
env:
QWEN_SANDBOX: 'docker'
run: |-
npm run build:sandbox -- -s
- name: 'Run CLI Docker Integration Tests'
run: |-
# The package.json docker test scripts each rebuild the sandbox image.
# Run vitest directly here so this job reuses the image built above.
QWEN_SANDBOX=docker npx vitest run --root ./integration-tests cli
- name: 'Run Interactive Docker Integration Tests'
run: |-
QWEN_SANDBOX=docker npx vitest run --root ./integration-tests interactive
audio_capture_prebuilds:
name: 'Audio Capture Prebuilds'
needs: 'prepare'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
uses: './.github/workflows/audio-capture-prebuilds.yml'
publish:
name: 'Publish Release'
runs-on: 'ubuntu-latest'
needs:
- 'prepare'
- 'quality'
- 'integration_none'
- 'integration_docker'
- 'audio_capture_prebuilds'
if: |-
${{
always() &&
needs.prepare.result == 'success' &&
(
github.repository != 'QwenLM/qwen-code' ||
needs.audio_capture_prebuilds.result == 'success'
) &&
(
github.event.inputs.force_skip_tests == 'true' ||
(
needs.quality.result == 'success' &&
needs.integration_none.result == 'success' &&
needs.integration_docker.result == 'success'
)
)
}}
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ needs.prepare.outputs.release_tag }}'
permissions:
contents: 'write'
packages: 'write'
id-token: 'write'
pull-requests: 'write'
issues: 'write'
steps:
- name: 'Checkout'
uses: 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' # v6.0.2
with:
# Persist the bot PAT for release-branch pushes so downstream CI
# workflows are triggered.
token: '${{ secrets.CI_BOT_PAT }}'
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Install Dependencies'
env:
NPM_CONFIG_PREFER_OFFLINE: 'true'
QWEN_SKIP_PREPARE: '1'
run: |-
npm ci --no-audit --progress=false
- name: 'Configure Git User'
run: |-
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config core.hooksPath .husky
- name: 'Create and switch to a release branch'
id: 'release_branch'
env:
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
run: |-
BRANCH_NAME="release/${RELEASE_TAG}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions'
env:
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
run: |-
npm run release:version "${RELEASE_VERSION}"
- name: 'Commit and Conditionally Push package versions'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}'
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
run: |-
git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(release): ${RELEASE_TAG}"
fi
if [[ "${IS_DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Download audio capture prebuilds'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'audio-capture-prebuilds'
path: 'packages/audio-capture/prebuilds'
- name: 'Build Bundle and Prepare Package'
env:
QWEN_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
run: |-
npm run build
npm run bundle
npm run prepare:package
- name: 'Build Standalone Archives'
env:
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone'
- name: 'Publish @qwen-code/audio-capture'
if: |-
${{ github.repository == 'QwenLM/qwen-code' }}
working-directory: 'packages/audio-capture'
run: |-
npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Publish @qwen-code/qwen-code'
working-directory: 'dist'
run: |-
npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Publish @qwen-code/channel-base'
working-directory: 'packages/channels/base'
run: |-
npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Verify Standalone Archives'
run: |-
npm run verify:installation-release -- --dir dist/standalone
- 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 workflows (sync-release-to-oss.yml).
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
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: |-
PRERELEASE_FLAG=""
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
PRERELEASE_FLAG="--prerelease"
fi
gh release create "${RELEASE_TAG}" \
dist/cli.js \
dist/standalone/qwen-code-* \
dist/standalone/SHA256SUMS \
--target "${RELEASE_BRANCH}" \
--title "Release ${RELEASE_TAG}" \
--notes-start-tag "${PREVIOUS_RELEASE_TAG}" \
--generate-notes \
${PRERELEASE_FLAG}
- name: 'Regenerate CHANGELOG.md'
# Stable releases only: nightly/preview ship daily and would drown out
# the changelog. The just-created GitHub Release is already queryable,
# so the generator picks it up. The release branch was already pushed
# above, so push this follow-up commit too — otherwise the PR opened
# below (whose head is the remote branch) would not include it.
#
# Non-blocking by design: the only realistic failures are transient
# (the gh API read or the git push). The changelog is rebuilt from the
# full release history on every run, so a skipped update self-heals on
# the next stable release — never worth blocking the version-bump PR to
# main that follows.
if: |-
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
continue-on-error: true
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
run: |-
set -euo pipefail
node scripts/generate-changelog.js
git add CHANGELOG.md
if git diff --cached --quiet -- CHANGELOG.md; then
echo "CHANGELOG.md already up to date."
else
git commit -m "docs(changelog): sync for ${RELEASE_TAG}"
git push origin "${BRANCH_NAME}"
fi
- name: 'Create PR to merge release branch into main'
if: |-
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
run: |-
set -euo pipefail
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
if [[ -z "${pr_url}" ]]; then
pr_url="$(gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--label 'skip-changelog' \
--title "chore(release): ${RELEASE_TAG}" \
--body "Automated release PR for ${RELEASE_TAG}. Syncs package.json versions and CHANGELOG.md on main.")"
fi
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Approve release PR'
if: |-
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
env:
# Separate bot account from the PR author (CI_BOT_PAT) so GitHub
# allows the approval; covers one of the two required reviews.
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr review "${PR_URL}" --approve --body "Automated approval for the release version bump."
- name: 'Enable auto-merge for release PR'
if: |-
${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}'
run: |-
set -euo pipefail
# Keep [skip ci] only on the squash commit that lands on main. The
# release branch commit and PR title intentionally omit it so tag-push
# workflows and PR metadata stay unaffected.
#
# No --delete-branch: main has a merge queue, and gh rejects
# --delete-branch when one is enabled (the queue owns the merge, so
# head-branch deletion is governed by the repo's
# "Automatically delete head branches" setting instead).
gh pr merge "${PR_URL}" \
--squash \
--auto \
--subject "chore(release): ${RELEASE_TAG} [skip ci]"
notify_failure:
name: 'Notify Release Failure'
runs-on: 'ubuntu-latest'
needs:
- 'prepare'
- 'quality'
- 'integration_none'
- 'integration_docker'
- 'publish'
if: |-
${{
always() &&
(
github.event_name == 'schedule' ||
github.event.inputs.dry_run != 'true'
) &&
(
needs.prepare.result == 'failure' ||
needs.quality.result == 'failure' ||
needs.integration_none.result == 'failure' ||
needs.integration_docker.result == 'failure' ||
needs.publish.result == 'failure'
)
}}
permissions:
actions: 'write'
issues: 'write'
steps:
- name: 'Create Issue on Failure'
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 }}'
PUBLISH_RESULT: '${{ needs.publish.result }}'
run: |-
failed_jobs="$(
for job in \
"prepare:${PREPARE_RESULT}" \
"quality:${QUALITY_RESULT}" \
"integration_none:${INTEGRATION_NONE_RESULT}" \
"integration_docker:${INTEGRATION_DOCKER_RESULT}" \
"publish:${PUBLISH_RESULT}"; do
name="${job%%:*}"
result="${job#*:}"
if [[ "${result}" == "failure" ]]; then
printf -- '- %s\n' "${name}"
fi
done
)"
if [[ -z "${failed_jobs}" ]]; then
failed_jobs='- unknown'
fi
body_file="$(mktemp)"
cat > "${body_file}" <<BODY
The release workflow failed.
Release tag: ${RELEASE_TAG}
Run: ${DETAILS_URL}
Failed job(s):
${failed_jobs}
BODY
# `in:title` is a fuzzy full-text search, so anchor the reuse on an
# exact "Release Failed for <tag> on " title prefix — otherwise a tag
# that is a prefix of another (v0.18.1 vs v0.18.10) could reuse the
# wrong release's issue. Prefer a workflow-owned (github-actions[bot])
# match so a same-titled human/foreign issue sorting first can't make
# us skip an existing bot issue and open duplicates.
existing_issue="$(
gh issue list --repo "${GH_REPO}" \
--state open \
--search "\"Release Failed for ${RELEASE_TAG}\" in:title" \
--limit 30 \
--json number,url,labels,author,title \
| jq -c --arg tag "${RELEASE_TAG}" \
'[ .[] | select(.title | startswith("Release Failed for " + $tag + " on ")) ] | (map(select(.author.login == "github-actions[bot]"))[0] // .[0]) // empty'
)"
gh label create "${AUTOFIX_APPROVED_LABEL}" --repo "${GH_REPO}" \
--description 'Maintainer explicitly approved this issue for autonomous autofix' \
--color '0e8a16' 2> /dev/null || true
if [[ -n "${existing_issue}" ]]; then
issue_number="$(jq -r '.number' <<<"${existing_issue}")"
issue_url="$(jq -r '.url' <<<"${existing_issue}")"
issue_author="$(jq -r '.author.login // ""' <<<"${existing_issue}")"
if [[ "${issue_author}" != "github-actions[bot]" ]]; then
echo "::warning::Existing ${issue_url} was opened by ${issue_author:-unknown}; creating a workflow-owned issue instead."
existing_issue=''
elif jq -e \
'(.labels // []) | map(.name) | any(. == "autofix/skip" or . == "autofix/in-progress")' \
<<<"${existing_issue}" > /dev/null; then
echo "::warning::Release failed but existing ${issue_url} has an autofix exclusion label; no autofix dispatched."
exit 0
else
gh issue comment "${issue_number}" --repo "${GH_REPO}" --body-file "${body_file}" \
|| echo "::warning::Failed to comment on existing issue #${issue_number}; proceeding with dispatch."
# Mirror the scheduled scan's exclusions for this release-forced
# dispatch: don't send the agent onto an issue a maintainer has
# taken over (assignee / linked PR / status/need-information /
# status/need-retesting). Fail closed — if the check can't run,
# skip the dispatch.
still_eligible="$(gh issue list --repo "${GH_REPO}" --state open \
--search "\"Release Failed for ${RELEASE_TAG}\" in:title no:assignee -linked:pr -label:status/need-information -label:status/need-retesting" \
--json number --jq "any(.[]; .number == ${issue_number})" || echo 'false')"
if [[ "${still_eligible}" != "true" ]]; then
echo "::warning::Reused ${issue_url} looks maintainer-owned (assignee / linked PR / need-information / need-retesting); skipping autofix dispatch."
exit 0
fi
# Ensure the fallback labels are present so that, if the dispatch
# below fails, the scheduled ready-for-agent scan can still find it.
# Safe to auto-apply approval: release-failure issue content is
# fully CI-generated, not user-controlled issue text.
gh issue edit "${issue_number}" --repo "${GH_REPO}" \
--add-label "${BUG_LABEL},${READY_FOR_AGENT_LABEL},${AUTOFIX_APPROVED_LABEL}" \
|| echo "::warning::Failed to ensure ${BUG_LABEL}/${READY_FOR_AGENT_LABEL}/${AUTOFIX_APPROVED_LABEL} on issue #${issue_number}."
fi
fi
if [[ -z "${existing_issue}" ]]; then
# Safe to auto-apply approval: release-failure issue content is
# fully CI-generated, not user-controlled issue text.
issue_url="$(gh issue create --repo "${GH_REPO}" \
--title "Release Failed for ${RELEASE_TAG} on $(date -u +'%Y-%m-%d')" \
--body-file "${body_file}" \
--label "${BUG_LABEL}" \
--label "${READY_FOR_AGENT_LABEL}" \
--label "${AUTOFIX_APPROVED_LABEL}")"
issue_number="${issue_url##*/}"
fi
echo "Using ${issue_url}; dispatching autofix."
if ! gh workflow run qwen-autofix.yml --repo "${GH_REPO}" --ref main \
-f phase=issue \
-f issue_number="${issue_number}" \
-f dry_run=false; then
echo "::warning::Autofix dispatch failed; scheduled autofix can still pick up issue #${issue_number}."
exit 1
fi