mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
ci(release): only file issues on failure and reconcile stale ones (#15852)
* ci(release): file failure issues only for failures and close stale ones The VSCode companion and Chrome extension notifiers filed an issue when a job was merely cancelled, and nothing closed that issue once a later attempt shipped, leaving a live failure report against a released version. Drop the cancelled arm from both notifiers and add a success-gated reconcile-failure job to each, reusing the shared release step. That step now takes the title prefix and release version so all three release workflows can close the bot-authored issue an earlier attempt filed. Fixes #15840 * test(release): pin the version boundary when closing a stale issue A nightly sibling of the same release sorts first in the gh issue list result, so dropping the " on " suffix from the title guard silently closes another release's issue. The fixture now covers that ordering and the boundary mutant is killed. * test(release): tie each reconcile prefix to the title its notifier files The fan-out suites asserted `FAILURE_TITLE_PREFIX` against a hand-written constant, so renaming the notifier's `--title` left both green while the real reconcile would match no issue and the stale release-failure issue would stay open against a shipped release (QwenLM/qwen-code#15852). Read the prefix out of the notifier's own `--title` argument and assert it starts with the prefix the reconcile job passes, instead of restating the literal a third time. * test(ci): pin the version token and separator in the release title contract reconcile-failure reads a notifier title through startswith(prefix + version + " on "), but the suite only pinned the prefix. A notifier that dropped the version or separated it with something other than " on " closed nothing while both suites stayed green, which is a silent failure of the stale-issue cleanup (#15840). Derive the rest of the title from the notifier's own --title, accept the version variable with or without its :-unknown default, and assert the reconcile job reads the same variable the notifier writes. --------- Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: 2SinceJuly <[email protected]>
This commit is contained in:
4 files changed
+286
-12
No files matched your search
@@ -429,17 +429,24 @@ BODY
|
||||
# good (#15705). Bot authorship is a hard requirement: a human may have
|
||||
# filed the same title to track the flakiness, and closing their issue
|
||||
# would discard that follow-up.
|
||||
#
|
||||
# Shared with the VSCode companion and Chrome extension release workflows
|
||||
# (#15840), which file their own issues: they pass FAILURE_TITLE_PREFIX and
|
||||
# RELEASE_VERSION because their titles and version outputs differ from
|
||||
# release.yml's.
|
||||
release_version="${RELEASE_VERSION:-${RELEASE_TAG:-unknown}}"
|
||||
title_prefix="${FAILURE_TITLE_PREFIX:-Release Failed for }"
|
||||
existing_issue="$(
|
||||
gh issue list --repo "${GH_REPO}" \
|
||||
--state open \
|
||||
--search "\"Release Failed for ${RELEASE_TAG}\" in:title" \
|
||||
--search "\"${title_prefix}${release_version}\" in:title" \
|
||||
--limit 30 \
|
||||
--json number,url,labels,author,title \
|
||||
| jq -c --arg tag "${RELEASE_TAG}" \
|
||||
'[ .[] | select(.title | startswith("Release Failed for " + $tag + " on ")) | select(.author.login == "github-actions[bot]") ][0] // empty'
|
||||
| jq -c --arg prefix "${title_prefix}" --arg version "${release_version}" \
|
||||
'[ .[] | select(.title | startswith($prefix + $version + " on ")) | select(.author.login == "github-actions[bot]") ][0] // empty'
|
||||
)"
|
||||
if [[ -z "${existing_issue}" ]]; then
|
||||
echo "No workflow-owned release-failure issue is open for ${RELEASE_TAG}; nothing to reconcile."
|
||||
echo "No workflow-owned release-failure issue is open for ${release_version}; nothing to reconcile."
|
||||
exit 0
|
||||
fi
|
||||
issue_number="$(jq -r '.number' <<< "${existing_issue}")"
|
||||
@@ -448,10 +455,10 @@ BODY
|
||||
cat > "${body_file}" <<BODY
|
||||
The release completed successfully on a later attempt of this run, so the failure this issue reported is no longer current.
|
||||
|
||||
Release tag: ${RELEASE_TAG}
|
||||
Release tag: ${release_version}
|
||||
Run: ${DETAILS_URL} (attempt ${RUN_ATTEMPT:-unknown})
|
||||
|
||||
Closing as not planned. Reopen if the ${RELEASE_TAG} artifacts later prove incomplete.
|
||||
Closing as not planned. Reopen if the ${release_version} artifacts later prove incomplete.
|
||||
BODY
|
||||
gh issue comment "${issue_number}" --repo "${GH_REPO}" --body-file "${body_file}" || {
|
||||
echo "::warning::Failed to comment on ${issue_url}; closing regardless."
|
||||
|
||||
@@ -319,9 +319,7 @@ jobs:
|
||||
always() &&
|
||||
(
|
||||
needs.build.result == 'failure' ||
|
||||
needs.build.result == 'cancelled' ||
|
||||
needs.publish.result == 'failure' ||
|
||||
needs.publish.result == 'cancelled'
|
||||
needs.publish.result == 'failure'
|
||||
)
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
@@ -340,3 +338,45 @@ jobs:
|
||||
--repo "${GH_REPO}" \
|
||||
--title "Chrome extension release failed for ${RELEASE_VERSION:-unknown} on $(date +'%Y-%m-%d')" \
|
||||
--body "The Chrome Web Store release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
|
||||
reconcile-failure:
|
||||
name: 'Close Stale Failure Issue'
|
||||
# Success-gated sibling of report-failure: a re-run that passes skips the
|
||||
# notifier, which is why the issue an earlier attempt filed would otherwise
|
||||
# stay open against a shipped release (#15840).
|
||||
needs:
|
||||
- 'build'
|
||||
- 'publish'
|
||||
if: |-
|
||||
${{
|
||||
always() &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.publish.result == 'success'
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- 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
|
||||
sparse-checkout-cone-mode: false
|
||||
path: '.release-workflow'
|
||||
|
||||
- name: 'Close the issue an earlier attempt filed'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
RELEASE_VERSION: '${{ needs.build.outputs.release_version }}'
|
||||
FAILURE_TITLE_PREFIX: 'Chrome extension release failed for '
|
||||
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'
|
||||
@@ -422,9 +422,7 @@ jobs:
|
||||
always() &&
|
||||
(
|
||||
needs.build.result == 'failure' ||
|
||||
needs.build.result == 'cancelled' ||
|
||||
needs.publish.result == 'failure' ||
|
||||
needs.publish.result == 'cancelled'
|
||||
needs.publish.result == 'failure'
|
||||
)
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
@@ -443,3 +441,47 @@ jobs:
|
||||
--repo "${GH_REPO}" \
|
||||
--title "VSCode IDE Companion Release Failed for ${RELEASE_VERSION} on $(date +'%Y-%m-%d')" \
|
||||
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
|
||||
reconcile-failure:
|
||||
name: 'Close Stale Failure Issue'
|
||||
# Success-gated sibling of report-failure: a re-run that passes skips the
|
||||
# notifier, which is why the issue an earlier attempt filed would otherwise
|
||||
# stay open against a shipped release (#15840).
|
||||
needs:
|
||||
- 'prepare'
|
||||
- 'build'
|
||||
- 'publish'
|
||||
if: |-
|
||||
${{
|
||||
always() &&
|
||||
needs.prepare.result == 'success' &&
|
||||
needs.build.result == 'success' &&
|
||||
needs.publish.result == 'success'
|
||||
}}
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- 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
|
||||
sparse-checkout-cone-mode: false
|
||||
path: '.release-workflow'
|
||||
|
||||
- name: 'Close the issue an earlier attempt filed'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_REPO: '${{ github.repository }}'
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
FAILURE_TITLE_PREFIX: 'VSCode IDE Companion Release Failed for '
|
||||
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'
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const releaseStepScriptAbsolutePath = join(
|
||||
process.cwd(),
|
||||
'.github/scripts/run-release-step.sh',
|
||||
);
|
||||
|
||||
const workflows = [
|
||||
{
|
||||
path: '.github/workflows/release-vscode-companion.yml',
|
||||
titlePrefix: 'VSCode IDE Companion Release Failed for ',
|
||||
},
|
||||
{
|
||||
path: '.github/workflows/release-chrome-extension.yml',
|
||||
titlePrefix: 'Chrome extension release failed for ',
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(workflows)('$path failure reporting', ({ path, titlePrefix }) => {
|
||||
const yml = parse(readFileSync(path, 'utf8'));
|
||||
|
||||
it('files an issue for a failure but not for a cancelled run', () => {
|
||||
const job = yml.jobs['report-failure'];
|
||||
expect(job.if).toContain('always()');
|
||||
expect(job.if).toContain("needs.build.result == 'failure'");
|
||||
expect(job.if).toContain("needs.publish.result == 'failure'");
|
||||
// A cancelled run ships nothing and must not file an issue (#15840).
|
||||
expect(job.if).not.toContain("'cancelled'");
|
||||
});
|
||||
|
||||
it('closes the issue an earlier attempt filed once a re-run passes', () => {
|
||||
const job = yml.jobs['reconcile-failure'];
|
||||
// report-failure is skipped on a passing re-run, so the close path has to
|
||||
// be a sibling gated on success rather than a step inside it.
|
||||
expect(job.if).toContain('always()');
|
||||
expect(job.if).toContain("needs.build.result == 'success'");
|
||||
expect(job.if).toContain("needs.publish.result == 'success'");
|
||||
expect(job.permissions).toEqual({ contents: 'read', issues: 'write' });
|
||||
|
||||
const checkout = job.steps.find(
|
||||
(step) => step.name === 'Checkout release workflow scripts',
|
||||
);
|
||||
// The shared script is pinned to the workflow's own commit.
|
||||
expect(checkout.with.ref).toBe('${{ github.workflow_sha }}');
|
||||
expect(checkout.with.path).toBe('.release-workflow');
|
||||
|
||||
const step = job.steps.find(
|
||||
(entry) => entry.name === 'Close the issue an earlier attempt filed',
|
||||
);
|
||||
expect(step.run).toBe(
|
||||
'.release-workflow/.github/scripts/run-release-step.sh reconcile-failure',
|
||||
);
|
||||
expect(step.env.FAILURE_TITLE_PREFIX).toBe(titlePrefix);
|
||||
// A reconciliation failure must never turn a shipped release red.
|
||||
expect(step['continue-on-error']).toBe(true);
|
||||
|
||||
// The reader's prefix has to be the one the notifier actually writes, or
|
||||
// the close silently matches nothing and the stale issue stays open. Take
|
||||
// it from the notifier's own `--title` rather than restating the literal a
|
||||
// third time.
|
||||
const notifier = yml.jobs['report-failure'].steps.find(
|
||||
(entry) => entry.name === 'Create failure issue',
|
||||
);
|
||||
const title = notifier.run.match(/--title "([^"]*)"/)?.[1];
|
||||
expect(title, notifier.run).toBeDefined();
|
||||
expect(title.startsWith(titlePrefix), title).toBe(true);
|
||||
|
||||
// The prefix alone is not the contract: `run-release-step.sh` filters on
|
||||
// `startswith(prefix + version + " on ")`, so a title that stops before
|
||||
// the version, or separates it with something other than " on ", closes
|
||||
// nothing while both suites stay green. The notifier interpolates its
|
||||
// version variable with or without a `:-unknown` default, so accept both.
|
||||
expect(title.slice(titlePrefix.length)).toMatch(
|
||||
/^\$\{RELEASE_VERSION(?::-unknown)?\} on \$\(date \+'%Y-%m-%d'\)$/,
|
||||
);
|
||||
// Both sides must read the same version variable, or the reader looks for
|
||||
// a value the writer never wrote.
|
||||
const reconcileStep = yml.jobs['reconcile-failure'].steps.find(
|
||||
(entry) => entry.name === 'Close the issue an earlier attempt filed',
|
||||
);
|
||||
expect(notifier.env.RELEASE_VERSION).toBe(
|
||||
reconcileStep.env.RELEASE_VERSION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile-failure title matching', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'closes only the issue matching the given prefix and version',
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'release-fanout-'));
|
||||
const bin = join(directory, 'bin');
|
||||
const ghLog = join(directory, 'gh-calls');
|
||||
const issues = join(directory, 'issues.json');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(
|
||||
issues,
|
||||
JSON.stringify([
|
||||
{
|
||||
number: 303,
|
||||
url: 'https://github.example/QwenLM/qwen-code/issues/303',
|
||||
labels: [],
|
||||
author: { login: 'github-actions[bot]' },
|
||||
title:
|
||||
'Chrome extension release failed for v1.2.3-nightly.20261028 on 2026-10-28',
|
||||
},
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.example/QwenLM/qwen-code/issues/101',
|
||||
labels: [],
|
||||
author: { login: 'github-actions[bot]' },
|
||||
title:
|
||||
'VSCode IDE Companion Release Failed for v1.2.3 on 2026-10-28',
|
||||
},
|
||||
{
|
||||
number: 202,
|
||||
url: 'https://github.example/QwenLM/qwen-code/issues/202',
|
||||
labels: [],
|
||||
author: { login: 'github-actions[bot]' },
|
||||
title: 'Chrome extension release failed for v1.2.3 on 2026-10-28',
|
||||
},
|
||||
]),
|
||||
);
|
||||
writeFileSync(
|
||||
join(bin, 'gh'),
|
||||
'#!/bin/sh\n' +
|
||||
'printf "%s\\n" "$*" >> "$GH_CALL_LOG"\n' +
|
||||
'if [ "$1" = issue ] && [ "$2" = list ]; then\n' +
|
||||
' cat "$GH_ISSUES"\n' +
|
||||
' exit 0\n' +
|
||||
'fi\n' +
|
||||
'if [ "$1" = issue ] && [ "$2" = edit ]; then\n' +
|
||||
' exit 1\n' +
|
||||
'fi\n' +
|
||||
'exit 0\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[releaseStepScriptAbsolutePath, 'reconcile-failure'],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
GH_CALL_LOG: ghLog,
|
||||
GH_ISSUES: issues,
|
||||
GH_REPO: 'QwenLM/qwen-code',
|
||||
RELEASE_VERSION: 'v1.2.3',
|
||||
FAILURE_TITLE_PREFIX: 'Chrome extension release failed for ',
|
||||
DETAILS_URL: 'https://github.example/run/1',
|
||||
RUN_ATTEMPT: '2',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const calls = readFileSync(ghLog, 'utf8');
|
||||
expect(calls).toContain('issue close 202');
|
||||
expect(calls).not.toContain('issue close 101');
|
||||
expect(calls).not.toContain('issue close 303');
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user