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
73 changes: 73 additions & 0 deletions .github/scripts/resolve-owning-packages.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Owning-workspace resolver, shared by the qwen-autofix verify steps
# (.github/workflows/qwen-autofix.yml) so the two gates cannot drift apart.
#
# Reads changed file paths on stdin (one per line, e.g. the output of
# `git diff --name-only`) and emits, sorted and unique on stdout, the OWNING
# npm workspace of each: the workspace whose location is the LONGEST matching
# path prefix of the file.
#
# The workspace set is expanded from the ON-DISK root package.json `workspaces`
# globs, NOT from `npm query`/node_modules: node_modules reflects the BASE
# checkout the gate installed, so a workspace the PR branch ADDS (a new channel
# adapter, a new sdk — the issue-fix job's whole purpose) would be invisible and
# its tests silently skipped. It is also NOT "any ancestor dir with a
# package.json": a fixture/example package inside a workspace's src tree (e.g.
# packages/cli/src/commands/extensions/examples/starter) has a package.json but
# is not a workspace, so resolving a change there to the fixture would skip
# packages/cli's own tests. Expanding the globs (shallow `dir/*` + literals,
# honouring `!` negations, keeping dirs that contain a package.json) matches
# what `npm run --workspace` accepts downstream and reflects the branch.
#
# Invoked with the repository as the working directory. Staged to RUNNER_TEMP
# from the trusted base checkout (never the PR branch) alongside
# check-settings-schema.sh.
set -euo pipefail

workspaces="$(node -e '
const fs = require("fs");
const path = require("path");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
let globs = pkg.workspaces || [];
if (!Array.isArray(globs)) globs = globs.packages || [];
const positive = [];
const negative = [];
for (const g of globs) (g[0] === "!" ? negative : positive).push(g.replace(/^!/, ""));
const hasManifest = (d) => {
try { return fs.statSync(path.join(d, "package.json")).isFile(); }
catch { return false; }
};
const expand = (g) => {
const star = g.indexOf("*");
if (star === -1) return [g];
const parent = g.slice(0, star).replace(/\/$/, "");
let entries = [];
try { entries = fs.readdirSync(parent, { withFileTypes: true }); }
catch { return []; }
return entries.filter((e) => e.isDirectory()).map((e) => path.posix.join(parent, e.name));
};
const dirs = new Set();
for (const g of positive) for (const d of expand(g)) if (hasManifest(d)) dirs.add(d);
for (const g of negative) { for (const d of expand(g)) dirs.delete(d); dirs.delete(g); }
process.stdout.write([...dirs].sort().join("\n"));
')"

if [[ -z "${workspaces}" ]]; then
echo "resolve-owning-packages: no workspaces resolved from package.json" >&2
exit 1
fi

