mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(ci): isolate triage agent credentials and run all triage on ECS (#16468)
* feat(ci): isolate triage agent credentials and run all triage on ECS The triage agent carried a write PAT, so comment reruns and external fork-PR triage had to stay on ephemeral hosted runners while hosted capacity is tight and the ECS pool has headroom. Wire the token-isolation follow-up the workflow already documented: the agent reads GitHub with the job's read-only GITHUB_TOKEN and expresses every write as a draft; a separate publish job validates and executes drafts with the bot PAT, using one publisher script shared with local /triage runs. Contain the agent job, launch the CLI directly behind the hardened loopback model proxy, and move the deterministic maintainer resolver into the publisher. With no write credential in the agent environment, route every triage trigger to ECS behind the existing kill switch, and retire author_can_write. Markers, bot identity, and the finalize workflow's approve-on-green protocol are unchanged. Co-authored-by: Qwen-Coder <[email protected]> * fix(ci): bump triage CI budget and address triage review findings The new publish-triage job crossed the .ci-budget ratchet (7->8 jobs, 280->290 declared timeout-minutes), aborting Lint & Static before any lint or helper test ran. Bump the budget entry, mirror the artifact-name fallback expression on the upload side so an early-death run's drafts stay addressable, and enforce SKILL.md's process-label rule at the publisher chokepoint (welcome-pr, maintainer, help wanted, good first issue) with fail-closed tests, aligning the issue-workflow instruction that previously contradicted the rule. Co-authored-by: Qwen-Coder <[email protected]> * fix(ci): harden the triage draft pipeline after an adversarial audit Six findings from a multi-round pre-push audit, each with a mutation witness: kill lingering agent-user processes before the drafts strip/upload so a detached child cannot re-plant symlinks in the race window (upload-artifact follows them and RUNNER_TEMP is host-shared; unkillable survivors drop the draft set deny-by-default); lstat the draft containers themselves in the publisher preflight, since a symlinked bodies/ redirects the realpath containment base; reject cross-kind actions (review/close-pr on issues, close-issue on PRs); install util-linux for runuser as the sibling lanes do; make the mktemp'd proxy-wrapper directory traversable by the agent user so the gh/git wrappers do not silently fall out of its PATH; pin the artifact-name symmetry, the kill-before-strip ordering, and the toolchain install line. Design pair updated in both languages. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
1ea05364a6
commit
dd4515ca27
13 files changed
+3782
-405
No files matched your search
@@ -54,6 +54,39 @@ const cleanStep = steps.find((s) => s.name === 'Clean stale agent state');
|
||||
const triageOwnershipStep = steps.find(
|
||||
(s) => s.name === 'Restore workspace ownership',
|
||||
);
|
||||
const triageContextStep = steps.find((s) => s.name === 'Prepare agent context');
|
||||
const publishTriageJob = doc.jobs['publish-triage'];
|
||||
const publisherPath = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'triage-publish.mjs',
|
||||
);
|
||||
// The triage agent is a direct CLI launch now; its settings travel as the
|
||||
// heredoc the host writes into the fresh per-run QWEN_HOME. Extract and parse
|
||||
// them the same way the runtime will read them.
|
||||
const readTriageSettings = () => {
|
||||
assert.ok(
|
||||
triageContextStep,
|
||||
'triage must keep the Prepare agent context step',
|
||||
);
|
||||
const m = triageContextStep.run.match(
|
||||
/cat > "\$QWEN_HOME\/settings\.json" <<'SETTINGS_JSON'\n([\s\S]*?)\n\s*SETTINGS_JSON/,
|
||||
);
|
||||
assert.ok(
|
||||
m,
|
||||
'the context step must write settings.json through the SETTINGS_JSON heredoc',
|
||||
);
|
||||
const settings = JSON.parse(m[1]);
|
||||
// v1 top-level keys only work through runtime migration; write the native
|
||||
// v2 shape (the qwen-triage.yml convention).
|
||||
for (const key of ['coreTools', 'maxSessionTurns', 'sandbox']) {
|
||||
assert.equal(
|
||||
settings[key],
|
||||
undefined,
|
||||
`v1 top-level \`${key}\` is a legacy key — use the v2 shape`,
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
const verifyJob = doc.jobs.verify;
|
||||
const verifyOwnershipStep = verifyJob.steps.find(
|
||||
(s) => s.name === 'Restore workspace ownership',
|
||||
@@ -187,12 +220,22 @@ const assertSettingsContract = (step, label) => {
|
||||
};
|
||||
|
||||
describe('qwen-triage: agent tool/permission settings', () => {
|
||||
it('passes `settings:` (not the silently-dropped `settings_json:`)', () => {
|
||||
assertSettingsContract(triageStep, 'triage step (id: triage)');
|
||||
it('writes settings host-side; the agent is a direct launch, not an action', () => {
|
||||
// The settings_json bug class (an unknown action input silently dropped)
|
||||
// is gone with the action: the host writes the settings file itself and
|
||||
// the launch is a plain CLI invocation whose env we fully control.
|
||||
readTriageSettings();
|
||||
assert.equal(
|
||||
triageStep.uses,
|
||||
undefined,
|
||||
'the triage agent must be a direct CLI launch, not qwen-code-action',
|
||||
);
|
||||
assert.match(triageStep.run, /runuser -u node -- env -i/);
|
||||
assert.doesNotMatch(JSON.stringify(triageJob), /settings_json/);
|
||||
});
|
||||
|
||||
it('settings is valid JSON that restricts the toolset', () => {
|
||||
const settings = assertSettingsContract(triageStep, 'triage settings');
|
||||
const settings = readTriageSettings();
|
||||
const core = settings.tools?.core;
|
||||
assert.ok(
|
||||
Array.isArray(core),
|
||||
@@ -221,7 +264,7 @@ describe('qwen-triage: agent tool/permission settings', () => {
|
||||
});
|
||||
|
||||
it('settings denies interpreters, network, and PR-code-materializing git/gh', () => {
|
||||
const settings = assertSettingsContract(triageStep, 'triage settings');
|
||||
const settings = readTriageSettings();
|
||||
const deny = settings.permissions?.deny ?? [];
|
||||
for (const d of [
|
||||
'run_shell_command(node)',
|
||||
@@ -234,8 +277,7 @@ describe('qwen-triage: agent tool/permission settings', () => {
|
||||
]) {
|
||||
assert.ok(deny.includes(d), `permissions.deny must include ${d}`);
|
||||
}
|
||||
// No sandbox key: the ECS pool ships no container runtime, and adding one
|
||||
// would silently disable the step.
|
||||
// No sandbox key: the job itself is the container now.
|
||||
assert.equal(
|
||||
settings.sandbox,
|
||||
undefined,
|
||||
@@ -698,23 +740,24 @@ describe('qwen-issue-followup-bot.yml: agent settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen-triage: fork-PR runner routing', () => {
|
||||
describe('qwen-triage: runner routing under token isolation', () => {
|
||||
const runsOn = String(triageJob['runs-on']);
|
||||
const authorizeJob = doc.jobs.authorize;
|
||||
const authorizeRunsOn = String(authorizeJob['runs-on']);
|
||||
|
||||
it('routes the ECS pool on same-repo or a REAL write-permission check', () => {
|
||||
assert.match(runsOn, /head\.repo\.full_name == github\.repository/);
|
||||
// The boundary is the collaborator-permission lookup authorize computes,
|
||||
// NOT the coarse author_association: MEMBER admits any org member and
|
||||
// COLLABORATOR admits read-only invitees — neither implies write access
|
||||
// on this repo, and this job's agent loads bot PATs and a model key.
|
||||
assert.match(
|
||||
runsOn,
|
||||
/needs\.authorize\.outputs\.author_can_write == 'true'/,
|
||||
);
|
||||
assert.doesNotMatch(runsOn, /author_association/);
|
||||
it('routes every trigger to ECS behind the repo guard and kill-switch', () => {
|
||||
// With the write PAT out of the agent environment (read-only token +
|
||||
// drafts executed by publish-triage + container), routing no longer
|
||||
// depends on the target PR's author or on the event shape: the controls
|
||||
// carry the trust boundary, not the machine choice. Hosted capacity is
|
||||
// the scarce side; the pool is maintainer-provided and expandable.
|
||||
assert.match(runsOn, /github\.repository == 'QwenLM\/qwen-code'/);
|
||||
assert.match(runsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/);
|
||||
assert.match(runsOn, /ecs-qwen/);
|
||||
assert.doesNotMatch(runsOn, /author_can_write/);
|
||||
assert.doesNotMatch(runsOn, /author_association/);
|
||||
assert.doesNotMatch(runsOn, /head\.repo/);
|
||||
assert.doesNotMatch(runsOn, /event_name/);
|
||||
});
|
||||
|
||||
it('keeps the authorize gate itself on the same-repo guard', () => {
|
||||
@@ -728,30 +771,164 @@ describe('qwen-triage: fork-PR runner routing', () => {
|
||||
assert.doesNotMatch(authorizeRunsOn, /needs\./);
|
||||
});
|
||||
|
||||
it('computes author_can_write from the collaborator-permission API', () => {
|
||||
assert.equal(
|
||||
authorizeJob.outputs.author_can_write,
|
||||
'${{ steps.perm.outputs.author_can_write }}',
|
||||
);
|
||||
it('retires author_can_write (its only consumer was triage routing)', () => {
|
||||
assert.equal(authorizeJob.outputs.author_can_write, undefined);
|
||||
const perm = authorizeJob.steps.find((s) => s.id === 'perm');
|
||||
assert.ok(
|
||||
String(perm.env.PR_AUTHOR).includes(
|
||||
'github.event.pull_request.user.login',
|
||||
),
|
||||
);
|
||||
assert.match(perm.run, /collaborators\/\$\{PR_AUTHOR\}\/permission/);
|
||||
assert.match(
|
||||
perm.run,
|
||||
/admin\|maintain\|write\) echo "author_can_write=true"/,
|
||||
);
|
||||
assert.doesNotMatch(perm.run, /author_can_write/);
|
||||
assert.equal(perm.env.PR_AUTHOR, undefined);
|
||||
});
|
||||
|
||||
it('falls back to an ephemeral hosted runner', () => {
|
||||
assert.match(runsOn, /ubuntu-latest/);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps issue triage on ECS (issues carry no foreign code)', () => {
|
||||
assert.match(runsOn, /github\.event_name == 'issues'/);
|
||||
describe('qwen-triage: token isolation contract', () => {
|
||||
it('gives the triage job read-only permissions only', () => {
|
||||
const perms = triageJob.permissions;
|
||||
assert.ok(perms, 'triage must declare explicit job-level permissions');
|
||||
for (const [scope, level] of Object.entries(perms)) {
|
||||
assert.equal(level, 'read', `triage permission ${scope} must be read`);
|
||||
}
|
||||
for (const scope of [
|
||||
'contents',
|
||||
'issues',
|
||||
'pull-requests',
|
||||
'checks',
|
||||
'actions',
|
||||
]) {
|
||||
assert.equal(
|
||||
perms[scope],
|
||||
'read',
|
||||
`triage needs ${scope}:read for the agent's API reads`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('runs the agent in a container with no write credential in its env', () => {
|
||||
assert.equal(triageJob.container?.image, 'node:22-bookworm');
|
||||
// runuser comes from util-linux; the tmux/verify lanes install it
|
||||
// explicitly before launching the agent as node. Pin the install line,
|
||||
// not a prose mention.
|
||||
const tools = steps.find((s) => s.name === 'Install triage tools');
|
||||
assert.ok(tools, 'the container toolchain step must exist');
|
||||
assert.match(tools.run, /apt-get install[^\n]*util-linux/);
|
||||
const env = triageStep.env ?? {};
|
||||
// The agent's GitHub credential is the job's read-only GITHUB_TOKEN.
|
||||
assert.equal(env.GH_TOKEN, '${{ secrets.GITHUB_TOKEN }}');
|
||||
assert.ok(!JSON.stringify(env).includes('CI_BOT_PAT'));
|
||||
assert.ok(!JSON.stringify(env).includes('QWEN_CODE_BOT_TOKEN'));
|
||||
const run = triageStep.run;
|
||||
assert.match(run, /runuser -u node -- env -i/);
|
||||
// The gh/git proxy wrappers must be traversable by the agent user, or
|
||||
// they silently fall out of PATH on a proxied runner.
|
||||
assert.match(run, /chmod 755 "\$proxy_bin"/);
|
||||
assert.match(run, /"GITHUB_TOKEN=\$GH_TOKEN"/);
|
||||
assert.match(run, /"GH_TOKEN=\$GH_TOKEN"/);
|
||||
assert.match(run, /"QWEN_TRIAGE_PUBLISH=workflow"/);
|
||||
// The real model key stays behind the loopback proxy.
|
||||
assert.match(run, /"OPENAI_API_KEY=\$PROXY_TOKEN"/);
|
||||
const envBlock = run.slice(
|
||||
run.indexOf('QWEN_ENV=('),
|
||||
run.indexOf('timeout --kill-after'),
|
||||
);
|
||||
assert.ok(envBlock.length > 0, 'the env -i list must exist');
|
||||
assert.ok(!envBlock.includes('secrets.'));
|
||||
});
|
||||
|
||||
it('uploads drafts on every outcome with the non-regular-file strip', () => {
|
||||
const sanitize = steps.find((s) => s.name === 'Sanitize triage drafts');
|
||||
const upload = steps.find((s) => s.name === 'Upload triage drafts');
|
||||
assert.ok(sanitize, 'drafts must be sanitized before upload');
|
||||
assert.ok(upload, 'drafts must upload for the publisher');
|
||||
assert.equal(sanitize.if, 'always()');
|
||||
assert.match(sanitize.run, /-type l/);
|
||||
// A detached agent child could re-plant symlinks between the strip and
|
||||
// the upload (which FOLLOWS them), so lingering agent-user processes
|
||||
// must be killed BEFORE the strip — and zombies must not count, or the
|
||||
// check never clears (tmux/verify rule).
|
||||
const killAt = sanitize.run.indexOf('pkill -KILL -u node');
|
||||
const stripAt = sanitize.run.indexOf('-type l');
|
||||
assert.ok(killAt !== -1, 'sanitize must kill lingering agent processes');
|
||||
assert.ok(
|
||||
killAt < stripAt,
|
||||
'the process kill must precede the symlink strip',
|
||||
);
|
||||
assert.match(sanitize.run, /\$2 !~ \/\^Z\//);
|
||||
assert.equal(upload.if, 'always()');
|
||||
assert.match(String(upload.with.name), /triage-drafts-/);
|
||||
});
|
||||
|
||||
it('keeps the drafts artifact addressable when resolve never ran', () => {
|
||||
// Upload and download must agree on the name even when the run died
|
||||
// before 'Resolve target number': the job output mirrors the upload's
|
||||
// primary segment, and both sides share the same event-number fallback.
|
||||
const upload = steps.find((s) => s.name === 'Upload triage drafts');
|
||||
const download = publishTriageJob.steps.find(
|
||||
(s) => s.name === 'Download triage drafts',
|
||||
);
|
||||
assert.ok(download, 'the publisher must download the drafts artifact');
|
||||
assert.equal(
|
||||
triageJob.outputs.pr_number,
|
||||
'${{ steps.resolve.outputs.number }}',
|
||||
);
|
||||
const fallback = '|| github.event.issue.number';
|
||||
assert.ok(String(upload.with.name).includes(fallback));
|
||||
assert.ok(String(download.with.name).includes(fallback));
|
||||
});
|
||||
|
||||
it('publishes drafts from a separate job that never runs agent code', () => {
|
||||
assert.ok(publishTriageJob, 'publish-triage job must exist');
|
||||
assert.deepEqual(publishTriageJob.needs, ['triage']);
|
||||
assert.match(String(publishTriageJob.if), /always\(\)/);
|
||||
assert.match(
|
||||
String(publishTriageJob.if),
|
||||
/needs\.triage\.result != 'skipped'/,
|
||||
);
|
||||
// Per-RUN group (publish-tmux rule): a per-PR group would let a second
|
||||
// run replace a completed run's pending publisher and drop its drafts.
|
||||
assert.match(
|
||||
String(publishTriageJob.concurrency.group),
|
||||
/publish-triage-\{1\}/,
|
||||
);
|
||||
assert.match(String(publishTriageJob.concurrency.group), /github\.run_id/);
|
||||
assert.equal(publishTriageJob.concurrency['cancel-in-progress'], false);
|
||||
assert.ok(publishTriageJob['timeout-minutes'] <= 30);
|
||||
const checkout = publishTriageJob.steps.find((s) =>
|
||||
/^Checkout/.test(s.name),
|
||||
);
|
||||
assert.ok(checkout, 'publisher must sparse-checkout its scripts');
|
||||
assert.match(
|
||||
String(checkout.with['sparse-checkout']),
|
||||
/triage-publish\.mjs/,
|
||||
);
|
||||
assert.equal(checkout.with['persist-credentials'], false);
|
||||
assert.equal(checkout.with.ref, '${{ github.sha }}');
|
||||
const publish = publishTriageJob.steps.find(
|
||||
(s) => s.name === 'Publish triage drafts',
|
||||
);
|
||||
assert.ok(publish, 'publisher must run the draft script');
|
||||
assert.match(publish.run, /triage-publish\.mjs/);
|
||||
assert.ok(String(publish.env.GH_TOKEN).includes('CI_BOT_PAT'));
|
||||
});
|
||||
|
||||
it('inspects published reviews only after publishing (notify moved)', () => {
|
||||
// The notify step reads the reviews THIS run posted; under token
|
||||
// isolation those land in the publish step, so notify must follow it in
|
||||
// the publish job — left in the agent job it would always see none.
|
||||
const notify = publishTriageJob.steps.find(
|
||||
(s) => s.name === 'Notify silent triage re-run',
|
||||
);
|
||||
assert.ok(notify, 'notify must live in publish-triage');
|
||||
const notifyIdx = publishTriageJob.steps.indexOf(notify);
|
||||
const publishIdx = publishTriageJob.steps.findIndex(
|
||||
(s) => s.name === 'Publish triage drafts',
|
||||
);
|
||||
assert.ok(publishIdx !== -1 && notifyIdx > publishIdx);
|
||||
assert.ok(
|
||||
!steps.some((s) => s.name === 'Notify silent triage re-run'),
|
||||
'notify must not remain in the agent job',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1074,30 +1251,32 @@ describe('ci.yml: self-hosted checkout jobs restore ownership unconditionally',
|
||||
});
|
||||
});
|
||||
|
||||
describe('qwen-triage: maintainer resolver heredoc vs the backtick deny rule', () => {
|
||||
it('keeps the resolver bash block backtick-free so the deny rule never fires', () => {
|
||||
// The triage lane's permissions.deny includes run_shell_command(*`*):
|
||||
// any command text containing a backtick is EXECUTION_DENIED before
|
||||
// approval. A template literal (or a backticked comment) inside the
|
||||
// resolver heredoc would therefore kill the whole command, and
|
||||
// deferrals would silently lose their deterministic assignee.
|
||||
const settings = assertSettingsContract(triageStep, 'triage settings');
|
||||
describe('qwen-triage: maintainer resolver lives in the publisher', () => {
|
||||
it('keeps the backtick deny rule; the skill drafts defer instead of resolving', () => {
|
||||
// The resolver's collaborator-permission lookup needs a write-level
|
||||
// token, which the read-only CI agent no longer holds, so the resolver
|
||||
// moved into triage-publish.mjs. The skill must not grow a replacement
|
||||
// heredoc (which would also re-expose the backtick deny-rule hazard the
|
||||
// old block had to dance around).
|
||||
const settings = readTriageSettings();
|
||||
const deny = settings.permissions?.deny ?? [];
|
||||
assert.ok(
|
||||
deny.includes('run_shell_command(*`*)'),
|
||||
'permissions.deny must keep the backtick rule',
|
||||
);
|
||||
const blocks = [...prSkill.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
const resolver = blocks.find(
|
||||
(b) => b.includes("<<'EOF'") && b.includes('collaborators'),
|
||||
);
|
||||
assert.ok(resolver, 'maintainer resolver heredoc block must exist');
|
||||
assert.ok(
|
||||
!resolver.includes('`'),
|
||||
'resolver block must contain no backtick, or the deny rule blocks it',
|
||||
!prSkill.includes('collaborators/'),
|
||||
'the skill must not call the collaborator-permission API',
|
||||
);
|
||||
assert.ok(
|
||||
!prSkill.includes('assign-issue-owner.mjs'),
|
||||
'the resolver import must live in the publisher, not the skill',
|
||||
);
|
||||
assert.match(prSkill, /\{action:"defer"/);
|
||||
const publisher = readFileSync(publisherPath, 'utf8');
|
||||
assert.match(publisher, /collaborators/);
|
||||
assert.match(publisher, /pickOwner/);
|
||||
assert.match(publisher, /QWEN_MAINTAINER_HANDLE/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
#!/usr/bin/env node
|
||||
// Publishes the triage agent's draft actions to GitHub. The agent never holds
|
||||
// a write credential: it writes data (actions.jsonl + body files), and this
|
||||
// script validates the whole set before executing any of it, then performs
|
||||
// the writes with the gh CLI's ambient GH_TOKEN.
|
||||
import {
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { loadPolicy, matchArea, pickOwner } from './assign-issue-owner.mjs';
|
||||
|
||||
const MARKERS = new Set([
|
||||
'stage=1',
|
||||
'stage=2',
|
||||
'stage=3',
|
||||
'stage=1-pre',
|
||||
'stage=1b',
|
||||
]);
|
||||
const MAX_BODY_LENGTH = 65536;
|
||||
// SKILL.md forbids touching process labels; now that every label write goes
|
||||
// through this one chokepoint, the rule is enforced in code, not judgment.
|
||||
const PROCESS_LABELS = new Set([
|
||||
'welcome-pr',
|
||||
'maintainer',
|
||||
'help wanted',
|
||||
'good first issue',
|
||||
]);
|
||||
const WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'write']);
|
||||
const COMMIT_ID = /^[0-9a-fA-F]{40}$/;
|
||||
|
||||
const ACTION_FIELDS = {
|
||||
'upsert-comment': ['marker', 'body'],
|
||||
review: ['event', 'commit_id', 'body'],
|
||||
'close-pr': [],
|
||||
'close-issue': ['reason'],
|
||||
labels: ['add', 'remove'],
|
||||
defer: ['body'],
|
||||
};
|
||||
|
||||
const defaultExec = (args) =>
|
||||
spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
|
||||
|
||||
function ghCall(exec, args) {
|
||||
const result = exec(args);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr ?? '').trim() || `gh ${args.slice(0, 2).join(' ')} failed`,
|
||||
);
|
||||
}
|
||||
return (result.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
// --paginate emits one JSON document per page; `.[] | @json` flattens every
|
||||
// page into JSONL so multi-page results parse as reliably as single-page ones.
|
||||
function ghJsonLines(exec, args) {
|
||||
return ghCall(exec, [...args, '--paginate', '--jq', '.[] | @json'])
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function isStringArray(value) {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
||||
}
|
||||
|
||||
function markerLine(marker) {
|
||||
return `<!-- qwen-triage ${marker} -->`;
|
||||
}
|
||||
|
||||
function readBodyFile(draftDir, bodiesDir, rel, where) {
|
||||
const resolved = path.resolve(draftDir, rel);
|
||||
if (!resolved.startsWith(bodiesDir + path.sep)) {
|
||||
throw new Error(`${where}: body path escapes the bodies directory: ${rel}`);
|
||||
}
|
||||
// lstat, not stat: a symlink (or FIFO, or directory) is rejected even when
|
||||
// its target would be a regular file inside the draft directory.
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(resolved);
|
||||
} catch {
|
||||
throw new Error(`${where}: body file not found: ${rel}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${where}: body is not a regular file: ${rel}`);
|
||||
}
|
||||
const realBodies = realpathSync(bodiesDir);
|
||||
if (!realpathSync(resolved).startsWith(realBodies + path.sep)) {
|
||||
throw new Error(`${where}: body path escapes the bodies directory: ${rel}`);
|
||||
}
|
||||
const text = readFileSync(resolved, 'utf8');
|
||||
// Reject, never truncate: cutting the tail could drop the CI-region or
|
||||
// approve-on-green markers the finalize workflow consumes.
|
||||
if (text.length > MAX_BODY_LENGTH) {
|
||||
throw new Error(
|
||||
`${where}: body exceeds ${MAX_BODY_LENGTH} characters (${text.length})`,
|
||||
);
|
||||
}
|
||||
return { file: resolved, text };
|
||||
}
|
||||
|
||||
export function validateDrafts({ dir, targetKind }) {
|
||||
const draftDir = path.resolve(dir);
|
||||
const bodiesDir = path.join(draftDir, 'bodies');
|
||||
// lstat the containers themselves: a symlinked bodies/ would redirect the
|
||||
// realpath containment base below, letting outside files pass as bodies,
|
||||
// and a symlinked actions.jsonl would feed validation from another file.
|
||||
if (!lstatSync(path.join(draftDir, 'actions.jsonl')).isFile()) {
|
||||
throw new Error('actions.jsonl must be a regular file, not a symlink');
|
||||
}
|
||||
if (!lstatSync(bodiesDir).isDirectory()) {
|
||||
throw new Error('bodies/ must be a real directory, not a symlink');
|
||||
}
|
||||
const raw = readFileSync(path.join(draftDir, 'actions.jsonl'), 'utf8');
|
||||
const actions = [];
|
||||
const lines = raw.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
const where = `actions.jsonl line ${i + 1}`;
|
||||
let action;
|
||||
try {
|
||||
action = JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`${where}: invalid JSON`);
|
||||
}
|
||||
if (
|
||||
!action ||
|
||||
typeof action !== 'object' ||
|
||||
Array.isArray(action) ||
|
||||
typeof action.action !== 'string' ||
|
||||
!Object.hasOwn(ACTION_FIELDS, action.action)
|
||||
) {
|
||||
throw new Error(`${where}: unknown action`);
|
||||
}
|
||||
const allowed = ACTION_FIELDS[action.action];
|
||||
for (const key of Object.keys(action)) {
|
||||
if (key !== 'action' && !allowed.includes(key)) {
|
||||
throw new Error(`${where}: unknown field ${key} on ${action.action}`);
|
||||
}
|
||||
}
|
||||
if (action.action === 'upsert-comment') {
|
||||
if (!MARKERS.has(action.marker)) {
|
||||
throw new Error(`${where}: marker not in allowlist: ${action.marker}`);
|
||||
}
|
||||
}
|
||||
if (action.action === 'review') {
|
||||
if (!['APPROVE', 'REQUEST_CHANGES'].includes(action.event)) {
|
||||
throw new Error(`${where}: unsupported review event: ${action.event}`);
|
||||
}
|
||||
if (action.commit_id !== undefined && !COMMIT_ID.test(action.commit_id)) {
|
||||
throw new Error(`${where}: commit_id must be 40 hex characters`);
|
||||
}
|
||||
if (action.event === 'APPROVE' && action.commit_id === undefined) {
|
||||
throw new Error(`${where}: APPROVE requires commit_id`);
|
||||
}
|
||||
}
|
||||
if (action.action === 'close-issue' && action.reason !== 'not planned') {
|
||||
throw new Error(`${where}: close-issue reason must be "not planned"`);
|
||||
}
|
||||
if (action.action === 'labels') {
|
||||
for (const key of ['add', 'remove']) {
|
||||
if (action[key] !== undefined && !isStringArray(action[key])) {
|
||||
throw new Error(
|
||||
`${where}: labels ${key} must be an array of strings`,
|
||||
);
|
||||
}
|
||||
for (const label of action[key] ?? []) {
|
||||
if (PROCESS_LABELS.has(label)) {
|
||||
throw new Error(`${where}: process label "${label}" is off-limits`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.action === 'defer' && targetKind !== 'pr') {
|
||||
throw new Error(`${where}: defer is only valid for pull requests`);
|
||||
}
|
||||
if (
|
||||
(action.action === 'review' || action.action === 'close-pr') &&
|
||||
targetKind !== 'pr'
|
||||
) {
|
||||
throw new Error(
|
||||
`${where}: ${action.action} is only valid for pull requests`,
|
||||
);
|
||||
}
|
||||
if (action.action === 'close-issue' && targetKind !== 'issue') {
|
||||
throw new Error(`${where}: close-issue is only valid for issues`);
|
||||
}
|
||||
if (['upsert-comment', 'review', 'defer'].includes(action.action)) {
|
||||
if (typeof action.body !== 'string') {
|
||||
throw new Error(`${where}: ${action.action} requires a body path`);
|
||||
}
|
||||
const body = readBodyFile(draftDir, bodiesDir, action.body, where);
|
||||
if (
|
||||
action.action === 'upsert-comment' &&
|
||||
body.text.split('\n')[0] !== markerLine(action.marker)
|
||||
) {
|
||||
// The publisher owns the marker prefix; the agent is not trusted to
|
||||
// have written it, and dedup/finalize match on these exact bytes.
|
||||
throw new Error(
|
||||
`${where}: body must start with "${markerLine(action.marker)}"`,
|
||||
);
|
||||
}
|
||||
action.bodyFile = body.file;
|
||||
action.bodyText = body.text;
|
||||
}
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function canWrite(exec, repo, login) {
|
||||
try {
|
||||
return WRITE_PERMISSIONS.has(
|
||||
ghCall(exec, [
|
||||
'api',
|
||||
`repos/${repo}/collaborators/${login}/permission`,
|
||||
'--jq',
|
||||
'.permission',
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
// A stale map entry (renamed/deleted account, lost access) must drop the
|
||||
// candidate, not fail the defer.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same gh invocation as assign-issue-owner.mjs's openIssueCount so the two
|
||||
// assignment paths cannot drift; re-spelled here because that export binds
|
||||
// its own spawnSync and cannot take the injected executor.
|
||||
function openIssueCount(exec, repo, login) {
|
||||
return Number(
|
||||
ghCall(exec, [
|
||||
'issue',
|
||||
'list',
|
||||
'--repo',
|
||||
repo,
|
||||
'--state',
|
||||
'open',
|
||||
'--assignee',
|
||||
login,
|
||||
'--limit',
|
||||
'100',
|
||||
'--json',
|
||||
'number',
|
||||
'--jq',
|
||||
'length',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMaintainer({
|
||||
repo,
|
||||
number,
|
||||
exec,
|
||||
env = process.env,
|
||||
ownersFile = fileURLToPath(new URL('../issue-owners.json', import.meta.url)),
|
||||
err = process.stderr,
|
||||
}) {
|
||||
const handle = env.QWEN_MAINTAINER_HANDLE;
|
||||
if (handle) return handle;
|
||||
try {
|
||||
const policy = loadPolicy(readFileSync(ownersFile, 'utf8'));
|
||||
const pr = JSON.parse(
|
||||
ghCall(exec, [
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'author,labels',
|
||||
]),
|
||||
);
|
||||
const area = matchArea(policy, pr);
|
||||
if (area) {
|
||||
// A null author means a deleted account — nobody to exclude.
|
||||
const authorLogin = pr.author?.login?.toLowerCase() ?? '';
|
||||
const eligible = area.owners.filter(
|
||||
(owner) =>
|
||||
owner.toLowerCase() !== authorLogin && canWrite(exec, repo, owner),
|
||||
);
|
||||
if (eligible.length > 0) {
|
||||
const load = new Map(
|
||||
eligible.map((owner) => [owner, openIssueCount(exec, repo, owner)]),
|
||||
);
|
||||
return pickOwner(eligible, load, Number(number));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
err.write(`warning: owner-map resolution failed: ${error.message}\n`);
|
||||
}
|
||||
try {
|
||||
const { latestReviews } = JSON.parse(
|
||||
ghCall(exec, [
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'latestReviews',
|
||||
]),
|
||||
);
|
||||
// latestReviews is not recency-sorted; drop deleted accounts and bot
|
||||
// suffixes before sorting so a defer never @mentions a bot.
|
||||
const humans = (latestReviews ?? [])
|
||||
.filter((review) => review.author?.login != null)
|
||||
.filter(
|
||||
(review) =>
|
||||
!review.author.login.endsWith('[bot]') &&
|
||||
!review.author.login.endsWith('-bot'),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
String(a.submittedAt).localeCompare(String(b.submittedAt)),
|
||||
);
|
||||
return humans.at(-1)?.author.login ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function upsertComment(action, { repo, number, botLogin, exec }) {
|
||||
const prefix = markerLine(action.marker);
|
||||
let existing = null;
|
||||
// Without a verified bot identity a PATCH could overwrite a human comment
|
||||
// that quotes the marker, so identity failure forbids PATCH entirely.
|
||||
if (botLogin) {
|
||||
const comments = ghJsonLines(exec, [
|
||||
'api',
|
||||
`repos/${repo}/issues/${number}/comments`,
|
||||
]);
|
||||
const matches = comments.filter(
|
||||
(comment) =>
|
||||
comment.user?.login === botLogin &&
|
||||
typeof comment.body === 'string' &&
|
||||
comment.body.startsWith(prefix),
|
||||
);
|
||||
existing = matches.at(-1) ?? null;
|
||||
}
|
||||
if (existing) {
|
||||
ghCall(exec, [
|
||||
'api',
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${repo}/issues/comments/${existing.id}`,
|
||||
'-F',
|
||||
`body=@${action.bodyFile}`,
|
||||
]);
|
||||
return { status: 'published', detail: `PATCH comment ${existing.id}` };
|
||||
}
|
||||
ghCall(exec, [
|
||||
'api',
|
||||
`repos/${repo}/issues/${number}/comments`,
|
||||
'-F',
|
||||
`body=@${action.bodyFile}`,
|
||||
]);
|
||||
return {
|
||||
status: 'published',
|
||||
detail: botLogin
|
||||
? 'POST comment'
|
||||
: 'POST comment (identity unresolved; PATCH disabled)',
|
||||
};
|
||||
}
|
||||
|
||||
function submitReview(action, { repo, number, botLogin, exec }) {
|
||||
const reviews = ghJsonLines(exec, [
|
||||
'api',
|
||||
`repos/${repo}/pulls/${number}/reviews`,
|
||||
]);
|
||||
const head = ghCall(exec, [
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--json',
|
||||
'headRefOid',
|
||||
'--jq',
|
||||
'.headRefOid',
|
||||
]);
|
||||
const own = botLogin
|
||||
? reviews.filter((review) => review.user?.login === botLogin)
|
||||
: [];
|
||||
if (action.event === 'APPROVE') {
|
||||
if (
|
||||
own.some(
|
||||
(review) =>
|
||||
review.state === 'APPROVED' && review.commit_id === action.commit_id,
|
||||
)
|
||||
) {
|
||||
return { status: 'skipped', detail: 'already approved at this commit' };
|
||||
}
|
||||
// Attesting a commit that is no longer head would approve code nobody
|
||||
// reviewed; the finalize workflow applies the same stale check.
|
||||
if (head !== action.commit_id) {
|
||||
return { status: 'skipped', detail: 'stale' };
|
||||
}
|
||||
} else if (own.some((review) => review.state === 'CHANGES_REQUESTED')) {
|
||||
return { status: 'skipped', detail: 'changes already requested' };
|
||||
}
|
||||
const args = [
|
||||
'api',
|
||||
`repos/${repo}/pulls/${number}/reviews`,
|
||||
'-f',
|
||||
`event=${action.event}`,
|
||||
];
|
||||
if (action.commit_id !== undefined) {
|
||||
args.push('-f', `commit_id=${action.commit_id}`);
|
||||
}
|
||||
args.push('-F', `body=@${action.bodyFile}`);
|
||||
ghCall(exec, args);
|
||||
return { status: 'published', detail: `${action.event} review` };
|
||||
}
|
||||
|
||||
function closeTarget(action, { repo, number, exec }) {
|
||||
const state = ghCall(exec, [
|
||||
'api',
|
||||
`repos/${repo}/issues/${number}`,
|
||||
'--jq',
|
||||
'.state',
|
||||
]);
|
||||
if (state !== 'open') {
|
||||
return { status: 'skipped', detail: `already ${state}` };
|
||||
}
|
||||
if (action.action === 'close-pr') {
|
||||
ghCall(exec, ['pr', 'close', String(number), '--repo', repo]);
|
||||
} else {
|
||||
ghCall(exec, [
|
||||
'issue',
|
||||
'close',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--reason',
|
||||
'not planned',
|
||||
]);
|
||||
}
|
||||
return { status: 'published', detail: 'closed' };
|
||||
}
|
||||
|
||||
function applyLabels(action, { repo, number, exec }) {
|
||||
const existing = new Set(
|
||||
ghJsonLines(exec, ['api', `repos/${repo}/labels`]).map(
|
||||
(label) => label.name,
|
||||
),
|
||||
);
|
||||
const add = action.add ?? [];
|
||||
const remove = action.remove ?? [];
|
||||
const missing = [...add, ...remove].filter((label) => !existing.has(label));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`labels do not exist: ${missing.join(', ')}`);
|
||||
}
|
||||
if (add.length + remove.length === 0) {
|
||||
return { status: 'skipped', detail: 'no labels to apply' };
|
||||
}
|
||||
const args = ['issue', 'edit', String(number), '--repo', repo];
|
||||
for (const label of add) args.push('--add-label', label);
|
||||
for (const label of remove) args.push('--remove-label', label);
|
||||
ghCall(exec, args);
|
||||
return {
|
||||
status: 'published',
|
||||
detail: `add [${add.join(', ')}] remove [${remove.join(', ')}]`,
|
||||
};
|
||||
}
|
||||
|
||||
function deferToMaintainer(action, context) {
|
||||
const { repo, number, exec, env, ownersFile, err } = context;
|
||||
const login = resolveMaintainer({ repo, number, exec, env, ownersFile, err });
|
||||
const mention = login
|
||||
? `⏸️ Deferring to @${login} — needs a human call on this one.`
|
||||
: '⏸️ Deferring to the maintainers — needs a human call on this one.';
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'triage-publish-'));
|
||||
const bodyFile = path.join(tempDir, 'defer.md');
|
||||
try {
|
||||
writeFileSync(bodyFile, `${mention}\n\n${action.bodyText}`);
|
||||
ghCall(exec, [
|
||||
'api',
|
||||
`repos/${repo}/issues/${number}/comments`,
|
||||
'-F',
|
||||
`body=@${bodyFile}`,
|
||||
]);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
if (login) {
|
||||
try {
|
||||
ghCall(exec, [
|
||||
'pr',
|
||||
'edit',
|
||||
String(number),
|
||||
'--repo',
|
||||
repo,
|
||||
'--add-assignee',
|
||||
login,
|
||||
]);
|
||||
} catch (error) {
|
||||
// The mention is the load-bearing signal; a failed assign must never
|
||||
// turn a published defer into a failed action.
|
||||
err.write(`warning: could not assign @${login}: ${error.message}\n`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'published',
|
||||
detail: login ? `deferred to @${login}` : 'deferred without mention',
|
||||
};
|
||||
}
|
||||
|
||||
function runAction(action, context) {
|
||||
switch (action.action) {
|
||||
case 'upsert-comment':
|
||||
return upsertComment(action, context);
|
||||
case 'review':
|
||||
return submitReview(action, context);
|
||||
case 'close-pr':
|
||||
case 'close-issue':
|
||||
return closeTarget(action, context);
|
||||
case 'labels':
|
||||
return applyLabels(action, context);
|
||||
case 'defer':
|
||||
return deferToMaintainer(action, context);
|
||||
}
|
||||
}
|
||||
|
||||
export function executeActions({
|
||||
actions,
|
||||
repo,
|
||||
number,
|
||||
botLogin,
|
||||
exec,
|
||||
env = process.env,
|
||||
ownersFile,
|
||||
err = process.stderr,
|
||||
}) {
|
||||
const context = { repo, number, botLogin, exec, env, ownersFile, err };
|
||||
const results = [];
|
||||
for (const action of actions) {
|
||||
try {
|
||||
const result = runAction(action, context);
|
||||
results.push({ action: action.action, ...result });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
action: action.action,
|
||||
status: 'failed',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function run({
|
||||
argv,
|
||||
env = process.env,
|
||||
exec = defaultExec,
|
||||
out = process.stdout,
|
||||
err = process.stderr,
|
||||
}) {
|
||||
let options;
|
||||
try {
|
||||
options = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
drafts: { type: 'string' },
|
||||
repo: { type: 'string' },
|
||||
number: { type: 'string' },
|
||||
'dry-run': { type: 'boolean' },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
} catch (error) {
|
||||
err.write(`${error.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
const dir = options.values.drafts;
|
||||
const repo = options.values.repo;
|
||||
const number = Number(options.values.number);
|
||||
const dryRun = options.values['dry-run'] === true;
|
||||
if (
|
||||
!dir ||
|
||||
!repo ||
|
||||
!/^[^/\s]+\/[^/\s]+$/.test(repo) ||
|
||||
!Number.isSafeInteger(number) ||
|
||||
number < 1
|
||||
) {
|
||||
err.write(
|
||||
'usage: triage-publish.mjs --drafts <dir> --repo <owner/repo> --number <n> [--dry-run]\n',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
let targetKind;
|
||||
try {
|
||||
const target = JSON.parse(
|
||||
ghCall(exec, ['api', `repos/${repo}/issues/${number}`]),
|
||||
);
|
||||
targetKind = target.pull_request ? 'pr' : 'issue';
|
||||
} catch (error) {
|
||||
err.write(`preflight: cannot read target: ${error.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
let actions;
|
||||
try {
|
||||
actions = validateDrafts({ dir, targetKind });
|
||||
} catch (error) {
|
||||
err.write(`preflight rejected: ${error.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
let botLogin = env.BOT_LOGIN || null;
|
||||
if (!botLogin) {
|
||||
try {
|
||||
botLogin = ghCall(exec, ['api', 'user', '--jq', '.login']) || null;
|
||||
} catch {
|
||||
botLogin = null;
|
||||
}
|
||||
if (!botLogin) {
|
||||
err.write(
|
||||
'warning: bot identity unresolved; comment PATCH disabled (fresh POST only)\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
for (const action of actions) {
|
||||
out.write(
|
||||
`${JSON.stringify({ action: action.action, status: 'skipped', detail: 'dry-run' })}\n`,
|
||||
);
|
||||
}
|
||||
err.write(
|
||||
`dry-run: ${actions.length} action(s) validated, nothing published\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const results = executeActions({
|
||||
actions,
|
||||
repo,
|
||||
number,
|
||||
botLogin,
|
||||
exec,
|
||||
env,
|
||||
err,
|
||||
});
|
||||
for (const result of results) {
|
||||
out.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
const count = (status) => results.filter((r) => r.status === status).length;
|
||||
err.write(
|
||||
`${results.length} action(s): ${count('published')} published, ${count('skipped')} skipped, ${count('failed')} failed\n`,
|
||||
);
|
||||
return count('failed') > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
process.exit(run({ argv: process.argv.slice(2) }));
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
1 5 0 pr-self-report-label.yml
|
||||
8 590 0 qwen-autofix.yml
|
||||
9 155 4 qwen-code-pr-review.yml
|
||||
7 280 2 qwen-triage.yml
|
||||
8 290 2 qwen-triage.yml
|
||||
2 55 0 release-managed-agent-reference.yml
|
||||
2 0 2 sdk-python.yml
|
||||
1 60 0 serve-ab.yml
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
5760 qwen-pr-safety-precheck.yml
|
||||
3243 qwen-review-runner-schedule.yml
|
||||
27648 qwen-triage-finalize.yml
|
||||
378242 qwen-triage.yml
|
||||
402331 qwen-triage.yml
|
||||
15726 release-chrome-extension.yml
|
||||
26801 release-managed-agent-reference.yml
|
||||
13464 release-managed-runtime.yml
|
||||
|
||||
+699
-167
@@ -85,13 +85,6 @@ jobs:
|
||||
contents: 'read'
|
||||
outputs:
|
||||
should_run: '${{ steps.perm.outputs.should_run }}'
|
||||
# Real write-permission check on the PR AUTHOR (collaborator-permission
|
||||
# API, not the coarse author_association), computed only on
|
||||
# pull_request_target events; empty on every other event. Consumed by
|
||||
# the triage job's runner routing so a maintainer's fork PR uses the
|
||||
# ECS pool while a read-only collaborator's or org member's fork PR
|
||||
# stays on ephemeral hosted runners.
|
||||
author_can_write: '${{ steps.perm.outputs.author_can_write }}'
|
||||
# /verify trust level: 'trusted' when the PR author has write,
|
||||
# 'external' when they do not. Both run on the persistent ECS pool
|
||||
# (maintainer decision, 2026-07-29: the pool is network-isolated and
|
||||
@@ -192,7 +185,6 @@ jobs:
|
||||
# issues; compare against null rather than truthiness so the shell
|
||||
# receives a plain true/false.
|
||||
IS_PR: '${{ github.event.issue.pull_request != null }}'
|
||||
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
IS_VERIFY=false
|
||||
@@ -210,18 +202,6 @@ jobs:
|
||||
# says the PR is ready to be worth that.
|
||||
echo "Automatic PR triage allowed for PR #${PR_NUMBER} after same-repo/precheck gate." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
# Runner-routing input for the triage job: the REAL author write
|
||||
# check (author_association admits org members and read-only
|
||||
# collaborators). Fail OPEN on purpose — unlike the deny gates
|
||||
# below, this selects a runner pool, not a decision: an API
|
||||
# hiccup merely routes the triage to a hosted runner, it must
|
||||
# never skip or fail the triage itself.
|
||||
if [ -n "$PR_AUTHOR" ] &&
|
||||
author_perm="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${PR_AUTHOR}/permission" --jq '.permission' 2>/dev/null)"; then
|
||||
case "$author_perm" in
|
||||
admin|maintain|write) echo "author_can_write=true" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
case "$EVENT_NAME" in
|
||||
@@ -483,26 +463,40 @@ jobs:
|
||||
}}
|
||||
# Triage is a static-analysis agent: it checks out the trusted base repo,
|
||||
# reaches the PR/issue only via the API, and must NEVER execute PR-derived
|
||||
# code — its step env carries a write PAT that executed code could read.
|
||||
# This is defended in layers, none of which is a hard boundary on its own
|
||||
# (see the long note on the Qwen step for why a `--yolo` command denylist
|
||||
# cannot be): the skill forbids executing PR code
|
||||
# (.qwen/skills/triage/SKILL.md Rules), the `settings` deny rules block the
|
||||
# direct interpreter/build/network invocations, and the routing below keeps
|
||||
# foreign code off the persistent pool.
|
||||
# Runner routing: the persistent ECS pool serves events whose payload
|
||||
# carries no foreign code — issue triage and same-repo PR events — plus
|
||||
# fork PRs whose author REALLY has write access, per the collaborator-
|
||||
# permission lookup the authorize job already performs (the coarse
|
||||
# author_association would admit org members and read-only collaborators
|
||||
# to this secret-bearing agent; the pool is network-isolated, but a
|
||||
# write-access author is the trust bar for reusing it). authorize is
|
||||
# skipped on the issues/dispatch paths, so the explicit `issues` clause
|
||||
# must stay; there its output is ''. PRs by other fork authors, and
|
||||
# comment/dispatch-triggered reruns (which may target fork PRs), go to
|
||||
# ephemeral hosted runners so a steered agent cannot persist anything
|
||||
# across runs. Forks of this repo fall back to ubuntu-latest as before.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''issues'' || (github.event.pull_request && (github.event.pull_request.head.repo.full_name == github.repository || needs.authorize.outputs.author_can_write == ''true'')))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# code. The agent no longer carries a write credential: it reads GitHub
|
||||
# with the job's read-only GITHUB_TOKEN and expresses every write as a
|
||||
# draft that the separate publish-triage job validates and executes with
|
||||
# the bot PAT (see the long note on the Qwen step). The container below
|
||||
# and the per-run workspace sanitize bound what a steered agent could
|
||||
# persist on the reusable pool.
|
||||
# Runner routing: with the write PAT out of the agent environment, every
|
||||
# triage trigger — issues, same-repo and fork PRs, comment reruns,
|
||||
# dispatch — may use the persistent ECS pool. The pool is
|
||||
# maintainer-provided and network-isolated; hosted capacity is the scarce
|
||||
# side. Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED falls back to
|
||||
# ubuntu-latest, which stays safe because the controls are additive.
|
||||
# Forks of this repo fall back to ubuntu-latest as before.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
# Read-only by construction: the agent's GH_TOKEN is this job's
|
||||
# GITHUB_TOKEN. Every write goes through publish-triage's PAT. The
|
||||
# pre-agent reaction step uses the PAT explicitly (reactions need write).
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
pull-requests: 'read'
|
||||
checks: 'read'
|
||||
actions: 'read'
|
||||
# The agent reads untrusted PR content on a reusable machine. Run the
|
||||
# steps in a container so nothing it does persists on the runner host
|
||||
# beyond the sanitized workspace (same contract as tmux/verify).
|
||||
container:
|
||||
image: 'node:22-bookworm'
|
||||
options: '--init'
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
outputs:
|
||||
pr_number: '${{ steps.resolve.outputs.number }}'
|
||||
# startsWith (not contains) prevents false triggers from comments that
|
||||
# mention the phrase in quoted text or mid-sentence descriptions.
|
||||
# always() so the job still evaluates when the upstream `authorize` job is
|
||||
@@ -531,10 +525,33 @@ jobs:
|
||||
)
|
||||
)
|
||||
steps:
|
||||
# The job runs in a bare node container; gh/jq/git are not in the image.
|
||||
# Same toolchain step as the tmux lane.
|
||||
- name: 'Install triage tools'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
apt-get update
|
||||
# util-linux provides runuser for the agent launch (tmux/verify
|
||||
# install it explicitly for the same reason).
|
||||
apt-get install -y --no-install-recommends ca-certificates curl git gnupg jq util-linux
|
||||
|
||||
install -d -m 755 /etc/apt/keyrings
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg
|
||||
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends gh
|
||||
|
||||
gh --version
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: 'Acknowledge triage request'
|
||||
if: "github.event_name == 'issue_comment'"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
# Reactions need write scope; the job's GITHUB_TOKEN is read-only.
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
COMMENT_ID: '${{ github.event.comment.id }}'
|
||||
run: |-
|
||||
gh api \
|
||||
@@ -619,6 +636,9 @@ jobs:
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
# The agent reads this workspace; keep even the read-only token out
|
||||
# of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Resolve target number'
|
||||
id: 'resolve'
|
||||
@@ -711,90 +731,95 @@ jobs:
|
||||
id: 'ensure_qwen'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
if command -v qwen >/dev/null 2>&1; then
|
||||
echo "qwen already installed:"
|
||||
else
|
||||
npm install -g --loglevel=error --no-audit @qwen-code/qwen-code@latest
|
||||
# Install from RUNNER_TEMP: the persistent workspace may hold a
|
||||
# previous run's tree, and npm reads a cwd .npmrc whose settings a
|
||||
# --registry flag does not override (same rule as the tmux lane).
|
||||
if ! command -v qwen >/dev/null 2>&1; then
|
||||
(cd "${RUNNER_TEMP:?}" && npm install -g --loglevel=error --no-audit --registry=https://registry.npmjs.org @qwen-code/qwen-code@latest)
|
||||
fi
|
||||
qwen --version
|
||||
echo "version=$(qwen --version)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Run Qwen Triage'
|
||||
id: 'triage'
|
||||
uses: 'QwenLM/qwen-code-action@05f81718976f3b7da657422e6e8e4d372b8621d7'
|
||||
# Everything the agent needs that it must NOT fetch with a write
|
||||
# credential: the bot identity for its author filters, the optional
|
||||
# cross-repo direction signal, the draft drop zone, and its settings.
|
||||
- name: 'Prepare agent context'
|
||||
id: 'context'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
# Per-run agent home so this run's session/memory cannot leak into the
|
||||
# next on the reused self-hosted workspace (reset in "Clean stale
|
||||
# agent state"). Must match the QWEN_HOME computed there.
|
||||
QWEN_HOME: '${{ runner.temp }}/qwen-home'
|
||||
with:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ vars.QWEN_TRIAGE_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
|
||||
# vars.QWEN_TRIAGE_EFFORT sets model.reasoningEffort in `settings`
|
||||
# below (low|medium|high|xhigh|max; the CLI has no flag). Unset, it
|
||||
# renders "", which the CLI ignores — the provider default.
|
||||
# Pin the action's unconditional global reinstall to the version
|
||||
# this job already runs. Left at `latest`, the action installs with
|
||||
# --prefer-offline and resolves the dist-tag from the runner's
|
||||
# persistent npm cache, which lags npm publishes and has downgraded
|
||||
# the shared ECS box back to a stale release.
|
||||
qwen_cli_version: '${{ steps.ensure_qwen.outputs.version }}'
|
||||
# The input name is `settings` — this action version has no
|
||||
# `settings_json` input. The old `settings_json:` block was silently
|
||||
# dropped ("Unexpected input(s) 'settings_json'" in the run log), so
|
||||
# the agent ran with the full default toolset. Do not rename it back.
|
||||
#
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
QWEN_HOME="${RUNNER_TEMP:?}/qwen-home"
|
||||
mkdir -p "$QWEN_HOME"
|
||||
# Under the read-only GITHUB_TOKEN the agent will run with,
|
||||
# `gh api user` returns github-actions[bot]; the skill's duplicate
|
||||
# guard, reopen guard, and review dedup filter on the PAT's bot
|
||||
# login, so resolve it here and pass it in.
|
||||
BOT_LOGIN="$(gh api user --jq '.login')"
|
||||
if [ -z "$BOT_LOGIN" ]; then
|
||||
echo "::error::Cannot resolve the bot identity; refusing to run the agent without it."
|
||||
exit 1
|
||||
fi
|
||||
echo "bot_login=$BOT_LOGIN" >> "$GITHUB_OUTPUT"
|
||||
# Stage 1c's optional cross-repo direction signal. GITHUB_TOKEN is
|
||||
# scoped to this repository, so prefetch with the PAT; a missing
|
||||
# file means "signal unavailable", which the skill accepts.
|
||||
mkdir -p "$RUNNER_TEMP/triage-context"
|
||||
gh api repos/anthropics/claude-code/contents/CHANGELOG.md \
|
||||
-H 'Accept: application/vnd.github.raw+json' \
|
||||
> "$RUNNER_TEMP/triage-context/claude-code-changelog.md" 2>/dev/null ||
|
||||
echo "::warning::CHANGELOG prefetch failed; the skill treats the signal as unavailable."
|
||||
# Draft drop zone: the agent writes actions.jsonl + bodies here;
|
||||
# publish-triage is the only writer to GitHub. RUNNER_TEMP survives
|
||||
# across runs on the persistent pool — clear any previous run's
|
||||
# drafts so a re-run cannot append to stale actions.
|
||||
rm -rf "$RUNNER_TEMP/triage-drafts" "$RUNNER_TEMP/triage-results"
|
||||
mkdir -p "$RUNNER_TEMP/triage-drafts/bodies" "$RUNNER_TEMP/triage-results"
|
||||
chown -R node:node "$GITHUB_WORKSPACE" "$QWEN_HOME" \
|
||||
"$RUNNER_TEMP/triage-drafts" "$RUNNER_TEMP/triage-results"
|
||||
# ── What these settings are, and are NOT ────────────────────────────
|
||||
# The action runs `qwen --yolo`, so every permission resolves to
|
||||
# auto-approve EXCEPT a `permissions.deny` match, which the scheduler
|
||||
# errors out before approval mode is consulted (verified in
|
||||
# coreToolScheduler L5 + permissionFlow.needsConfirmation). `tools.core`
|
||||
# additionally filters which tools register at all (fail-safe: an
|
||||
# unlisted tool like web_fetch/web_search/save_memory never loads, so
|
||||
# those network/persistence tool channels are gone). No `sandbox` key
|
||||
# — a deliberate omission, not a capability gap: the ECS pool does run
|
||||
# containers (this workflow's own container jobs, and autofix's
|
||||
# `sandbox: "docker"` agent, both land on those labels). Adding one
|
||||
# here is an open decision, NOT tracked in #9556 — that issue is
|
||||
# scoped to the /review pipeline's execution steps.
|
||||
# The agent runs under --approval-mode yolo, so every permission
|
||||
# resolves to auto-approve EXCEPT a `permissions.deny` match, which
|
||||
# the scheduler errors out before approval mode is consulted.
|
||||
# `tools.core` additionally filters which tools register at all
|
||||
# (fail-safe: an unlisted tool like web_fetch/web_search/save_memory
|
||||
# never loads). No `sandbox` key: the job itself is the container.
|
||||
#
|
||||
# ⚠️ A command denylist is NOT a security boundary under --yolo, and
|
||||
# ⚠️ A command denylist is NOT a security boundary under yolo, and
|
||||
# is not treated as one here. It is defense-in-depth against the
|
||||
# NORMAL failure (the agent running `npm test`/`npx`/a build because a
|
||||
# prompt implied it) and low-effort injection. It does NOT stop a
|
||||
# NORMAL failure (the agent running `npm test`/`npx`/a build because
|
||||
# a prompt implied it) and low-effort injection. It does NOT stop a
|
||||
# determined prompt-injection: `$(...)` substitution is not reliably
|
||||
# deny-matchable (parser-dependent), and even with it blocked, exec
|
||||
# can hide in `sed s///e`, env-var interpreters, etc. — an unwinnable
|
||||
# denylist game. Backtick and `<(` ARE hard-blocked below (verified,
|
||||
# they were free); `$(` and `>(` are not reliably matchable and are
|
||||
# left to the layers below, not papered over with a rule that no-ops.
|
||||
# deny-matchable, and exec can hide in `sed s///e`, env-var
|
||||
# interpreters, etc. Backtick and `<(` ARE hard-blocked below.
|
||||
#
|
||||
# The REAL controls, in order of strength:
|
||||
# 1. No PR code is ever executed (skill Rules; every legit triage
|
||||
# command is a gh-API call, a read-only git command, or text
|
||||
# processing — none touch the PR's tree in an executable way).
|
||||
# 2. Fork PRs run on ephemeral hosted runners (see the triage job
|
||||
# `runs-on`), bounding persistence/lateral movement.
|
||||
# 3. Stale git exec vectors (hooks/config/aliases) are wiped each
|
||||
# run (see "Clean stale agent state").
|
||||
# RESIDUAL RISK: the agent still holds a write PAT (GH_TOKEN above),
|
||||
# and under a successful injection it could exfiltrate it or forge bot
|
||||
# writes — it even has a legitimate comment-posting capability. The
|
||||
# structural fix is token isolation (agent runs read-only; a separate
|
||||
# PR-code-free step publishes its drafted output with the PAT, as
|
||||
# publish-tmux already does). Tracked as a follow-up; not yet wired.
|
||||
# 1. No PR code is ever executed (skill Rules; the agent reads the
|
||||
# PR only through the API and the base checkout).
|
||||
# 2. Token isolation: the agent's GH_TOKEN is the job's read-only
|
||||
# GITHUB_TOKEN; every write is a draft executed by
|
||||
# publish-triage with the PAT. There is no write credential in
|
||||
# the agent environment to exfiltrate, and the model key sits
|
||||
# behind the loopback proxy started in the run step.
|
||||
# 3. The job container plus the per-run workspace sanitize bound
|
||||
# persistence on the reusable pool (see "Clean stale agent
|
||||
# state" and the ownership restores).
|
||||
# A steered agent can still draft malicious content within triage's
|
||||
# intended capability (a wrong review, a misleading comment); that
|
||||
# surface is inherent to the product and reviewed by humans. The
|
||||
# publisher validates shape, not semantics.
|
||||
#
|
||||
# The deny list below therefore backs the skill's no-PR-code rule as
|
||||
# a speed bump: interpreters/build tools, shells, exec wrappers,
|
||||
# network binaries, path execution, and the git/gh subcommands that
|
||||
# execute configured commands or materialize PR code. Prefix match is
|
||||
# word-boundary ("npm" blocks `npm test`, not `npmx`); shell redirects
|
||||
# are also checked against the write_file path rules.
|
||||
settings: |-
|
||||
# The deny list below backs the skill's no-PR-code rule as a speed
|
||||
# bump: interpreters/build tools, shells, exec wrappers, network
|
||||
# binaries, path execution, and the git/gh subcommands that execute
|
||||
# configured commands or materialize PR code. Prefix match is
|
||||
# word-boundary ("npm" blocks `npm test`, not `npmx`). It also
|
||||
# blocks `node`, which structurally prevents the agent from running
|
||||
# the publisher itself in CI mode.
|
||||
# vars.QWEN_TRIAGE_EFFORT sets model.reasoningEffort
|
||||
# (low|medium|high|xhigh|max; the CLI has no flag). Unset, it
|
||||
# renders "", which the CLI ignores — the provider default.
|
||||
cat > "$QWEN_HOME/settings.json" <<'SETTINGS_JSON'
|
||||
{
|
||||
"model": {
|
||||
"reasoningEffort": "${{ vars.QWEN_TRIAGE_EFFORT }}"
|
||||
@@ -925,7 +950,357 @@ jobs:
|
||||
]
|
||||
}
|
||||
}
|
||||
prompt: '/triage ${{ steps.resolve.outputs.number }} --repo ${{ github.repository }}'
|
||||
SETTINGS_JSON
|
||||
|
||||
# Direct CLI launch (the tmux/verify contract), replacing the former
|
||||
# qwen-code-action step: the proxy must live and die in the same step as
|
||||
# the agent, and env -i is what keeps the write credential out. The step
|
||||
# keeps the id `triage` and emits the same `summary` output so 'Check
|
||||
# triage response' stays unchanged.
|
||||
- name: 'Run Qwen Triage'
|
||||
id: 'triage'
|
||||
env:
|
||||
# Upstream model credentials — consumed by the loopback proxy below,
|
||||
# never passed to the agent (it gets the dummy PROXY_TOKEN instead).
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ vars.QWEN_TRIAGE_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
|
||||
# The agent's read credential: this job's read-only GITHUB_TOKEN.
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
BOT_LOGIN: '${{ steps.context.outputs.bot_login }}'
|
||||
NUMBER: '${{ steps.resolve.outputs.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
EVENT_NAME: '${{ github.event_name }}'
|
||||
TRIAGE_TIMEOUT_MINUTES: '${{ needs.authorize.outputs.triage_timeout_minutes || 60 }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
if ! command -v qwen >/dev/null 2>&1; then
|
||||
echo "::error::qwen CLI not found on runner"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bypass the runner proxy for qwen itself (it cuts SSE streams and
|
||||
# qwen reads HTTP(S)_PROXY without honoring NO_PROXY), while
|
||||
# restoring it for child gh/git commands the agent spawns. Same
|
||||
# shape as the tmux lane; wrappers live on the container's own disk
|
||||
# so no root-owned files leak onto the host through RUNNER_TEMP.
|
||||
# shellcheck disable=SC2016
|
||||
configure_qwen_network() {
|
||||
local openai_host proxy_bin
|
||||
openai_host="$(node -e 'console.log(new URL(process.env.OPENAI_BASE_URL).hostname)')"
|
||||
if [ -z "$openai_host" ]; then
|
||||
echo "::error::Could not parse a hostname from OPENAI_BASE_URL"
|
||||
exit 1
|
||||
fi
|
||||
export NO_PROXY="${NO_PROXY:+$NO_PROXY,}${openai_host}"
|
||||
export no_proxy="${no_proxy:+$no_proxy,}${openai_host}"
|
||||
export QWEN_CI_HTTPS_PROXY="${HTTPS_PROXY:-}"
|
||||
export QWEN_CI_https_proxy="${https_proxy:-}"
|
||||
export QWEN_CI_HTTP_PROXY="${HTTP_PROXY:-}"
|
||||
export QWEN_CI_http_proxy="${http_proxy:-}"
|
||||
proxy_bin="$(mktemp -d /tmp/qwen-network-bin.XXXXXX)"
|
||||
# mktemp -d is 700 root-owned; the agent runs as node and must be
|
||||
# able to traverse it, or the wrappers silently fall out of PATH
|
||||
# and child gh/git lose the runner proxy. The wrappers hold no
|
||||
# secrets (env-var names only).
|
||||
chmod 755 "$proxy_bin"
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
local real_gh
|
||||
real_gh="$(command -v gh)"
|
||||
export QWEN_CI_REAL_GH="$real_gh"
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"'
|
||||
printf '%s\n' 'exec "$QWEN_CI_REAL_GH" "$@"'
|
||||
} > "$proxy_bin/gh"
|
||||
chmod +x "$proxy_bin/gh"
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
local real_git
|
||||
real_git="$(command -v git)"
|
||||
export QWEN_CI_REAL_GIT="$real_git"
|
||||
{
|
||||
printf '%s\n' '#!/usr/bin/env bash'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_HTTPS_PROXY:-}" ] && export HTTPS_PROXY="$QWEN_CI_HTTPS_PROXY"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_https_proxy:-}" ] && export https_proxy="$QWEN_CI_https_proxy"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_HTTP_PROXY:-}" ] && export HTTP_PROXY="$QWEN_CI_HTTP_PROXY"'
|
||||
printf '%s\n' '[ -n "${QWEN_CI_http_proxy:-}" ] && export http_proxy="$QWEN_CI_http_proxy"'
|
||||
printf '%s\n' 'exec "$QWEN_CI_REAL_GIT" "$@"'
|
||||
} > "$proxy_bin/git"
|
||||
chmod +x "$proxy_bin/git"
|
||||
fi
|
||||
export PATH="$proxy_bin:$PATH"
|
||||
unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy
|
||||
}
|
||||
configure_qwen_network
|
||||
|
||||
unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL
|
||||
|
||||
# Root-owned loopback proxy: the real model key never enters the
|
||||
# agent environment. Ephemeral port + nonce health check + bearer
|
||||
# token defend against a squatter on the reused runner (proven
|
||||
# exploitable on the verify lane; same defences as tmux).
|
||||
start_openai_proxy() {
|
||||
local proxy_port proxy_script proxy_nonce port_file
|
||||
proxy_script="${RUNNER_TEMP:-/tmp}/qwen-openai-proxy-triage.js"
|
||||
port_file="${RUNNER_TEMP:-/tmp}/qwen-openai-proxy-triage.port"
|
||||
proxy_nonce="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
# NOT local on purpose: the agent env reads it as OPENAI_API_KEY
|
||||
# and the proxy receives it as PROXY_TOKEN (tmux lane rule).
|
||||
PROXY_TOKEN="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
export PROXY_TOKEN
|
||||
rm -f "$port_file"
|
||||
cat > "$proxy_script" <<'NODE'
|
||||
const http = require('node:http');
|
||||
const { writeFileSync } = require('node:fs');
|
||||
const { Readable } = require('node:stream');
|
||||
|
||||
const portFile = process.argv[2];
|
||||
const baseUrl = process.env.OPENAI_BASE_URL;
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const nonce = process.env.QWEN_PROXY_NONCE;
|
||||
const token = process.env.PROXY_TOKEN;
|
||||
if (!baseUrl || !apiKey || !portFile || !nonce || !token) {
|
||||
console.error('missing proxy configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base = new URL(baseUrl);
|
||||
const basePath = base.pathname.replace(/\/+$/, '');
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/__health') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(nonce);
|
||||
return;
|
||||
}
|
||||
|
||||
let timer;
|
||||
try {
|
||||
const incoming = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const target = new URL(base.origin);
|
||||
let path = incoming.pathname;
|
||||
if (
|
||||
basePath &&
|
||||
basePath !== '/' &&
|
||||
path !== basePath &&
|
||||
!path.startsWith(`${basePath}/`)
|
||||
) {
|
||||
path = `${basePath}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
target.pathname = path;
|
||||
target.search = incoming.search;
|
||||
|
||||
if (req.method !== 'POST' || !target.pathname.endsWith('/chat/completions')) {
|
||||
res.writeHead(403, { 'content-type': 'text/plain' });
|
||||
res.end('proxy: only POST /chat/completions is allowed\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers.authorization !== `Bearer ${token}`) {
|
||||
res.writeHead(401, { 'content-type': 'text/plain' });
|
||||
res.end('proxy: unauthorized\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete('host');
|
||||
headers.delete('content-length');
|
||||
headers.set('authorization', `Bearer ${apiKey}`);
|
||||
|
||||
const init = { method: req.method, headers };
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = req;
|
||||
init.duplex = 'half';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
timer = setTimeout(() => controller.abort(), 120_000);
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(target, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
res.writeHead(504, { 'content-type': 'text/plain' });
|
||||
res.end('proxy error: upstream request timed out\n');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// The timer deliberately stays armed after fetch() resolves on
|
||||
// headers: it doubles as the first idle window for the body,
|
||||
// refreshed per chunk below (tmux lane rule).
|
||||
const responseHeaders = {};
|
||||
upstream.headers.forEach((value, key) => {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower !== 'content-encoding' && lower !== 'content-length') {
|
||||
responseHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
res.writeHead(upstream.status, responseHeaders);
|
||||
if (upstream.body) {
|
||||
const body = Readable.fromWeb(upstream.body);
|
||||
const done = () => clearTimeout(timer);
|
||||
body.on('end', done);
|
||||
body.on('error', () => {
|
||||
done();
|
||||
res.destroy();
|
||||
});
|
||||
res.on('close', () => {
|
||||
done();
|
||||
controller.abort();
|
||||
});
|
||||
body.pipe(res);
|
||||
body.on('data', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => controller.abort(), 120_000);
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
console.error('proxy upstream failure:', error);
|
||||
res.writeHead(502, { 'content-type': 'text/plain' });
|
||||
res.end('proxy error: upstream request failed\n');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
writeFileSync(portFile, String(server.address().port));
|
||||
});
|
||||
NODE
|
||||
|
||||
OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||
OPENAI_BASE_URL="$OPENAI_BASE_URL" \
|
||||
QWEN_PROXY_NONCE="$proxy_nonce" \
|
||||
node "$proxy_script" "$port_file" &
|
||||
OPENAI_PROXY_PID=$!
|
||||
trap 'kill "$OPENAI_PROXY_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
proxy_port=''
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if ! kill -0 "$OPENAI_PROXY_PID" 2>/dev/null; then
|
||||
echo "::error::OpenAI proxy exited before becoming ready"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$proxy_port" ] && [ -s "$port_file" ]; then
|
||||
proxy_port="$(tr -cd '0-9' < "$port_file")"
|
||||
fi
|
||||
if [ -n "$proxy_port" ] &&
|
||||
[ "$(curl -fsS "http://127.0.0.1:${proxy_port}/__health" 2>/dev/null)" = "$proxy_nonce" ]; then
|
||||
break
|
||||
fi
|
||||
proxy_port=''
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$proxy_port" ] ||
|
||||
! kill -0 "$OPENAI_PROXY_PID" 2>/dev/null ||
|
||||
[ "$(curl -fsS "http://127.0.0.1:${proxy_port}/__health" 2>/dev/null)" != "$proxy_nonce" ]; then
|
||||
echo "::error::OpenAI proxy did not become ready (or another process answered on its port)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOCAL_OPENAI_BASE_URL="$(
|
||||
OPENAI_BASE_URL="$OPENAI_BASE_URL" node -e '
|
||||
const base = new URL(process.env.OPENAI_BASE_URL);
|
||||
const path = base.pathname.replace(/\/+$/, "");
|
||||
console.log("http://127.0.0.1:" + process.argv[1] + (path && path !== "/" ? path : ""));
|
||||
' "$proxy_port"
|
||||
)"
|
||||
export LOCAL_OPENAI_BASE_URL
|
||||
unset OPENAI_API_KEY OPENAI_BASE_URL
|
||||
echo "openai_proxy=enabled (${LOCAL_OPENAI_BASE_URL})"
|
||||
}
|
||||
start_openai_proxy
|
||||
|
||||
QWEN_CMD=(qwen --auth-type openai --approval-mode yolo)
|
||||
if [ -n "${OPENAI_MODEL:-}" ]; then
|
||||
QWEN_CMD+=(--model "$OPENAI_MODEL")
|
||||
fi
|
||||
|
||||
# The agent's graceful budget stays BELOW the job timeout so a
|
||||
# timeout still reaches the drafts upload, the publisher, and the
|
||||
# lifecycle finalize instead of the runner killing the container.
|
||||
AGENT_BUDGET_M=$(( ${TRIAGE_TIMEOUT_MINUTES:-60} - 10 ))
|
||||
if (( AGENT_BUDGET_M < 5 )); then
|
||||
AGENT_BUDGET_M=5
|
||||
fi
|
||||
|
||||
QWEN_ENV=(
|
||||
"HOME=/home/node"
|
||||
"USER=node"
|
||||
"SHELL=/bin/bash"
|
||||
"PATH=$PATH"
|
||||
"TERM=${TERM:-xterm-256color}"
|
||||
"LANG=${LANG:-C.UTF-8}"
|
||||
"CI=${CI:-true}"
|
||||
"GITHUB_WORKSPACE=$GITHUB_WORKSPACE"
|
||||
"GITHUB_REPOSITORY=$GITHUB_REPOSITORY"
|
||||
"GITHUB_EVENT_NAME=$EVENT_NAME"
|
||||
# Read-only job token: the agent's sole GitHub credential.
|
||||
"GITHUB_TOKEN=$GH_TOKEN"
|
||||
"GH_TOKEN=$GH_TOKEN"
|
||||
"BOT_LOGIN=$BOT_LOGIN"
|
||||
"REPOSITORY=$REPOSITORY"
|
||||
"QWEN_HOME=${RUNNER_TEMP:?}/qwen-home"
|
||||
"QWEN_TRIAGE_DRAFT_DIR=${RUNNER_TEMP:?}/triage-drafts"
|
||||
"QWEN_TRIAGE_PUBLISH=workflow"
|
||||
"QWEN_TRIAGE_CHANGELOG=${RUNNER_TEMP:?}/triage-context/claude-code-changelog.md"
|
||||
"OPENAI_API_KEY=$PROXY_TOKEN"
|
||||
"OPENAI_BASE_URL=$LOCAL_OPENAI_BASE_URL"
|
||||
"NO_PROXY=${NO_PROXY:-}"
|
||||
"no_proxy=${no_proxy:-}"
|
||||
"QWEN_CI_HTTPS_PROXY=${QWEN_CI_HTTPS_PROXY:-}"
|
||||
"QWEN_CI_https_proxy=${QWEN_CI_https_proxy:-}"
|
||||
"QWEN_CI_HTTP_PROXY=${QWEN_CI_HTTP_PROXY:-}"
|
||||
"QWEN_CI_http_proxy=${QWEN_CI_http_proxy:-}"
|
||||
"QWEN_CI_REAL_GH=${QWEN_CI_REAL_GH:-}"
|
||||
"QWEN_CI_REAL_GIT=${QWEN_CI_REAL_GIT:-}"
|
||||
)
|
||||
if [ -n "${OPENAI_MODEL:-}" ]; then
|
||||
QWEN_ENV+=("OPENAI_MODEL=$OPENAI_MODEL")
|
||||
fi
|
||||
|
||||
set +e
|
||||
timeout --kill-after=10s "${AGENT_BUDGET_M}m" runuser -u node -- env -i "${QWEN_ENV[@]}" "${QWEN_CMD[@]}" \
|
||||
--prompt "/triage ${NUMBER} --repo ${REPOSITORY}" \
|
||||
--output-format stream-json \
|
||||
| tee "$RUNNER_TEMP/triage-results/output.jsonl"
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# Same `summary` output the action used to emit: the last result
|
||||
# frame's text. 'Check triage response' consumes it unchanged.
|
||||
SUMMARY="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
let summary = "";
|
||||
for (const line of fs.readFileSync(process.argv[1], "utf8").split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.type === "result" && typeof frame.result === "string") {
|
||||
summary = frame.result;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
process.stdout.write(summary);
|
||||
' "$RUNNER_TEMP/triage-results/output.jsonl" 2>/dev/null || true)"
|
||||
# Random delimiter: the summary is agent-controlled text; a fixed
|
||||
# delimiter would let a steered result frame inject extra outputs
|
||||
# into this step's GITHUB_OUTPUT.
|
||||
SUMMARY_DELIM="TRIAGE_SUMMARY_$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
{
|
||||
echo "summary<<${SUMMARY_DELIM}"
|
||||
printf '%s\n' "$SUMMARY"
|
||||
echo "${SUMMARY_DELIM}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "triage exit code: $EXIT_CODE (budget ${AGENT_BUDGET_M}m)" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit "$EXIT_CODE"
|
||||
|
||||
- name: 'Check triage response'
|
||||
if: 'success() || failure()'
|
||||
@@ -979,6 +1354,216 @@ jobs:
|
||||
esac
|
||||
echo "Triage response received (${#RESPONSE} chars)."
|
||||
|
||||
# Flip the early status comment to a terminal state so a lingering
|
||||
# "running" line doesn't outlive the run. Neutral wording — the verdict
|
||||
# lives in the stage comments above; this only records that the run ended
|
||||
# and keeps the run link. PATCHes exactly the comment id the claim step
|
||||
# exported. An empty id means this run never ended up owning a comment:
|
||||
# a cancel landing before the claim posted, or a transient failure of the
|
||||
# claim's write (both the fresh-POST and PATCH arms export an empty id on
|
||||
# failure). Either way nothing is flipped and a previous run's terminal
|
||||
# wording stays untouched. A failed claim therefore leaves no lifecycle
|
||||
# breadcrumb even when the comments API recovers during the minutes-long
|
||||
# agent run — accepted: the verdict still lands in the stage comments,
|
||||
# and a list-then-POST recovery here would reintroduce exactly the
|
||||
# selection this coupling removes. Best-effort, on success, failure, AND
|
||||
# cancellation: always() (not success() || failure()) because
|
||||
# cancellation — cancel-in-progress superseding this run on a newer push
|
||||
# or comment, job timeout, or manual cancel — would skip this step and
|
||||
# leave the comment claiming the run is still in progress — the verify
|
||||
# lane has publish-verify to catch that case, this lane only has us.
|
||||
- name: 'Finalize triage status comment'
|
||||
if: "always() && steps.resolve.outputs.number != ''"
|
||||
shell: 'bash'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
STATUS_COMMENT_ID: '${{ steps.status.outputs.comment_id }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
TRIAGE_OUTCOME: '${{ steps.triage.outcome }}'
|
||||
JOB_STATUS: '${{ job.status }}'
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
if [ -z "${STATUS_COMMENT_ID:-}" ]; then
|
||||
echo "This run claimed no status comment; nothing to finalize." >&2
|
||||
else
|
||||
# A step success is not a green job: 'Check triage response' exits
|
||||
# 1 on an empty summary while steps.triage.outcome stays 'success',
|
||||
# and a later step can still fail. Only a non-failed job may claim
|
||||
# "finished".
|
||||
if [ "${TRIAGE_OUTCOME:-}" = 'success' ] && [ "${JOB_STATUS:-}" != 'failure' ]; then
|
||||
EN="✅ **Qwen Triage finished** — [view run]($RUN_URL). See the stage comments in this thread for the result."
|
||||
ZH="✅ **Qwen Triage 已完成** —— [查看运行]($RUN_URL)。结果见本线程中的各阶段评论。"
|
||||
elif [ "${JOB_STATUS:-}" = 'cancelled' ]; then
|
||||
EN="🚫 **Qwen Triage was cancelled** — [view run]($RUN_URL). The run was cancelled before finishing. Check for a newer run before re-running."
|
||||
ZH="🚫 **Qwen Triage 已取消** —— [查看运行]($RUN_URL)。运行未完成即被取消。重跑前请先确认是否有更新的运行。"
|
||||
else
|
||||
EN="⚠️ **Qwen Triage ended early** — [view run]($RUN_URL). It stopped before finishing; check the run log."
|
||||
ZH="⚠️ **Qwen Triage 提前结束** —— [查看运行]($RUN_URL)。未跑完,请查看运行日志。"
|
||||
fi
|
||||
printf -v BODY '%s\n\n%s\n\n%s' '<!-- qwen-triage lifecycle -->' "$EN" "$ZH"
|
||||
gh api --method PATCH \
|
||||
"repos/$GITHUB_REPOSITORY/issues/comments/$STATUS_COMMENT_ID" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to finalize triage status comment; continuing." >&2
|
||||
fi
|
||||
|
||||
# Drafts are the agent's only output channel to GitHub. Upload on every
|
||||
# outcome so a timeout or a failed check still publishes what was
|
||||
# drafted. Strip non-regular files first: actions/upload-artifact
|
||||
# FOLLOWS symlinks, and a node-planted link would exfiltrate its target
|
||||
# into the artifact (tmux/verify rule).
|
||||
- name: 'Sanitize triage drafts'
|
||||
if: 'always()'
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
# A steered agent can background children (the denylist is not a
|
||||
# boundary), and a detached child could re-plant symlinks between
|
||||
# the strip below and the upload — RUNNER_TEMP is shared across
|
||||
# jobs on the host, so a followed link exfiltrates other lanes'
|
||||
# data. Kill everything still running as the agent user FIRST
|
||||
# (tmux/verify rule). Zombies do not count: they hold nothing open
|
||||
# and cannot be killed, so counting them would never clear.
|
||||
live_agent_processes() {
|
||||
ps -o pid=,stat=,args= -u node 2>/dev/null | awk '$2 !~ /^Z/'
|
||||
}
|
||||
pkill -KILL -u node 2>/dev/null || true
|
||||
for _ in 1 2 3; do
|
||||
[ -n "$(live_agent_processes)" ] || break
|
||||
sleep 1
|
||||
pkill -KILL -u node 2>/dev/null || true
|
||||
done
|
||||
survivors="$(live_agent_processes)" || true
|
||||
if [ -n "$survivors" ]; then
|
||||
# Deny by default: an unkillable writer makes the draft set
|
||||
# untrustworthy, so drop it instead of uploading a race loser.
|
||||
echo "::error::Processes owned by the agent user survived SIGKILL; dropping drafts as possibly tainted."
|
||||
printf '%s\n' "$survivors"
|
||||
rm -rf "${RUNNER_TEMP:?}/triage-drafts"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "${RUNNER_TEMP:?}/triage-drafts" ] || exit 0
|
||||
find "$RUNNER_TEMP/triage-drafts" \( -type l -o -type p -o -type s -o -type b -o -type c \) -delete 2>/dev/null || true
|
||||
|
||||
- name: 'Upload triage drafts'
|
||||
if: 'always()'
|
||||
# A missing drafts dir (agent died before writing any) must not mask
|
||||
# the original error; the publisher reports the absence itself.
|
||||
continue-on-error: true
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # v4.6.2
|
||||
with:
|
||||
# Same expression as the download side: if the run died before
|
||||
# 'Resolve target number', both sides fall back to the event's
|
||||
# issue number instead of disagreeing on an empty segment.
|
||||
name: 'triage-drafts-${{ steps.resolve.outputs.number || github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
path: '${{ runner.temp }}/triage-drafts/'
|
||||
retention-days: 7
|
||||
if-no-files-found: 'warn'
|
||||
|
||||
# Restore workspace ownership while the container still runs as root so
|
||||
# the next job on this runner can check out without EACCES (the context
|
||||
# step chowned everything to node; tmux lane rule).
|
||||
- name: 'Restore workspace ownership after agent'
|
||||
if: 'always()'
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || echo "::warning::could not restore workspace write permissions"
|
||||
RUNNER_UID="$(stat -c '%u' "$RUNNER_TEMP" 2>/dev/null || stat -f '%u' "$RUNNER_TEMP" 2>/dev/null || echo 0)"
|
||||
RUNNER_GID="$(stat -c '%g' "$RUNNER_TEMP" 2>/dev/null || stat -f '%g' "$RUNNER_TEMP" 2>/dev/null || echo 0)"
|
||||
if [ "$RUNNER_UID" != "0" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || echo "::warning::could not restore workspace ownership"
|
||||
else
|
||||
echo "::warning::could not determine runner UID; skipping ownership restore"
|
||||
fi
|
||||
|
||||
# Publish the triage agent's drafts. Runs on a clean runner with the write
|
||||
# PAT and never executes agent-provided code — only validated data. The
|
||||
# triage job above holds no write credential in the agent environment; this
|
||||
# job is the sole writer to GitHub for the triage lane. Same isolation
|
||||
# shape as publish-tmux / publish-verify.
|
||||
publish-triage:
|
||||
needs: ['triage']
|
||||
# Publish even when the agent run failed or timed out: drafts written
|
||||
# before the failure still reach the PR, and the lifecycle comment in the
|
||||
# triage job records the early end. 'skipped' means no triage ran at all.
|
||||
if: >-
|
||||
always() &&
|
||||
needs.triage.result != 'skipped' &&
|
||||
github.repository == 'QwenLM/qwen-code'
|
||||
# Per-RUN group, deliberately not per-PR (publish-tmux rule): a per-PR
|
||||
# group would let a second run replace a completed run's pending
|
||||
# publisher and drop its drafts. Overlap is made safe by the publisher's
|
||||
# bot-owned marker upserts.
|
||||
concurrency:
|
||||
group: "${{ format('{0}-publish-triage-{1}', github.workflow, github.run_id) }}"
|
||||
cancel-in-progress: false
|
||||
timeout-minutes: 10
|
||||
# Checks out only trusted base-repo scripts at the default-branch head
|
||||
# (issue_comment/pull_request_target events resolve github.sha there);
|
||||
# no PR code, so the persistent pool is safe (publish-tmux rule).
|
||||
# Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED.
|
||||
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
- name: 'Restore workspace ownership'
|
||||
if: "${{ runner.environment == 'self-hosted' }}"
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
RUNNER_UID="$(id -u)"
|
||||
RUNNER_GID="$(id -g)"
|
||||
if [ "$RUNNER_UID" != "0" ]; then
|
||||
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; artifact download may fail on leftover root-owned files"
|
||||
fi
|
||||
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; artifact download may fail on leftover root-owned files"
|
||||
|
||||
# Sparse and pinned: the publisher, the owner-assignment helpers it
|
||||
# imports, and the owner policy — nothing else (publish-verify pattern).
|
||||
- name: 'Checkout the triage publisher scripts'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.sha }}'
|
||||
sparse-checkout: |-
|
||||
/.github/scripts/triage-publish.mjs
|
||||
/.github/scripts/assign-issue-owner.mjs
|
||||
/.github/issue-owners.json
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Download triage drafts'
|
||||
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0
|
||||
with:
|
||||
name: 'triage-drafts-${{ needs.triage.outputs.pr_number || github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}'
|
||||
path: 'triage-drafts'
|
||||
continue-on-error: true
|
||||
|
||||
- name: 'Publish triage drafts'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
NUMBER: '${{ needs.triage.outputs.pr_number || github.event.issue.number }}'
|
||||
TRIAGE_RESULT: '${{ needs.triage.result }}'
|
||||
QWEN_MAINTAINER_HANDLE: '${{ vars.QWEN_MAINTAINER_HANDLE }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
if [ -z "${NUMBER:-}" ]; then
|
||||
echo "::warning::No target number resolved; nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f triage-drafts/actions.jsonl ]; then
|
||||
if [ "${TRIAGE_RESULT:-}" = 'success' ]; then
|
||||
echo "::error::The triage agent succeeded but produced no drafts; nothing was published."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::The triage run ended before writing drafts; nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
node .github/scripts/triage-publish.mjs \
|
||||
--drafts triage-drafts \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--number "$NUMBER"
|
||||
|
||||
# Moved here from the triage job: it inspects the reviews the run
|
||||
# posted, and those now land in the publish step above, so it must run
|
||||
# AFTER publishing. Body unchanged otherwise.
|
||||
- name: 'Notify silent triage re-run'
|
||||
if: >-
|
||||
success() &&
|
||||
@@ -988,7 +1573,7 @@ jobs:
|
||||
shell: 'bash'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
NUMBER: '${{ steps.resolve.outputs.number }}'
|
||||
NUMBER: '${{ needs.triage.outputs.pr_number || github.event.issue.number }}'
|
||||
TRIGGERED_AT: '${{ github.event.comment.created_at }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
@@ -1107,59 +1692,6 @@ jobs:
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$NUMBER/comments" \
|
||||
-f body="$BODY" >/dev/null
|
||||
|
||||
# Flip the early status comment to a terminal state so a lingering
|
||||
# "running" line doesn't outlive the run. Neutral wording — the verdict
|
||||
# lives in the stage comments above; this only records that the run ended
|
||||
# and keeps the run link. PATCHes exactly the comment id the claim step
|
||||
# exported. An empty id means this run never ended up owning a comment:
|
||||
# a cancel landing before the claim posted, or a transient failure of the
|
||||
# claim's write (both the fresh-POST and PATCH arms export an empty id on
|
||||
# failure). Either way nothing is flipped and a previous run's terminal
|
||||
# wording stays untouched. A failed claim therefore leaves no lifecycle
|
||||
# breadcrumb even when the comments API recovers during the minutes-long
|
||||
# agent run — accepted: the verdict still lands in the stage comments,
|
||||
# and a list-then-POST recovery here would reintroduce exactly the
|
||||
# selection this coupling removes. Best-effort, on success, failure, AND
|
||||
# cancellation: always() (not success() || failure()) because
|
||||
# cancellation — cancel-in-progress superseding this run on a newer push
|
||||
# or comment, job timeout, or manual cancel — would skip this step and
|
||||
# leave the comment claiming the run is still in progress — the verify
|
||||
# lane has publish-verify to catch that case, this lane only has us.
|
||||
- name: 'Finalize triage status comment'
|
||||
if: "always() && steps.resolve.outputs.number != ''"
|
||||
shell: 'bash'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.QWEN_CODE_BOT_TOKEN || secrets.CI_BOT_PAT }}'
|
||||
STATUS_COMMENT_ID: '${{ steps.status.outputs.comment_id }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
TRIAGE_OUTCOME: '${{ steps.triage.outcome }}'
|
||||
JOB_STATUS: '${{ job.status }}'
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
if [ -z "${STATUS_COMMENT_ID:-}" ]; then
|
||||
echo "This run claimed no status comment; nothing to finalize." >&2
|
||||
else
|
||||
# A step success is not a green job: 'Check triage response' exits
|
||||
# 1 on an empty summary while steps.triage.outcome stays 'success',
|
||||
# and a later step can still fail. Only a non-failed job may claim
|
||||
# "finished".
|
||||
if [ "${TRIAGE_OUTCOME:-}" = 'success' ] && [ "${JOB_STATUS:-}" != 'failure' ]; then
|
||||
EN="✅ **Qwen Triage finished** — [view run]($RUN_URL). See the stage comments in this thread for the result."
|
||||
ZH="✅ **Qwen Triage 已完成** —— [查看运行]($RUN_URL)。结果见本线程中的各阶段评论。"
|
||||
elif [ "${JOB_STATUS:-}" = 'cancelled' ]; then
|
||||
EN="🚫 **Qwen Triage was cancelled** — [view run]($RUN_URL). The run was cancelled before finishing. Check for a newer run before re-running."
|
||||
ZH="🚫 **Qwen Triage 已取消** —— [查看运行]($RUN_URL)。运行未完成即被取消。重跑前请先确认是否有更新的运行。"
|
||||
else
|
||||
EN="⚠️ **Qwen Triage ended early** — [view run]($RUN_URL). It stopped before finishing; check the run log."
|
||||
ZH="⚠️ **Qwen Triage 提前结束** —— [查看运行]($RUN_URL)。未跑完,请查看运行日志。"
|
||||
fi
|
||||
printf -v BODY '%s\n\n%s\n\n%s' '<!-- qwen-triage lifecycle -->' "$EN" "$ZH"
|
||||
gh api --method PATCH \
|
||||
"repos/$GITHUB_REPOSITORY/issues/comments/$STATUS_COMMENT_ID" \
|
||||
-f body="$BODY" >/dev/null ||
|
||||
echo "::warning::Failed to finalize triage status comment; continuing." >&2
|
||||
fi
|
||||
|
||||
# On-demand real-user testing: a write-permission user comments
|
||||
# `@qwen-code /tmux` on a PR to launch the changed app in a tmux TUI and
|
||||
# exercise the affected flow. EXECUTES untrusted PR code, so: gated on the PR
|
||||
|
||||
Reference in new issue
Block a user