mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-23 07:35:13 +00:00
* fix(ci): route workflow label mutations through REST `gh pr edit` cannot mutate anything on this repository: its GraphQL lookup requests repository.pullRequest.projectCards, and with Projects (classic) attached GitHub returns the deprecation as an error, so the command exits 1 before applying the change. Reproduced from a live clone against PR #8755 — the error names the field outright. Three workflows carried label mutations through it: - pr-self-report-label.yml: every add/remove arm failed — 43 straight run failures from 2026-08-04 on; the green runs were all the nothing-to-do arm. Self-reported PRs (like #8755, whose author also opened #8750) never got the label. - qwen-autofix.yml: the `@qwen-code /takeover` and `/takeover stop` COMMAND paths never toggled the label — only the UI label events worked, so the command was dead weight wearing an ack. - repo-hygiene.yml: the add was `|| echo`-guarded, so it never failed the run — it just never labeled anything, while the fallback message blamed a label that exists. All five sites now use the REST issues/labels endpoints, which never touch that query. Two traps handled on the way: - Every label involved contains a slash, and in the DELETE the label is a PATH SEGMENT — unencoded it 404s. Encoded via jq @uri, and the tests assert the literal %2F because a real jq runs in the replay. - The REST add auto-creates a missing label, which repo-hygiene explicitly promises never to do — that site gets an existence probe first, and its misdiagnosing fallback message is corrected. Verified live on #8755 before editing anything: the exact gh pr edit call fails with the projectCards error; REST POST applies the label (backfilling the one it was owed), DELETE with %2F removes it. Tests: the stub-driven replays for both the self-report step and the takeover toggle now pin the full REST method + path (encoding included), and a repo-wide guard bans `gh pr edit --add-label/ --remove-label` in every workflow so the class cannot return. Mutation-tested, 6 of 6 caught: each of the five sites reverted to gh pr edit, and the DELETE stripped of its encoding. * fix(ci): harden REST label mutation steps per review (#8761) * fix(ci): pin REST label failure policies per review (#8761) Review round for the REST migration: - The DELETE arms tolerated EVERY failure (`|| true`), masking 403/5xx/network errors behind a green run and a false "removed" log. They now tolerate only the documented 404 race — any other failure emits a :⚠️: while keeping the step green (pr-self-report-label) and the release ack alive (qwen-autofix). - Neither replay harness could make a `gh api` call fail, so both failure policies were unpinned. They gain failure knobs (knob value on stderr like a real gh HTTP error) and now pin: 404 race silent, other DELETE failures warned, POST loud. The toggle replay also moves to -eo pipefail like the runner's bash default, reproducing the step's real failure semantics. - The jq stub enforced only the --arg shape; it now also enforces the `$l|@uri` program, so a filter mutation fails the suite instead of riding the stub's unconditional percent-encoding. - The gh-pr-edit guard misfired on comments and miscounted lines after joining continuations: comments are stripped before matching, and offenders are reported at the physical line where the (possibly wrapped) command starts. Mutation-tested with 8 probes, all caught: blanket || true on either DELETE, || true on either POST, dropped |@uri, a comment quoting the ban (stays green), an executable and a wrapped violation (both red, correct line). * Address review round 3: close the guard evasions, convert the release path Four round-3 findings, each reproduced before fixing, plus the release path the round-1 scope note deferred. - The ban guard now scans what bash executes, not the YAML surface: the decoded run: values of every parsed workflow, whole-line comments stripped, continuations joined the way bash joins them (backslash- newline removed, nothing inserted), matched whitespace-tolerantly. All three reproduced evasions — a # inside a quoted string eating the trailing backslash, wraps inside the command prefix or a flag token, and folded scalars — are fixture-pinned. Offenders report as file » job » step; line numbers stopped meaning anything after joins. - classify-release-notes.mjs mutates labels through REST now, and the guard grew an argv-form scan over .github/scripts/*.mjs that flags the old file (negative-controlled) — the release path was the last gh pr edit label site, failing silently behind continue-on-error. - JQ_STUB enforces the full invocation: -rn (with -r alone real jq evaluates zero inputs and prints nothing), the binding name l (real jq exits 3 on $l undefined), and the program. Either reproduced mutation previously expanded the substitution empty, sent the DELETE to …/labels/ with no name segment, and the 404 tolerance swallowed it. - The takeover engage POST gets the idempotent create its siblings carry, pinned to the label's real color (1D76DB): the REST add would re-create a deleted label silently with a random color. - runToggle captures writes on throw, and the engage-failure assertion now pins the ORDER its comment claims: a failing apply must leave no "takeover-ack engaged" in the captured writes — the bare toThrow passed even with the ack moved above the POST (reproduced). - The two REMOVE_ERR DELETE idioms are drift-pinned byte-identical modulo the label variable, the honest substitute for sharing shell across workflow files. Mutation-tested, 6 of 6 caught: the evadable regex restored, -rn and the binding name mutated in the workflow, the create dropped, the ack posted before the POST, and the old .mjs flagged by the new scan. --------- Co-authored-by: verify <verify@local> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
288 lines
8.9 KiB
JavaScript
288 lines
8.9 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, it } from 'node:test';
|
|
import { parse } from 'yaml';
|
|
import { shouldAutoSkipChangelog } from './classify-release-notes.mjs';
|
|
|
|
describe('release note classification', () => {
|
|
it('only skips internal CI changes', () => {
|
|
const cases = [
|
|
{
|
|
title: 'ci: speed up PR checks',
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci(autofix): harden review automation',
|
|
files: [
|
|
'.github/workflows/qwen-autofix.yml',
|
|
'.qwen/skills/autofix/SKILL.md',
|
|
'scripts/tests/qwen-autofix-workflow.test.js',
|
|
],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci: update test helpers',
|
|
files: ['packages/core/src/__tests__/utils.ts'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci: update vitest config',
|
|
files: ['packages/cli/vitest.config.ts'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci: update spec tests',
|
|
files: ['packages/core/src/auth.spec.ts'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci: update playwright config',
|
|
files: ['packages/e2e/playwright.config.ts'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci: update release classifier tests',
|
|
files: ['.github/scripts/classify-release-notes.test.mjs'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'Fix flaky CI routing',
|
|
labels: [{ name: 'scope/ci-cd' }],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'ci!: breaking dispatch change',
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'Update action permissions',
|
|
labels: ['scope/github-actions'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'fix(ci): repair check results',
|
|
labels: ['scope/ci-cd'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: fix check',
|
|
labels: ['bug'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: fix check',
|
|
labels: ['breaking-change'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: keep automatic exclusion stable',
|
|
labels: ['skip-changelog-auto'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: true,
|
|
},
|
|
{
|
|
title: 'Update docs',
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: empty',
|
|
files: [],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update release automation',
|
|
files: ['.github/workflows/finalize-release.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update release helper',
|
|
files: ['.github/scripts/publish-release.mjs'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update changelog helper',
|
|
files: ['.github/workflows/update-changelog.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update deploy pipeline',
|
|
files: ['.github/workflows/deploy-app.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: bump sync action',
|
|
files: ['.github/workflows/sync-labels.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update prebuild pipeline',
|
|
files: ['.github/workflows/prebuild.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update package pipeline',
|
|
files: ['.github/workflows/package-cli.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update installer pipeline',
|
|
files: ['.github/workflows/build-installer.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update artifact upload',
|
|
files: ['.github/workflows/upload-artifact.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update image build',
|
|
files: ['.github/scripts/build-image.mjs'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update cd pipeline',
|
|
files: ['.github/workflows/cd-pages.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: support a new platform build',
|
|
labels: ['category/platform'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: update checks and runtime',
|
|
files: ['.github/workflows/ci.yml', 'packages/core/src/index.ts'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: move a runtime file into automation',
|
|
files: ['.github/scripts/runtime.ts', 'packages/core/src/runtime.ts'],
|
|
expected: false,
|
|
},
|
|
{
|
|
title: 'ci: keep manual exclusion',
|
|
labels: ['skip-changelog'],
|
|
files: ['.github/workflows/ci.yml'],
|
|
expected: false,
|
|
},
|
|
];
|
|
|
|
for (const { expected, ...pullRequest } of cases) {
|
|
assert.equal(
|
|
shouldAutoSkipChangelog(pullRequest),
|
|
expected,
|
|
pullRequest.title,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('wires batch labeling and exclusion through release.yml', () => {
|
|
const release = readFileSync(
|
|
join(import.meta.dirname, '../workflows/release.yml'),
|
|
'utf8',
|
|
);
|
|
const changelog = readFileSync(
|
|
join(import.meta.dirname, '../release.yml'),
|
|
'utf8',
|
|
);
|
|
const workflow = parse(release);
|
|
const publish = workflow.jobs.publish;
|
|
const autoLabel = publish.steps.find(
|
|
(step) =>
|
|
step.name === 'Auto-label internal CI PRs for release notes exclusion',
|
|
);
|
|
|
|
assert.match(changelog, /- 'skip-changelog-auto'/);
|
|
assert.equal(autoLabel['continue-on-error'], true);
|
|
assert.equal(autoLabel.env.GITHUB_TOKEN, '${{ github.token }}');
|
|
assert.equal(publish.permissions.issues, 'write');
|
|
assert.equal(publish.permissions['pull-requests'], 'write');
|
|
assert.match(autoLabel.run, /classify-release-notes\.mjs/);
|
|
assert.match(autoLabel.run, /commits="\$\(git rev-list/);
|
|
assert.match(autoLabel.run, /Cannot enumerate commits/);
|
|
assert.match(autoLabel.run, /Failed to fetch PRs for commit/);
|
|
});
|
|
|
|
it('updates labels after a lookup failure and exits non-zero', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'release-note-classifier-'));
|
|
try {
|
|
const updates = join(dir, 'updates.txt');
|
|
const gh = join(dir, 'gh');
|
|
writeFileSync(
|
|
gh,
|
|
[
|
|
'#!/usr/bin/env node',
|
|
'const args = process.argv.slice(2);',
|
|
"if (args[0] === 'api' && args.includes('.[] | .filename, (.previous_filename // empty)')) {",
|
|
" if (args.some((arg) => arg.endsWith('/pulls/11/files'))) { process.stderr.write('lookup failed\\n'); process.exit(1); }",
|
|
" process.stdout.write('.github/workflows/ci.yml\\n');",
|
|
' process.exit(0);',
|
|
'}',
|
|
// Label mutations arrive as REST calls (gh pr edit is banned for
|
|
// labels — its projectCards lookup fails on affected gh builds).
|
|
"if (args[0] === 'api' && args[1] === '-X' && (args[2] === 'POST' || args[2] === 'DELETE') && /\\/issues\\/\\d+\\/labels/.test(args[3])) {",
|
|
" const action = args[2] === 'DELETE' ? 'remove' : 'add';",
|
|
" const number = args[3].match(/\\/issues\\/(\\d+)\\/labels/)[1];",
|
|
` require('node:fs').appendFileSync(${JSON.stringify(updates)}, number + ' ' + action + '\\n');`,
|
|
' process.exit(0);',
|
|
'}',
|
|
'process.exit(1);',
|
|
].join('\n'),
|
|
);
|
|
chmodSync(gh, 0o755);
|
|
|
|
const input = JSON.stringify([
|
|
{ number: 10, title: 'ci: speed up checks', labels: [] },
|
|
{ number: 11, title: 'ci: broken lookup', labels: [] },
|
|
{
|
|
number: 12,
|
|
title: 'fix: user-visible bug',
|
|
labels: [{ name: 'skip-changelog-auto' }],
|
|
},
|
|
{ number: 13, title: 'feat: new feature', labels: [] },
|
|
]);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[join(import.meta.dirname, 'classify-release-notes.mjs')],
|
|
{
|
|
encoding: 'utf8',
|
|
input,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stdout, /Labeled: 10/);
|
|
assert.match(result.stdout, /Unlabeled: 12/);
|
|
assert.match(result.stderr, /Failed to process PR #11/);
|
|
assert.match(result.stderr, /lookup failed/);
|
|
const updateContent = readFileSync(updates, 'utf8').trim();
|
|
assert.equal(updateContent, '10 add\n12 remove');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|