Files
qwen-code/scripts/tests/release-fanout-workflows.test.js
4d61487419 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]>
2026-11-07 12:52:38 +00:00

186 lines
6.7 KiB
JavaScript

/**
* @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 });
}
},
);
});