while IFS= read -r f || [[ -n "${f}" ]]; do
[[ -n "${f}" ]] || continue
best=''
while IFS= read -r w; do
[[ -n "${w}" ]] || continue
if [[ "${f}" == "${w}"/* && "${#w}" -gt "${#best}" ]]; then
best="${w}"
fi
done <<< "${workspaces}"
# `if`, not `[[ ]] && printf`: an unmatched file (best empty) must leave the
# loop body's exit status 0, or under `set -o pipefail` a no-match on the LAST
# line makes `while … | sort` fail and (with `set -e`) aborts the script.
if [[ -n "${best}" ]]; then printf '%s\n' "${best}"; fi
done | sort -u
63 changes: 60 additions & 3 deletions .github/workflows/qwen-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ jobs:
- name: 'Stage trusted schema gate'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"

- name: 'Check bot credentials'
env:
Expand Down Expand Up @@ -1035,12 +1036,26 @@ jobs:
# Run changed/related tests for the packages this fix touches.
# --changed follows the import graph so transitive breakage is caught.
# Full regression is covered by regular CI on the PR after the push.
# Map each changed file to its OWNING npm workspace via the trusted
# staged resolver, shared with the other verify gate so both resolve
# packages identically. It expands the on-disk root package.json
# workspaces globs (so a workspace the branch ADDS is included) and
# takes each file's longest-prefix workspace — never a flat
# 'packages/<dir>' (ENOENT-crashes on nested packages) nor a fixture
# package.json inside a workspace's src tree (would skip the owning
# workspace's tests). No '|| true': a resolver error (missing node, an
# unreadable manifest) must fail the gate loudly rather than silently
# skip package tests; legitimate no-match input already exits 0 empty.
CHANGED_PKGS="$(git diff --name-only "origin/main...${BRANCH}" \
| grep -oE '^packages/[^/]+' | sort -u || true)"
| bash "${RUNNER_TEMP}/resolve-owning-packages.sh")"
if [[ -z "${CHANGED_PKGS}" ]]; then
echo 'No package changes detected; skipping package tests.'
else
for p in ${CHANGED_PKGS}; do
if [[ ! -f "${p}/package.json" ]]; then
echo "Skipping ${p}: no package.json."
continue
fi
test_script="$(node -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(pkg.scripts?.test || "");' "${p}/package.json")"
if [[ "${test_script}" != *vitest* ]]; then
echo "Skipping ${p}: test script is not Vitest."
Expand Down Expand Up @@ -1931,6 +1946,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/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
# branch-controlled code on the host with the model key in env
Expand Down Expand Up @@ -2413,6 +2429,20 @@ jobs:
if: |-
${{ always() && steps.prepare.outputs.stale != 'true' }}
run: |-
# Record whether the agent left a commit FIRST — this is a ref-only
# diff, so it runs before the failure.md early-exits and covers an
# agent that commits and then aborts. The failure handoff keys its
# "was NOT pushed / commit discarded" wording on this, NOT on
# outcome=failed: abort / pre-commit-gate paths that never committed
# keep the neutral framing. `git diff --quiet` exits 1 for a real diff
# (committed) but 128 on a bad ref — only 1 counts as a commit, so a
# git error is not misreported as a discarded commit.
committed_rc=0
git diff --quiet "origin/${BRANCH}...${BRANCH}" || committed_rc=$?
if [[ "${committed_rc}" -eq 1 ]]; then
echo "committed=true" >> "${GITHUB_OUTPUT}"
fi

if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then
echo "❌ Agent wrote failure.md after leaving a dirty workspace:"
git status --short
Expand Down Expand Up @@ -2476,12 +2506,26 @@ jobs:
# Test changed/related files for the packages this PR touches.
# --changed follows the import graph so transitive breakage is caught.
# Full regression is covered by regular CI on the PR after the push.
# Map each changed file to its OWNING npm workspace via the trusted
# staged resolver, shared with the other verify gate so both resolve
# packages identically. It expands the on-disk root package.json
# workspaces globs (so a workspace the branch ADDS is included) and
# takes each file's longest-prefix workspace — never a flat
# 'packages/<dir>' (ENOENT-crashes on nested packages) nor a fixture
# package.json inside a workspace's src tree (would skip the owning
# workspace's tests). No '|| true': a resolver error (missing node, an
# unreadable manifest) must fail the gate loudly rather than silently
# skip package tests; legitimate no-match input already exits 0 empty.
CHANGED_PKGS="$(git diff --name-only "origin/main...${BRANCH}" \
| grep -oE '^packages/[^/]+' | sort -u || true)"
| bash "${RUNNER_TEMP}/resolve-owning-packages.sh")"
if [[ -z "${CHANGED_PKGS}" ]]; then
echo 'No package changes detected; skipping package tests.'
else
for p in ${CHANGED_PKGS}; do
if [[ ! -f "${p}/package.json" ]]; then
echo "Skipping ${p}: no package.json."
continue
fi
test_script="$(node -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(pkg.scripts?.test || "");' "${p}/package.json")"
if [[ "${test_script}" != *vitest* ]]; then
echo "Skipping ${p}: test script is not Vitest."
Expand Down Expand Up @@ -2634,6 +2678,7 @@ jobs:
${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }}
env:
OUTCOME: '${{ steps.verify.outputs.outcome }}'
COMMITTED: '${{ steps.verify.outputs.committed }}'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
Expand Down Expand Up @@ -2768,7 +2813,19 @@ jobs:
echo "${HEADLINE}"
echo
if [[ -n "${DETAIL_FILE}" ]]; then
echo "**What I found before stopping:**"
if [[ "${COMMITTED}" == "true" ]]; then
# The agent committed (verify recorded committed=true before
# any gate could fail), but every path that reaches this
# handoff skipped "Push and report" — nothing landed on the
# branch. Say so before the agent's address-summary.md, which
# can read like a success and cite that now-discarded commit
# SHA. Keyed on committed, NOT outcome=failed: the abort/no-op
# paths (failure.md, dirty tree, unchanged branch, missing
# summary) made no commit and keep the neutral framing below.
echo "⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:"
else
echo "**What I found before stopping:**"
fi
# -c drops any partial multi-byte sequence a byte-level head -c may
# have split, so the comment body stays valid UTF-8. iconv -c still
# EXITS 1 when it discards a byte, which under this shell's
Expand Down
4 changes: 3 additions & 1 deletion scripts/tests/package-scripts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,9 @@ describe('package scripts', () => {
expect(verifyStep).toContain(
'npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests',
);
expect(verifyStep).toContain("grep -oE '^packages/[^/]+'");
expect(verifyStep).toContain(
'bash "${RUNNER_TEMP}/resolve-owning-packages.sh"',
);
expect(verifyStep).toContain('pkg.scripts?.test');
expect(verifyStep).toContain('!= *vitest*');
expect(verifyStep).not.toContain(
Expand Down
Loading
Loading