mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 14:46:19 +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>
144 lines
4.2 KiB
JavaScript
144 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const INTERNAL_LABELS = new Set([
|
|
'category/development',
|
|
'scope/build-system',
|
|
'scope/ci-cd',
|
|
'scope/github-actions',
|
|
'scope/testing',
|
|
]);
|
|
const AUTO_LABEL = 'skip-changelog-auto';
|
|
const RELEASE_AUTOMATION_RE =
|
|
/^\.github\/.*(?:changelog|release|publish|deploy|sync|prebuild|package|installer|artifact|image|cd-)/i;
|
|
const TEST_FILE_RE =
|
|
/(?:^|\/)(?:__tests__\/|[^/]+\.(?:test|spec)\.[^/]+$|(?:vitest|playwright)(?:\.[^/]+)?\.config\.[^/]+$)/;
|
|
|
|
export function shouldAutoSkipChangelog({ title, labels = [], files = [] }) {
|
|
const names = labels
|
|
.map((label) =>
|
|
(typeof label === 'string' ? label : label.name).toLowerCase(),
|
|
)
|
|
.filter((name) => name !== AUTO_LABEL);
|
|
if (names.includes('skip-changelog')) return false;
|
|
|
|
const subject = /^(\w+)(?:\([^)]*\))?(!)?:/.exec(title.trim());
|
|
const type = subject?.[1].toLowerCase();
|
|
if (
|
|
subject?.[2] ||
|
|
(type
|
|
? type !== 'ci'
|
|
: !names.some((label) =>
|
|
['scope/ci-cd', 'scope/github-actions'].includes(label),
|
|
)) ||
|
|
names.includes('bug') ||
|
|
names.includes('breaking-change') ||
|
|
names.some(
|
|
(label) =>
|
|
/^(?:type|category|scope)\//.test(label) && !INTERNAL_LABELS.has(label),
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
files.length > 0 &&
|
|
files.every(
|
|
(file) =>
|
|
TEST_FILE_RE.test(file) ||
|
|
(!RELEASE_AUTOMATION_RE.test(file) &&
|
|
(file.startsWith('.github/') || file.startsWith('.qwen/'))),
|
|
)
|
|
);
|
|
}
|
|
|
|
function fetchFiles(repo, number) {
|
|
return execFileSync(
|
|
'gh',
|
|
[
|
|
'api',
|
|
'--paginate',
|
|
`repos/${repo}/pulls/${number}/files`,
|
|
'--jq',
|
|
'.[] | .filename, (.previous_filename // empty)',
|
|
],
|
|
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
|
)
|
|
.split(/\r?\n/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function main() {
|
|
const repo = process.env.GITHUB_REPOSITORY || '';
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
throw new Error('GITHUB_REPOSITORY must be set to owner/repo.');
|
|
}
|
|
|
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
const prs = Array.isArray(input) ? input : [];
|
|
const labeled = [];
|
|
const unlabeled = [];
|
|
|
|
for (const pr of prs) {
|
|
const number = String(pr.number);
|
|
if (!/^[1-9]\d*$/.test(number)) continue;
|
|
try {
|
|
const files = fetchFiles(repo, number);
|
|
const shouldSkip = shouldAutoSkipChangelog({ ...pr, files });
|
|
const hasAutoLabel = pr.labels.some(
|
|
(label) =>
|
|
(typeof label === 'string' ? label : label.name).toLowerCase() ===
|
|
AUTO_LABEL,
|
|
);
|
|
// REST, not `gh pr edit`: that command's GraphQL lookup requests
|
|
// repository.pullRequest.projectCards, which GitHub rejects on gh
|
|
// builds that still send the query — the mutation then exits 1 before
|
|
// applying anything, and this step's continue-on-error turned that
|
|
// into a silent skip on every affected release. The REST label
|
|
// endpoints never touch that query. The label is a path segment in
|
|
// the DELETE, hence encodeURIComponent.
|
|
if (shouldSkip && !hasAutoLabel) {
|
|
execFileSync('gh', [
|
|
'api',
|
|
'-X',
|
|
'POST',
|
|
`repos/${repo}/issues/${number}/labels`,
|
|
'-f',
|
|
`labels[]=${AUTO_LABEL}`,
|
|
]);
|
|
labeled.push(number);
|
|
} else if (!shouldSkip && hasAutoLabel) {
|
|
execFileSync('gh', [
|
|
'api',
|
|
'-X',
|
|
'DELETE',
|
|
`repos/${repo}/issues/${number}/labels/${encodeURIComponent(AUTO_LABEL)}`,
|
|
]);
|
|
unlabeled.push(number);
|
|
}
|
|
} catch (error) {
|
|
process.exitCode = 1;
|
|
process.stderr.write(
|
|
`::warning::Failed to process PR #${number}: ${error.message}; skipping.\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (labeled.length > 0) {
|
|
process.stdout.write(`Labeled: ${labeled.join(', ')}\n`);
|
|
}
|
|
if (unlabeled.length > 0) {
|
|
process.stdout.write(`Unlabeled: ${unlabeled.join(', ')}\n`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
main();
|
|
}
|