Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/scripts/check-autofix-contracts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -uo pipefail

fail() {
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "outcome=failed" >> "${GITHUB_OUTPUT}"
fi
exit 1
}

changed_files="$(cat)"

if ! npm run check-i18n; then
echo '❌ i18n verification failed.'
fail
fi

if grep -Fxq 'packages/core/src/tools/tool-names.ts' <<< "${changed_files}"; then
if ! npm run test --workspace packages/web-shell -- \
client/components/messages/toolFormatting.drift.test.ts; then
echo '❌ Web Shell tool-display contract verification failed.'
fail
fi
fi
6 changes: 6 additions & 0 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ jobs:
- name: 'Stage trusted schema gate'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"

- name: 'Check bot credentials'
Expand Down Expand Up @@ -1151,6 +1152,8 @@ jobs:
# and kill the gate with no outcome), and the gate logic must come
# from the trusted base, not the branch under verification.
bash "${RUNNER_TEMP}/check-settings-schema.sh"
git diff --name-only "origin/main...${BRANCH}" \
| bash "${RUNNER_TEMP}/check-autofix-contracts.sh"

# Run changed/related tests for the packages this fix touches.
# --changed follows the import graph so transitive breakage is caught.
Expand Down Expand Up @@ -2480,6 +2483,7 @@ jobs:
- name: 'Stage trusted schema gate and agent runner'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"
# The agent step runs AFTER prepare checks out the PR branch, so
# invoking the runner from the working tree would execute
Expand Down Expand Up @@ -3075,6 +3079,8 @@ jobs:
# and kill the gate with no outcome), and the gate logic must come
# from the trusted base, not the branch under verification.
bash "${RUNNER_TEMP}/check-settings-schema.sh"
git diff --name-only "origin/main...${BRANCH}" \
| bash "${RUNNER_TEMP}/check-autofix-contracts.sh"

if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then
# No new commit. That is only legitimate as a deliberate no-action.
Expand Down
95 changes: 95 additions & 0 deletions scripts/tests/qwen-autofix-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const sandboxImageResolverScript = readFileSync(
'.github/scripts/resolve-sandbox-image.mjs',
'utf8',
);
const autofixContractsScriptPath = '.github/scripts/check-autofix-contracts.sh';
const autofixContractsScript = readFileSync(autofixContractsScriptPath, 'utf8');
const autofixRunnerScriptPath = '.qwen/skills/autofix/scripts/run-agent.mjs';
const checkBotCredentialsStep =
workflow.match(
Expand Down Expand Up @@ -4103,6 +4105,12 @@ describe('qwen-autofix workflow', () => {
expect(step).not.toContain(
'bash .github/scripts/check-settings-schema.sh',
);
expect(step).toContain(
'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"',
);
expect(step).not.toContain(
'bash .github/scripts/check-autofix-contracts.sh',
);
// The owning-package resolver is likewise a shared script staged from the
// trusted base, invoked (not inlined) so the two gates cannot drift into
// resolving packages differently. The old inline detection must be gone.
Expand All @@ -4125,6 +4133,11 @@ describe('qwen-autofix workflow', () => {
/cp \.github\/scripts\/check-settings-schema\.sh "\$\{RUNNER_TEMP\}\/check-settings-schema\.sh"/g,
) ?? [],
).toHaveLength(2);
expect(
workflow.match(
/cp \.github\/scripts\/check-autofix-contracts\.sh "\$\{RUNNER_TEMP\}\/check-autofix-contracts\.sh"/g,
) ?? [],
).toHaveLength(2);
// The owning-package resolver is staged the same way, in the same steps.
expect(
workflow.match(
Expand Down Expand Up @@ -4201,6 +4214,88 @@ describe('qwen-autofix workflow', () => {
'bash "${RUNNER_TEMP}/check-settings-schema.sh"',
),
).toBeLessThan(reviewVerifyGate.indexOf('outcome=noop'));
expect(
reviewVerifyGate.indexOf(
'bash "${RUNNER_TEMP}/check-autofix-contracts.sh"',
),
).toBeLessThan(reviewVerifyGate.indexOf('outcome=noop'));
expect(autofixContractsScript).toContain('npm run check-i18n');
expect(autofixContractsScript).toContain(
'packages/core/src/tools/tool-names.ts',
);
expect(autofixContractsScript).toContain(
'client/components/messages/toolFormatting.drift.test.ts',
);
expect(autofixContractsScript).toContain('outcome=failed');
expect(ciWorkflow).toContain("run: 'npm run check-i18n'");
expect(ciWorkflow).toContain('npm run test:ci');
});

it('runs cross-package autofix contracts only when their source changes', () => {
const dir = mkdtempSync(join(tmpdir(), 'autofix-contracts-'));
const npmLog = join(dir, 'npm.log');
try {
writeFileSync(
join(dir, 'npm'),
[
'#!/usr/bin/env bash',
'printf \'%s\\n\' "$*" >> "${NPM_LOG}"',
'if [[ "$*" == "run check-i18n" ]]; then',
' exit "${I18N_EXIT:-0}"',
'fi',
'exit "${DRIFT_EXIT:-0}"',
'',
].join('\n'),
);
chmodSync(join(dir, 'npm'), 0o755);

const run = (changedFiles, extraEnv = {}) =>
spawnSync('bash', [resolve(autofixContractsScriptPath)], {
input: changedFiles,
encoding: 'utf8',
env: {
...process.env,
PATH: `${dir}:${process.env.PATH}`,
NPM_LOG: npmLog,
...extraEnv,
},
});

expect(run('packages/core/src/config/config.ts\n').status).toBe(0);
expect(readFileSync(npmLog, 'utf8').trim().split('\n')).toEqual([
'run check-i18n',
]);

writeFileSync(npmLog, '');
expect(run('packages/core/src/tools/tool-names.ts\n').status).toBe(0);
expect(readFileSync(npmLog, 'utf8').trim().split('\n')).toEqual([
'run check-i18n',
'run test --workspace packages/web-shell -- client/components/messages/toolFormatting.drift.test.ts',
]);

writeFileSync(npmLog, '');
const output = join(dir, 'output');
expect(
run('packages/core/src/tools/tool-names.ts\n', {
GITHUB_OUTPUT: output,
I18N_EXIT: '1',
}).status,
).toBe(1);
expect(readFileSync(npmLog, 'utf8').trim()).toBe('run check-i18n');
expect(readFileSync(output, 'utf8')).toContain('outcome=failed');

writeFileSync(npmLog, '');
writeFileSync(output, '');
expect(
run('packages/core/src/tools/tool-names.ts\n', {
GITHUB_OUTPUT: output,
DRIFT_EXIT: '1',
}).status,
).toBe(1);
expect(readFileSync(output, 'utf8')).toContain('outcome=failed');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('passes model credentials directly to qwen subprocesses', () => {
Expand Down
Loading