ci(sdk-java): stop the API contract version from regressing or stalling (#13804) (#13808)

The public Managed Agent contract's info.version is allocated by hand on
every contract-changing pull request, and nothing checked it: on
2026-10-09 four such pull requests were in flight with 1.34.0 claimed
twice while main was already at 1.35.0, and the stall had reached main
once before — #13163 changed the document with no version movement. A
duplicate Flyway version refuses to boot within minutes, but a regressed
or stalled contract version breaks nothing at runtime; it silently
misinforms every consumer that keys capability detection on it.

On every pull request the Flyway lane now also compares the merge
result's document with the base branch's head copy — fetched at depth
one, since the default checkout is one commit deep — and fails when a
changed document declares an equal or lower version, compared numerically
per segment so 1.9.0 ranks below 1.37.0, naming whether the number
stalled or regressed; a pull request arriving at the base ref's own
bytes owes no bump. Each refusal arm has a test that fails when its
guard is removed, and any path echoed into an ::error:: line is bound to
the same byte alphabet check-flyway-migrations.js applies, so a hostile
name cannot forge a second error line into a decoded log.

After every push to main that changes the document, and every 30
minutes, a new workflow re-checks each open pull request's changed paths
and document through the API — no pull request code is fetched, checked
out or run — and sets the 'API contract version uniqueness (latest
main)' status, the #13770 shape for the version number instead of the
migrations: failure when the declared version no longer advances past
main's, success otherwise, naming the other open pull requests claiming
the same version, and error when a file list or head document could not
be read in full. A head that already carries the status is re-evaluated
even after it stops changing the document, so a stale failure clears.

Both scripts ran live before this commit: the pull-request check over
four merge shapes (stall, regression, advance, untouched) against a
depth-one clone and filtered base fetch, and the re-check as a dry run
against the open queue, whose six contract-changing pull requests it
correctly read as stalled or regressed against main at 1.38.0.

Fixes #13804
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-10-10 05:43:18 +00:00
1 parent c3a6e43554
commit ba8615f4c4
11 files changed
+1760 -1

No files matched your search

+2 -1
View File
@@ -55,8 +55,9 @@
35955 release.yml
44285 repo-hygiene.yml
1079 scorecard-monthly.yml
9751 sdk-java-contract-open-prs.yml
9555 sdk-java-flyway-open-prs.yml
45184 sdk-java.yml
49062 sdk-java.yml
3886 sdk-python.yml
5595 security-checks.yml
6777 serve-ab-publish.yml
@@ -0,0 +1,174 @@
name: 'SDK Java Contract Open PRs'
# The contract-version guard in sdk-java.yml checks a pull request's
# document against the base ref of the moment it ran. Nothing re-runs it
# when main later moves, so a PR that went green claiming the next version
# stays green after another PR takes that version and publishes the number
# twice once it merges; it also cannot see several open PRs claiming one
# version — 2026-10-09 had four contract-changing PRs in flight with
# 1.34.0 claimed twice (#13804, mirroring #13742). After every push to
# main that changes the contract document, this re-checks every open PR
# against main from the API's file lists and the claiming PRs' documents
# from the contents API — no PR code is fetched, checked out or run — and
# sets the 'API contract version uniqueness (latest main)' status on PR
# heads as scripts/check-contract-open-prs.js describes. The schedule
# catches up after a failed or cancelled run and reaches PRs opened,
# pushed or retargeted to main since the last one; a head whose status
# would not change is not written again.
on:
push:
branches:
- 'main'
paths:
- 'packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json'
- 'scripts/check-contract-open-prs.js'
- 'scripts/contract-version-utils.js'
- '.github/workflows/sdk-java-contract-open-prs.yml'
schedule:
- cron: '13,43 * * * *'
workflow_dispatch:
permissions:
contents: 'read'
# Queued, never cancelled mid-run: GitHub keeps only the newest pending run,
# and each run checks out main when it starts — a re-run included — so the
# last statuses written are those of the newest main. Keyed by ref so that a
# dispatch from another branch, whose job is skipped, cannot replace a
# pending main run.
concurrency:
group: 'sdk-java-contract-open-prs-${{ github.ref }}'
cancel-in-progress: false
jobs:
recheck:
name: 'Re-check open PRs against main'
if: "${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}"
# Push, schedule and dispatch are all trusted triggers, so the ECS pool
# (#13245): the check races the next merge. 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"]'') }}'
timeout-minutes: 10
permissions:
contents: 'read'
pull-requests: 'read'
statuses: 'write'
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; checkout 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; checkout may fail on leftover read-only files"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# ci.yml's pre-checkout sweep, byte-identical to the ci.yml copies
# (pinned in scripts/tests/sdk-java-workflow.test.js): residue left by
# review/autofix jobs on the shared ECS pool fails actions/checkout's
# clean — the #13506 failure. Hosted legs skip it on fresh VMs.
- name: 'Clean stale .qwen before checkout'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: 'main'
persist-credentials: false
- name: 'Re-check open pull requests'
env:
GH_TOKEN: '${{ github.token }}'
run: 'node scripts/check-contract-open-prs.js'
+28
View File
@@ -31,6 +31,8 @@ on:
- 'scripts/check-failsafe-reports.js'
- 'scripts/check-flyway-migrations.js'
- 'scripts/flyway-migration-utils.js'
- 'scripts/check-contract-version.js'
- 'scripts/contract-version-utils.js'
- '.github/workflows/sdk-java.yml'
- '.github/workflows/release-sdk-java.yml'
push:
@@ -62,6 +64,8 @@ on:
- 'scripts/check-failsafe-reports.js'
- 'scripts/check-flyway-migrations.js'
- 'scripts/flyway-migration-utils.js'
- 'scripts/check-contract-version.js'
- 'scripts/contract-version-utils.js'
- '.github/workflows/sdk-java.yml'
- '.github/workflows/release-sdk-java.yml'
@@ -438,6 +442,30 @@ jobs:
- name: 'Check Flyway migration versions are unique'
run: 'node scripts/check-flyway-migrations.js packages/sdk-java/managed-agent-server packages/sdk-java/runtime-broker packages/sdk-java/qwencode'
# Contract version monotonicity, #13804: every pull request that
# changes the OpenAPI document must move info.version strictly past
# the base ref's — consumers key capability detection on it, and a
# regressed or stalled number breaks nothing at runtime to be caught
# by. The default checkout is one commit deep and holds the
# prospective merge result, so the base branch's head is fetched at
# depth one for the comparison copy. Pull requests only: a push has
# no base to compare, and the races that remain after this ran are
# the re-check workflow's. Comparing contents, not a changed-paths
# list, is what lets a merge result that already advanced (another
# pull request landed) read as regressed-or-stalled instead of
# passing on a stale branch point.
- name: 'Check the API contract version advances'
if: "${{ github.event_name == 'pull_request' }}"
env:
BASE_REF: '${{ github.event.pull_request.base.ref }}'
run: |-
set -uo pipefail
DOCUMENT='packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json'
BASE_DOCUMENT="${RUNNER_TEMP}/contract-base.json"
git fetch --quiet --depth=1 --filter=blob:none origin "refs/heads/${BASE_REF}"
git show "FETCH_HEAD:${DOCUMENT}" > "${BASE_DOCUMENT}" 2>/dev/null || : > "${BASE_DOCUMENT}"
node scripts/check-contract-version.js "${BASE_DOCUMENT}" "${DOCUMENT}"
mysql-integration:
name: 'Runtime Broker and Managed Agent MariaDB / Java 21'
runs-on: 'ubuntu-latest'
@@ -63,6 +63,23 @@ and 4.5.
D1 introduced `1.13.0` for the lifecycle routes (4.3). Integrating W0d adds
the workspace lookup route and advances the version to `1.14.0` (4.5).
The version is allocated by hand and consumers key capability detection on
it, so monotonicity is enforced in CI (#13804). On every pull request the
SDK Java workflow's fast guard lane compares the merge result's document
with the base branch's head copy and fails when a changed document declares
an equal or lower `info.version`, numeric per segment, naming whether the
number stalled or regressed; a pull request arriving at the base ref's own
bytes owes no bump. After every push to `main` that changes the document,
and every 30 minutes, a re-check reads each open pull request's changed
paths and document through the API — no pull request code is fetched,
checked out or run — and sets the
`API contract version uniqueness (latest main)` status: failure when the
declared version no longer advances past `main`'s, success otherwise,
naming the other open pull requests claiming the same version, and error
when a file list or head document could not be read in full. A head that
already carries the status is re-evaluated even after it stops changing the
document, so a stale failure is cleared.
### 4.2 Generate TypeScript, validate Java
- **TypeScript is generated.** `openapi-typescript` generates the WebShell
@@ -57,6 +57,17 @@ spec 迁入
D1 为生命周期路由(4.3)引入了 `1.13.0`。集成 W0d 新增了 Workspace 查询路由,
版本推进到 `1.14.0`(4.5)。
版本号由手工分配,而消费者按它判断能力,因此单调性由 CI 强制(#13804)。每个
pull request 上,SDK Java workflow 的快速守卫 lane 把合并结果中的文档与 base 分支
head 上的副本比较:文档发生变化而 `info.version` 相等或更低时失败,按段做数字
比较,并指名版本是停滞还是回退;与 base ref 逐字节相同的 pull request 不欠一次
递增。每次改动该文档的 `main` push,以及每 30 分钟的定时任务,都会通过 API 读取
每个开放 pull request 的 changed paths 与其文档——不 fetch、不 checkout、不运行
pull request 代码——并设置 `API contract version uniqueness (latest main)` 状态:
声明的版本不再前进到 `main` 之上时失败,否则成功,并列出声明同一版本的其他开放
pull request;文件列表或 head 文档无法完整读取时改为错误。已带有该状态的 head
即使不再改动该文档也会被重新评估,因此过期的失败状态会被清除。
### 4.2 生成 TypeScript,校验 Java
- **TypeScript 由生成得到。** WebShell 类型由 `openapi-typescript` 生成。客户端
+398
View File
@@ -0,0 +1,398 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// check-contract-version.js checks a pull request's contract document
// against the base ref of the moment it ran, and nothing re-runs it when
// main later moves: a PR that declared the next version stays green after
// another PR takes that version, and whichever of them merges second
// publishes the number twice — 2026-10-09 saw four contract-changing PRs
// in flight at once with 1.34.0 claimed twice (#13804, mirroring #13742).
// This script runs against a checkout of main after every push that
// changes the contract document, and on a schedule. It reads each open
// PR's changed paths from the GraphQL API and each claiming PR's document
// through the contents API — one file, answered as text; no PR code is
// checked out or run — and sets a commit status on every PR head that
// declares a version: failure when the version does not advance past
// main's current one, success otherwise, naming the other open PRs that
// claim the same version, so the second of them turns red as soon as the
// first one lands. A head document that reads back byte-identical to
// main's declares nothing at all, even when the changed-paths list names
// the document. A PR whose file list or head document could not be read in
// full gets error unless the parts read already answer. A head that
// already carries the status is re-evaluated even when it no longer
// changes the document, so a stale failure is cleared; a head whose status
// already says the same is left alone, so a schedule does not pile
// statuses onto it.
import { appendFileSync, readFileSync } from 'node:fs';
import { execFile, execFileSync } from 'node:child_process';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import {
CONTRACT_DOCUMENT,
compareVersions,
documentVersion,
} from './contract-version-utils.js';
export const STATUS_CONTEXT = 'API contract version uniqueness (latest main)';
// GraphQL aborts a query after 10 s; 50 PRs with their file lists took ~5 s.
const PRS_QUERY = `query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, baseRefName: "main", first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
number
headRefOid
changedFiles
commits(last: 1) {
nodes {
commit {
oid
status {
context(name: "${STATUS_CONTEXT}") { state description }
}
}
}
}
files(first: 100) {
pageInfo { hasNextPage endCursor }
nodes { path changeType }
}
}
}
}
}`;
const FILES_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
files(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { path changeType }
}
}
}
}`;
// File pages and head-document reads share one budget, about four minutes
// of calls: a flood of huge PRs is reported as not checked instead of
// pushing every run past its timeout.
const API_PAGE_BUDGET = 100;
// The runner parses `::` and `##[` commands from the job log, and the
// error messages neutralized here come from the API.
const neutralize = (text) =>
text.replace(/\s+/g, ' ').replace(/::/g, ';;').replace(/##\[/g, '##[');
// main's document text and declared version from the checkout. Comparing
// head documents byte-for-byte is what lets a PR whose edit lands nothing
// declare no claim at all. A main whose document did not read and parse
// must not compare anything: an empty stand-in would pass every claim, so
// the run refuses instead.
export function mainContract(root) {
let text;
try {
text = readFileSync(path.join(root, CONTRACT_DOCUMENT), 'utf8').trim();
} catch {
throw new Error(
`${CONTRACT_DOCUMENT}: the checkout of main has no contract document`,
);
}
const { version, error } = documentVersion(text);
if (!version) {
throw new Error(`${CONTRACT_DOCUMENT} on main ${error}`);
}
return { text, version };
}
async function openPullRequests(repo, gh, budget) {
const [owner, name] = repo.split('/');
const query = async (text, variables) => {
const args = ['api', 'graphql', '-f', `query=${text}`];
for (const [key, value] of Object.entries({ owner, name, ...variables })) {
if (value == null) continue;
args.push(typeof value === 'number' ? '-F' : '-f', `${key}=${value}`);
}
return JSON.parse(await gh(args)).data.repository;
};
const prs = [];
let exhausted = false;
let cursor = null;
do {
const page = (await query(PRS_QUERY, { cursor })).pullRequests;
for (const pr of page.nodes) {
const files = [...pr.files.nodes];
let next = pr.files.pageInfo;
while (next.hasNextPage && budget.left > 0) {
budget.left -= 1;
const more = (
await query(FILES_QUERY, {
number: pr.number,
cursor: next.endCursor,
})
).pullRequest.files;
files.push(...more.nodes);
next = more.pageInfo;
}
if (next.hasNextPage) exhausted = true;
// The last commit has always been the head. If it ever is not, the
// head reads as carrying no status and is written on every run.
const head = pr.commits.nodes[0]?.commit;
const current = head?.oid === pr.headRefOid && head.status?.context;
prs.push({
number: pr.number,
sha: pr.headRefOid,
// Past the page budget, or the API's 3000-file cap.
incomplete: files.length < pr.changedFiles,
current: current && {
state: current.state.toLowerCase(),
description: current.description,
},
files,
});
}
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
return { prs, exhausted };
}
// The contents API answers the document at any commit in the repository
// network, including a fork's head — one file, answered as text; no PR
// code is checked out or run. Anything the API cannot answer (a deletion
// racing the file list, a symlike entry, a truncated read) is null, and
// the claim is reported as unread instead of guessed.
async function headDocument(repo, sha, gh) {
try {
const body = JSON.parse(
await gh([
'api',
`repos/${repo}/contents/${CONTRACT_DOCUMENT}?ref=${sha}`,
]),
);
if (typeof body?.content !== 'string') return null;
return Buffer.from(body.content, 'base64').toString('utf8').trim();
} catch {
return null;
}
}
// Each PR's claim on the contract version: absent when it does not change
// the document or its copy reads back identical to main's; 'deleted',
// 'unread', the parse refusal, or the declared version otherwise. File
// pages and document reads draw the same remaining budget.
async function claims(repo, prs, main, budget, gh) {
let exhausted = false;
const byPr = new Map();
for (const pr of prs) {
const touched = pr.files.find((file) => file.path === CONTRACT_DOCUMENT);
if (!touched) continue;
if (touched.changeType === 'DELETED') {
byPr.set(pr.number, 'deleted');
continue;
}
if (budget.left <= 0) {
exhausted = true;
byPr.set(pr.number, 'unread');
continue;
}
budget.left -= 1;
const text = await headDocument(repo, pr.sha, gh);
if (text === null) {
byPr.set(pr.number, 'unread');
} else if (text !== main.text) {
const { version, error } = documentVersion(text);
byPr.set(pr.number, version ? { version } : { error });
}
// Identical bytes to main's copy: no claim.
}
return { byPr, exhausted };
}
// One verdict per PR that changes the document, whose file list is cut
// short, or whose head already carries this status — which may need
// clearing.
function evaluate(prs, claimsByPr, mainVersion) {
const claimedBy = new Map();
for (const pr of prs) {
const claim = claimsByPr.get(pr.number);
if (!claim?.version) continue;
claimedBy.set(claim.version, [
...(claimedBy.get(claim.version) ?? []),
pr.number,
]);
}
return prs
.filter((pr) => pr.incomplete || pr.current || claimsByPr.has(pr.number))
.map((pr) => {
const claim = claimsByPr.get(pr.number);
let state;
let text;
if (claim?.version) {
const order = compareVersions(claim.version, mainVersion);
if (order > 0) {
const others = (claimedBy.get(claim.version) ?? []).filter(
(number) => number !== pr.number,
);
state = 'success';
text = `${claim.version} is unique against main`;
if (others.length > 0) {
text += `; also claimed by open PRs: ${others
.map((number) => `#${number}`)
.join(' ')}`;
}
} else {
state = 'failure';
text =
order === 0
? `${claim.version} does not advance past main's ${mainVersion}`
: `${claim.version} regresses below main's ${mainVersion}`;
}
} else if (claim?.error) {
state = 'failure';
text = `Head document ${claim.error}`;
} else if (claim === 'deleted') {
state = 'success';
text = 'Removes the contract document, so no version claim';
} else if (claim === 'unread') {
state = 'error';
text = 'Could not read the head document, so not checked';
} else if (pr.incomplete) {
state = 'error';
text = 'Could not list every changed file, so not checked against main';
} else {
state = 'success';
text = 'No claim on the contract version';
}
return {
number: pr.number,
sha: pr.sha,
current: pr.current,
// A status description is plain text of at most 140 characters. It
// leaves out main's commit so that an unchanged verdict reads the
// same on every run.
description: text.length > 140 ? `${text.slice(0, 137)}...` : text,
state,
};
});
}
export async function run({
root,
repo,
mainSha,
targetUrl,
dryRun,
gh,
budget = { left: API_PAGE_BUDGET },
}) {
const main = mainContract(root);
const listed = await openPullRequests(repo, gh, budget);
const read = await claims(repo, listed.prs, main, budget, gh);
const verdicts = evaluate(listed.prs, read.byPr, main.version);
const lines = [
`### Contract versions of open PRs against main@${mainSha.slice(0, 7)}`,
'',
];
if (listed.exhausted || read.exhausted) {
lines.push(
`Read ${API_PAGE_BUDGET} API pages, the budget; PRs past it are reported as not checked.`,
'',
);
}
if (verdicts.length === 0) {
lines.push(
'No open pull request against main changes the contract document.',
);
}
let failed = 0;
for (const verdict of verdicts) {
const unchanged =
verdict.current?.state === verdict.state &&
verdict.current.description === verdict.description;
lines.push(
`- #${verdict.number} ${verdict.state}: ${verdict.description}${unchanged ? ' (unchanged)' : ''}`,
);
if (dryRun || unchanged) continue;
try {
await gh([
'api',
'--method',
'POST',
`repos/${repo}/statuses/${verdict.sha}`,
'-f',
`state=${verdict.state}`,
'-f',
`context=${STATUS_CONTEXT}`,
'-f',
`description=${verdict.description}`,
...(targetUrl ? ['-f', `target_url=${targetUrl}`] : []),
]);
} catch (error) {
failed += 1;
lines.push(` - could not set the status: ${neutralize(error.message)}`);
}
}
return { lines, failed };
}
const execFileAsync = promisify(execFile);
async function gh(args) {
for (let attempt = 1; ; attempt += 1) {
try {
const { stdout } = await execFileAsync('gh', args, {
maxBuffer: 64 * 1024 * 1024,
});
return stdout;
} catch (error) {
if (attempt === 3) {
throw new Error(error.stderr?.trim() || error.message);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}
}
async function main() {
const args = process.argv.slice(2);
const {
GITHUB_REPOSITORY: repo,
GITHUB_SERVER_URL: server,
GITHUB_RUN_ID: runId,
} = process.env;
if (args.some((arg) => arg !== '--dry-run') || !repo) {
console.error(
'usage: GITHUB_REPOSITORY=<owner/name> node scripts/check-contract-open-prs.js ' +
'[--dry-run] (run from a checkout of main)',
);
process.exit(2);
}
const { lines, failed } = await run({
root: '.',
repo,
mainSha: execFileSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim(),
targetUrl: server && runId && `${server}/${repo}/actions/runs/${runId}`,
dryRun: args.includes('--dry-run'),
gh,
});
const report = `${lines.join('\n')}\n`;
process.stdout.write(report);
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
}
process.exitCode = failed > 0 ? 1 : 0;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(neutralize(error.message));
process.exitCode = 1;
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Every pull request that changes the public Managed Agent contract bumps
// its info.version by hand, and until #13804 nothing checked that number:
// on 2026-10-09 two in-flight pull requests declared 1.34.0 while main was
// already at 1.35.0, and the stall had reached main once before — #13163
// changed the document with no version movement at all. A duplicate Flyway
// version refuses to boot within minutes, but a regressed or stalled
// contract version breaks nothing at runtime; it silently misinforms every
// consumer that keys capability detection on it. This is the PR-time check
// of #13804, run by the SDK Java workflow's fast guard lane on every pull
// request: given the base ref's copy of the document and the merge
// result's copy, it fails when a CHANGED document declares a version equal
// to or below the base ref's, naming which of the two modes it is. The
// comparison keys on the documents' contents, not a changed-paths list, so
// a merge result whose copy already advanced (another pull request landed)
// reads as regressed-or-stalled instead of passing on a stale branch
// point; the version comparison itself is numeric per segment, so 1.9.0
// ranks below 1.37.0.
import { existsSync, readFileSync } from 'node:fs';
import { compareVersions, documentVersion } from './contract-version-utils.js';
import { escapeWorkflowCommand } from './release-script-utils.js';
// The same binding check-flyway-migrations.js applies: a `::error::`
// command's data is runner-DECODED when the downloadable log is rendered,
// so nothing outside a path's real alphabet may reach it — every other
// byte becomes %XX first, with the shared helper's escape on top, so no
// decode pass can turn the text back into control bytes. A path reaching
// this script through its command line could otherwise smuggle a forged
// second `::error::` line into the log.
const boundWorkflowPath = (text) =>
escapeWorkflowCommand(
String(text).replace(
/[^A-Za-z0-9._/-]/g,
(char) =>
`%${char.codePointAt(0).toString(16).toUpperCase().padStart(2, '0')}`,
),
);
const [baseFile, headFile, extra] = process.argv.slice(2);
if (!baseFile || !headFile || extra) {
console.error(
'usage: node scripts/check-contract-version.js <base-document> <head-document>',
);
process.exit(2);
}
const fail = (message) => {
console.error(`::error::${message}`);
process.exit(1);
};
const read = (file) =>
existsSync(file) ? readFileSync(file, 'utf8').trim() : '';
const base = read(baseFile);
const head = read(headFile);
// The head first: a renamed document empties both reads, and its message
// names what happened. A missing or empty base copy can only mean the
// fetch or the path itself rotted — answered as an error too, never a
// silent pass.
if (!head) {
fail(
`${boundWorkflowPath(headFile)}: the contract document is missing or ` +
'empty; if it moved, point this check at the new location',
);
}
if (!base) {
fail(
`${boundWorkflowPath(baseFile)}: could not read the base ref's copy ` +
'of the contract document; nothing to compare against',
);
}
// Same bytes, same version: the pull request changed nothing the version
// exists to signal, even when the changed-paths list names the document.
// This arm runs before either copy is parsed so a baseline anomaly never
// blocks a pull request that makes no claim on it.
if (head === base) {
console.log(
`${boundWorkflowPath(headFile)}: identical to the base ref's copy; no version check needed`,
);
process.exit(0);
}
const headRead = documentVersion(head);
if (!headRead.version) {
fail(
`${boundWorkflowPath(headFile)}: the contract document ${headRead.error}`,
);
}
const baseRead = documentVersion(base);
if (!baseRead.version) {
fail(`${boundWorkflowPath(baseFile)}: the base ref's copy ${baseRead.error}`);
}
const order = compareVersions(headRead.version, baseRead.version);
if (order > 0) {
console.log(
`${boundWorkflowPath(headFile)}: info.version ${headRead.version} advances past ` +
`the base ref's ${baseRead.version}`,
);
process.exit(0);
}
if (order === 0) {
fail(
`${boundWorkflowPath(headFile)} declares info.version ` +
`${headRead.version}, which does not advance past the base ref's ` +
`${baseRead.version} while the document changed; bump the version`,
);
}
fail(
`${boundWorkflowPath(headFile)} declares info.version ` +
`${headRead.version}, which regresses below the base ref's ` +
`${baseRead.version} while the document changed; bump info.version ` +
`past ${baseRead.version}`,
);
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The public Managed Agent contract's version rules, shared by
// check-contract-version.js (one pull request against its base ref) and
// check-contract-open-prs.js (open pull requests against main). Consumers
// key capability detection on info.version, so a document that changed
// without moving the number, or moved it backwards, silently misinforms
// every reader (#13804): both failure modes were caught by a person on
// 2026-10-09, and the stall had already reached main once (#13163).
export const CONTRACT_DOCUMENT =
'packages/sdk-java/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json';
// The repository's practice is numeric segments joined by dots (1.38.0).
// Anything else cannot be ordered, so it refuses the check wherever it
// appears, on either side of the comparison — and the same refusal is what
// makes a version string safe to echo into a `::error::` command's data,
// whose alphabet this admits.
const VERSION = /^\d+(?:\.\d+)*$/;
export const isVersion = (text) => VERSION.test(text);
// Numeric per segment: 1.9.0 ranks below 1.37.0, a segment missing past
// the shorter length reads as 0 (1.38 equals 1.38.0), and leading zeros
// carry no meaning.
export function compareVersions(left, right) {
const a = left.split('.').map(Number);
const b = right.split('.').map(Number);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const difference = (a[index] ?? 0) - (b[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
// The version a document's text declares, or a refusal describing why it
// cannot make a comparable claim. The refusal never echoes the document's
// own bytes: a JSON.parse error message can carry them, and callers print
// this where a workflow-command parser decodes.
export function documentVersion(text) {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { error: 'is not parseable JSON' };
}
const version = parsed?.info?.version;
if (typeof version !== 'string' || !isVersion(version)) {
return { error: 'declares no numeric-segment info.version' };
}
return { version };
}
@@ -0,0 +1,643 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile, execFileSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
STATUS_CONTEXT,
mainContract,
run,
} from '../check-contract-open-prs.js';
import { CONTRACT_DOCUMENT } from '../contract-version-utils.js';
const script = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'check-contract-open-prs.js',
);
const MAIN_SHA = 'fbde5cf0'.padEnd(40, '0');
const document = (version, note = 'a') =>
JSON.stringify({ openapi: '3.1.0', info: { title: 't', version }, note });
const MAIN_TEXT = document('1.38.0', 'main');
let root;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'check-contract-open-prs-'));
const file = join(root, CONTRACT_DOCUMENT);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, `${MAIN_TEXT}\n`);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
// An open PR as the API lists it: every path ADDED unless given as
// [path, changeType]. `doc` is the document text its head answers through
// the contents API (null means the API cannot answer it), `current` the
// status its last commit carries, `currentOid` that commit when it is not
// the head.
const pr = (number, files, extra = {}) => ({
number,
headRefOid: String(number).padEnd(40, 'a'),
files: files.map((file) =>
Array.isArray(file)
? { path: file[0], changeType: file[1] }
: { path: file, changeType: 'ADDED' },
),
...extra,
});
const touching = (changeType = 'ADDED') => [CONTRACT_DOCUMENT, changeType];
// A fake `gh` serving the PR list two PRs per page and each PR's files two
// per page, so both cursors are exercised, answering each touching PR's
// document from `doc`, and recording every call.
function fakeGh(prs, { failPost = [], graphqlError } = {}) {
const calls = [];
const flags = (args) => {
const out = {};
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '-f' || args[index] === '-F') {
const [key, ...value] = args[index + 1].split('=');
out[key] = { value: value.join('='), typed: args[index] === '-F' };
}
}
return out;
};
const filePage = (files, cursor) => {
const start = Number(cursor ?? 0);
const end = start + 2;
return {
pageInfo: { hasNextPage: end < files.length, endCursor: String(end) },
nodes: files.slice(start, end),
};
};
const gh = async (args) => {
calls.push(args);
const f = flags(args);
if (args[1] === 'graphql') {
if (graphqlError) throw new Error(graphqlError);
expect(f.owner.value).toBe('QwenLM');
expect(f.name.value).toBe('qwen-code');
if (f.query.value.includes('pullRequests(')) {
expect(f.query.value).toContain('states: OPEN, baseRefName: "main"');
// The last commit is the head; the fake serves `current` for it.
expect(f.query.value).toContain('commits(last: 1)');
expect(f.query.value).toContain(`context(name: "${STATUS_CONTEXT}")`);
const start = Number(f.cursor?.value ?? 0);
const end = start + 2;
return JSON.stringify({
data: {
repository: {
pullRequests: {
pageInfo: {
hasNextPage: end < prs.length,
endCursor: String(end),
},
nodes: prs.slice(start, end).map((node) => ({
number: node.number,
headRefOid: node.headRefOid,
changedFiles: node.changedFiles ?? node.files.length,
commits: {
nodes: [
{
commit: {
oid: node.currentOid ?? node.headRefOid,
status: node.current
? { context: node.current }
: null,
},
},
],
},
files: filePage(node.files),
})),
},
},
},
});
}
// GraphQL rejects a string where the query declares Int!.
expect(f.number.typed).toBe(true);
const target = prs.find((node) => node.number === Number(f.number.value));
return JSON.stringify({
data: {
repository: {
pullRequest: { files: filePage(target.files, f.cursor.value) },
},
},
});
}
if (args[1] !== '--method') {
// The contents API: repos/<repo>/contents/<document>?ref=<sha>.
const [endpoint, query] = args[1].split('?');
expect(endpoint).toBe(
`repos/QwenLM/qwen-code/contents/${CONTRACT_DOCUMENT}`,
);
const sha = new URLSearchParams(query).get('ref');
const target = prs.find((node) => node.headRefOid === sha);
if (!target?.doc) throw new Error('HTTP 404: Not Found');
return JSON.stringify({
type: 'file',
encoding: 'base64',
content: Buffer.from(target.doc, 'utf8').toString('base64'),
});
}
const sha = args[3].split('/').pop();
if (failPost.includes(sha)) {
throw new Error('HTTP 422: No commit found\n::error::forged ##[group]x');
}
return '{}';
};
const posts = () =>
calls
.filter((args) => args[1] === '--method')
.map((args) => {
const f = flags(args);
return {
path: args[3],
state: f.state.value,
context: f.context.value,
description: f.description.value,
targetUrl: f.target_url?.value,
};
});
return { gh, calls, posts };
}
async function check(prs, options = {}) {
const fake = fakeGh(prs, options);
const result = await run({
root,
repo: 'QwenLM/qwen-code',
mainSha: MAIN_SHA,
targetUrl: 'https://github.com/QwenLM/qwen-code/actions/runs/1',
dryRun: options.dryRun ?? false,
gh: fake.gh,
});
const byPr = Object.fromEntries(
fake
.posts()
.map((post) => [Number(post.path.split('/').pop().slice(0, 5)), post]),
);
return { ...result, posts: fake.posts(), byPr, calls: fake.calls };
}
describe('check-contract-open-prs', () => {
it('fails a PR that declares the version main already carries', async () => {
// The stall #13163 landed: document changed, info.version unmoved.
const { posts, failed, lines } = await check([
pr(13598, ['README.md', touching()], { doc: document('1.38.0', 'pr') }),
]);
expect(failed).toBe(0);
expect(posts).toEqual([
{
path: `repos/QwenLM/qwen-code/statuses/${'13598'.padEnd(40, 'a')}`,
state: 'failure',
context: STATUS_CONTEXT,
description: "1.38.0 does not advance past main's 1.38.0",
targetUrl: 'https://github.com/QwenLM/qwen-code/actions/runs/1',
},
]);
expect(lines[0]).toBe(
'### Contract versions of open PRs against main@fbde5cf',
);
});
it('fails a PR that declares a version below main', async () => {
const { byPr } = await check([
pr(13545, [touching()], { doc: document('1.34.0', 'pr') }),
]);
expect(byPr[13545]).toMatchObject({
state: 'failure',
description: "1.34.0 regresses below main's 1.38.0",
});
});
it('compares versions numerically: 1.9.0 is below 1.37.0, and a missing segment reads as zero', async () => {
// Witness through the API path for the numeric compare: a lexicographic
// one ranks '1.9.0' above '1.37.0' and lets the regression pass.
const { byPr } = await check([
pr(10001, [touching()], { doc: document('1.9.0', 'pr') }),
pr(10002, [touching()], { doc: document('1.38', 'pr') }),
pr(10003, [touching()], { doc: document('1.039.0', 'pr') }),
]);
expect(byPr[10001].description).toBe("1.9.0 regresses below main's 1.38.0");
expect(byPr[10002].description).toBe(
"1.38 does not advance past main's 1.38.0",
);
expect(byPr[10003].state).toBe('success');
});
it('passes a version ahead of main and names every other open PR claiming it', async () => {
const { byPr } = await check([
pr(10001, [touching()], { doc: document('1.39.0', 'one') }),
pr(10002, [touching()], { doc: document('1.39.0', 'two') }),
pr(10003, [touching()], { doc: document('1.40.0', 'three') }),
]);
expect(byPr[10001]).toMatchObject({
state: 'success',
description:
'1.39.0 is unique against main; also claimed by open PRs: #10002',
});
expect(byPr[10002].description).toContain('#10001');
expect(byPr[10003]).toMatchObject({
state: 'success',
description: '1.40.0 is unique against main',
});
});
it('fails a PR whose head document does not parse or declares no numeric version', async () => {
const { byPr } = await check([
pr(10001, [touching()], { doc: '{"info":' }),
pr(10002, [touching()], { doc: document('1.39-SNAPSHOT') }),
]);
expect(byPr[10001]).toMatchObject({
state: 'failure',
description: 'Head document is not parseable JSON',
});
expect(byPr[10002]).toMatchObject({
state: 'failure',
description: 'Head document declares no numeric-segment info.version',
});
});
it('claims nothing for a PR that deletes the document', async () => {
const { byPr } = await check([pr(10001, [touching('DELETED')])]);
expect(byPr[10001]).toMatchObject({
state: 'success',
description: 'Removes the contract document, so no version claim',
});
});
it("claims nothing when a PR's document is byte-identical to main's", async () => {
// The changed-paths list names the document, but the head's copy read
// back through the API is main's own: there is no version claim to
// check, and the PR is not pestered when it carries no status.
const { posts } = await check([
pr(10001, [touching('MODIFIED')], { doc: MAIN_TEXT }),
]);
expect(posts).toEqual([]);
});
it('clears the status when the PR no longer makes a claim', async () => {
// Its claim landed on main, or a force-push took the document out of
// the diff: the stale failure must not stay.
const { byPr } = await check([
pr(10001, [touching('MODIFIED')], {
doc: MAIN_TEXT,
current: { state: 'FAILURE', description: '1.38.0 stalled' },
}),
pr(10002, ['README.md'], {
current: { state: 'FAILURE', description: '1.38.0 stalled' },
}),
]);
expect(byPr[10001]).toMatchObject({
state: 'success',
description: 'No claim on the contract version',
});
expect(byPr[10002].state).toBe('success');
});
it('leaves a head alone when its status already says the same', async () => {
const description = '1.39.0 is unique against main';
const { posts, lines } = await check([
pr(10001, [touching()], {
doc: document('1.39.0', 'pr'),
current: { state: 'SUCCESS', description },
}),
// A status on a commit that is no longer the head says nothing.
pr(10002, [touching()], {
doc: document('1.40.0', 'pr'),
current: { state: 'SUCCESS', description },
currentOid: 'c'.repeat(40),
}),
]);
expect(lines).toContain(`- #10001 success: ${description} (unchanged)`);
expect(posts.map((post) => post.path.slice(-40, -35))).toEqual(['10002']);
});
it('re-evaluates a head when another PR now claims its version', async () => {
const { byPr, posts } = await check([
pr(10001, [touching()], {
doc: document('1.39.0', 'one'),
current: {
state: 'SUCCESS',
description: '1.39.0 is unique against main',
},
}),
pr(10002, [touching()], { doc: document('1.39.0', 'two') }),
]);
expect(posts).toHaveLength(2);
expect(byPr[10001].description).toContain('#10002');
expect(byPr[10002].description).toContain('#10001');
});
it('reports a PR whose file list was cut short, unless the files read already answer', async () => {
const { byPr } = await check([
pr(10001, ['a.md'], { changedFiles: 3001 }),
pr(10002, [touching()], {
changedFiles: 3001,
doc: document('1.38.0', 'pr'),
}),
]);
expect(byPr[10001]).toMatchObject({
state: 'error',
description:
'Could not list every changed file, so not checked against main',
});
// The stall among the files it did list is already certain.
expect(byPr[10002].state).toBe('failure');
});
it('reports a PR whose head document cannot be read instead of guessing', async () => {
const { byPr } = await check([pr(10001, [touching()], { doc: null })]);
expect(byPr[10001]).toMatchObject({
state: 'error',
description: 'Could not read the head document, so not checked',
});
});
it('stops reading after the run-wide API budget and reports the rest as unread', async () => {
const huge = Array.from({ length: 300 }, (_, index) => `docs/f${index}.md`);
const { byPr, calls, lines } = await check([
pr(10001, [...huge, touching()], { doc: document('1.39.0', 'one') }),
pr(10002, ['a.md', touching()], { doc: document('1.39.0', 'two') }),
]);
// One page of the PR list and the hundred-budget pages of files; the
// second PR's claim never gets a read.
expect(calls.filter((args) => args[1] === 'graphql')).toHaveLength(101);
expect(lines).toContain(
'Read 100 API pages, the budget; PRs past it are reported as not checked.',
);
expect(byPr[10001].state).toBe('error');
expect(byPr[10002].state).toBe('error');
});
it('caps the status description at 140 characters', async () => {
const { byPr } = await check(
Array.from({ length: 15 }, (_, index) =>
pr(20001 + index, [touching()], { doc: document('1.39.0', 'pr') }),
),
);
for (let index = 0; index < 15; index += 1) {
const post = byPr[20001 + index];
expect(post.description).toHaveLength(140);
expect(post.description.endsWith('...')).toBe(true);
expect(post.state).toBe('success');
}
});
it('posts nothing on a dry run but still reports', async () => {
const { posts, lines } = await check(
[pr(13598, [touching()], { doc: document('1.38.0', 'pr') })],
{ dryRun: true },
);
expect(posts).toEqual([]);
expect(lines).toContain(
"- #13598 failure: 1.38.0 does not advance past main's 1.38.0",
);
});
it('keeps posting after one status fails, and counts the failure', async () => {
const first = pr(10001, [touching()], { doc: document('1.38.0', 'one') });
const { posts, failed, lines } = await check(
[first, pr(10002, [touching()], { doc: document('1.39.0', 'two') })],
{ failPost: [first.headRefOid] },
);
expect(failed).toBe(1);
expect(posts.map((post) => post.state)).toEqual(['failure', 'success']);
expect(lines).toContain(
' - could not set the status: HTTP 422: No commit found ;;error;;forged ##[group]x',
);
});
it('posts nothing when the PR list cannot be read', async () => {
const fake = fakeGh([pr(10001, [touching()], { doc: 'x' })], {
graphqlError: 'timeout',
});
await expect(
run({
root,
repo: 'QwenLM/qwen-code',
mainSha: MAIN_SHA,
gh: fake.gh,
}),
).rejects.toThrow('timeout');
expect(fake.posts()).toEqual([]);
});
it('never posts for a PR that neither touches the document nor carries the status', async () => {
const { posts, lines } = await check([pr(10001, ['README.md'])]);
expect(posts).toEqual([]);
expect(lines).toContain(
'No open pull request against main changes the contract document.',
);
});
});
describe('mainContract', () => {
it('reads the version of a checkout of main', () => {
expect(mainContract(root)).toEqual({
text: MAIN_TEXT,
version: '1.38.0',
});
});
it('refuses a checkout whose document is missing or does not parse', () => {
// Comparing against an empty stand-in would pass every claim, so the
// run refuses instead.
const empty = mkdtempSync(join(tmpdir(), 'main-contract-empty-'));
try {
expect(() => mainContract(empty)).toThrow('no contract document');
writeFileSync(
join(root, CONTRACT_DOCUMENT),
'{"info":{"version":"v1.38.0"}}',
);
expect(() => mainContract(root)).toThrow('on main');
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
});
// The CLI against a `gh` stub on PATH, in a git checkout: argument handling,
// the step summary, the exit status and the retry around a failed call.
describe.skipIf(process.platform === 'win32')(
'check-contract-open-prs CLI',
() => {
const HEAD = 'b'.repeat(40);
const GH_STUB = `const { appendFileSync, existsSync, readFileSync, writeFileSync } = require('node:fs');
appendFileSync(process.env.GH_LOG, JSON.stringify(process.argv.slice(2)) + '\\n');
const failures = Number(process.env.GH_FAILURES || 0);
const marker = process.env.GH_LOG + '.failed';
const failed = existsSync(marker) ? Number(readFileSync(marker, 'utf8')) : 0;
if (failed < failures) {
writeFileSync(marker, String(failed + 1));
process.stderr.write('HTTP 502: Bad Gateway\\n::error::forged ##[group]x');
process.exit(1);
}
const args = process.argv.slice(2);
if (args.includes('graphql')) {
process.stdout.write(JSON.stringify({ data: { repository: { pullRequests: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{ number: 13598, headRefOid: '${HEAD}', changedFiles: 1,
commits: { nodes: [{ commit: { oid: '${HEAD}', status: null } }] },
files: { pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{ path: '${CONTRACT_DOCUMENT}', changeType: 'MODIFIED' }] } }],
} } } }));
} else if (args[1] === '--method') {
if (process.env.GH_POST_FAILS) { process.stderr.write('HTTP 422: No commit found'); process.exit(1); }
process.stdout.write('{}');
} else {
process.stdout.write(JSON.stringify({ type: 'file', encoding: 'base64',
content: Buffer.from('{"info":{"version":"1.38.0","title":"t"},"openapi":"3.1.0"}').toString('base64') }));
}
`;
let head;
beforeEach(() => {
const git = (...args) =>
execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
git('init', '-q');
git(
'-c',
'user.name=t',
'-c',
'user.email=t@t',
'commit',
'-q',
'--allow-empty',
'-m',
'main',
);
head = git('rev-parse', 'HEAD');
});
const cli = (argv, env = {}) => {
const bin = join(root, 'bin');
mkdirSync(bin, { recursive: true });
// A .cjs body: an extensionless script under a "type": "module"
// ancestor would load as ESM.
writeFileSync(join(bin, 'gh.cjs'), GH_STUB);
writeFileSync(
join(bin, 'gh'),
`#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/gh.cjs" "$@"\n`,
);
chmodSync(join(bin, 'gh'), 0o755);
const log = join(root, 'gh.log');
const summary = join(root, 'summary.md');
return promisify(execFile)(process.execPath, [script, ...argv], {
cwd: root,
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
GH_LOG: log,
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
GITHUB_SERVER_URL: 'https://github.com',
GITHUB_RUN_ID: '7',
GITHUB_STEP_SUMMARY: summary,
...env,
},
})
.then(
({ stdout }) => ({ code: 0, stdout }),
(error) => ({
code: error.code,
stdout: error.stdout,
stderr: error.stderr,
}),
)
.then((result) => ({
...result,
calls: readFileSync(log, { encoding: 'utf8', flag: 'a+' })
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
summary: readFileSync(summary, { encoding: 'utf8', flag: 'a+' }),
}));
};
it('posts one status per claiming PR and writes the step summary', async () => {
const result = await cli([]);
expect(result.code).toBe(0);
expect(result.summary).toBe(
[
`### Contract versions of open PRs against main@${head.slice(0, 7)}`,
'',
"- #13598 failure: 1.38.0 does not advance past main's 1.38.0",
'',
].join('\n'),
);
const contentRead = result.calls.find(
(args) =>
args[0] === 'api' &&
args[1] ===
`repos/QwenLM/qwen-code/contents/${CONTRACT_DOCUMENT}?ref=${HEAD}`,
);
expect(contentRead).toBeDefined();
const post = result.calls.find(
(args) => args[0] === 'api' && args[2] === 'POST',
);
expect(post).toEqual([
'api',
'--method',
'POST',
`repos/QwenLM/qwen-code/statuses/${HEAD}`,
'-f',
'state=failure',
'-f',
`context=${STATUS_CONTEXT}`,
'-f',
"description=1.38.0 does not advance past main's 1.38.0",
'-f',
'target_url=https://github.com/QwenLM/qwen-code/actions/runs/7',
]);
});
it('retries a failed call, and posts nothing on a dry run', async () => {
const result = await cli(['--dry-run'], { GH_FAILURES: '1' });
expect(result.code).toBe(0);
expect(
result.calls.filter((args) => args.includes('graphql')),
).toHaveLength(2);
expect(result.calls.every((args) => args[1] !== '--method')).toBe(true);
expect(result.stdout).toContain('- #13598 failure:');
});
it('exits non-zero after three failed attempts', async () => {
const result = await cli([], { GH_FAILURES: '3' });
expect(result.code).toBe(1);
expect(result.stderr).toContain(
'HTTP 502: Bad Gateway ;;error;;forged ##[group]x',
);
expect(result.calls).toHaveLength(3);
});
it('exits non-zero when a status cannot be set', async () => {
const result = await cli([], { GH_POST_FAILS: '1' });
expect(result.code).toBe(1);
expect(result.summary).toContain(
' - could not set the status: HTTP 422: No commit found',
);
});
it('prints usage with arguments or without the repository', async () => {
expect((await cli(['packages/sdk-java'])).code).toBe(2);
expect((await cli([], { GITHUB_REPOSITORY: '' })).code).toBe(2);
});
},
);
@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const script = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'check-contract-version.js',
);
let root;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'check-contract-version-'));
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
// A contract document carrying `version`; `note` varies the bytes without
// moving the number, so "the document changed" and "the version moved" are
// independent fixture knobs — the two failure modes of #13804 differ
// exactly in which one moved.
function document(version, note = 'a') {
const body = JSON.stringify(
{ openapi: '3.1.0', info: { title: 't', version }, note },
null,
2,
);
const file = join(root, `document-${version}-${note}-${body.length}.json`);
writeFileSync(file, `${body}\n`);
return file;
}
function raw(name, text) {
const file = join(root, `${name}.json`);
writeFileSync(file, text);
return file;
}
function check(...args) {
const result = spawnSync(process.execPath, [script, ...args], {
encoding: 'utf8',
});
return { status: result.status, output: result.stdout + result.stderr };
}
// The newline forgery below needs a path only the POSIX lanes allow; gate
// on the capability, as check-flyway-migrations.test.js does for names.
const newlinePathsWork = (() => {
const probe = mkdtempSync(join(tmpdir(), 'contract-path-probe-'));
try {
writeFileSync(join(probe, 'a\nb'), '{}');
return true;
} catch {
return false;
} finally {
rmSync(probe, { recursive: true, force: true });
}
})();
describe('check-contract-version', () => {
it('passes a document identical to the base ref without comparing versions', () => {
// The changed-paths list may name the document while the merge
// result's bytes are the base ref's own; no version movement is owed.
const file = document('1.38.0');
const result = check(file, file);
expect(result.output).toContain('identical to the base ref');
expect(result.status).toBe(0);
});
it('fails a changed document that stayed at the base version', () => {
// #13163's shape: the document moved (+4 −4) and info.version did not.
const result = check(document('1.33.0'), document('1.33.0', 'b'));
expect(result.output).toContain(
'declares info.version 1.33.0, which does not advance past ' +
"the base ref's 1.33.0",
);
expect(result.status).toBe(1);
});
it('fails a changed document whose version is below the base ref', () => {
// The merge result's copy is what gets published: main at 1.35.0 with
// a pull request declaring 1.34.0 is a regression, not a stall.
const result = check(document('1.35.0'), document('1.34.0', 'b'));
expect(result.output).toContain(
'declares info.version 1.34.0, which regresses below ' +
"the base ref's 1.35.0",
);
expect(result.status).toBe(1);
});
it('passes a changed document whose version strictly advances', () => {
const result = check(document('1.38.0'), document('1.39.0'));
expect(result.output).toContain(
"info.version 1.39.0 advances past the base ref's 1.38.0",
);
expect(result.status).toBe(0);
});
it('compares versions numerically per segment', () => {
// Witness for the numeric compare: a lexicographic one ranks '1.9.0'
// above '1.37.0' and passes the regression below.
const regressed = check(document('1.37.0'), document('1.9.0', 'b'));
expect(regressed.output).toContain('regresses below');
expect(regressed.status).toBe(1);
const advanced = check(document('1.9.0'), document('1.37.0'));
expect(advanced.status).toBe(0);
});
it('reads a missing trailing segment as zero', () => {
// 1.38 declares the same version as 1.38.0, and 1.38.1 advances it.
const stalled = check(document('1.38.0'), document('1.38', 'b'));
expect(stalled.output).toContain('does not advance past');
expect(stalled.status).toBe(1);
expect(check(document('1.38'), document('1.38.1')).status).toBe(0);
});
it('ignores leading zeros when comparing', () => {
const result = check(document('1.38.0'), document('1.038.0', 'b'));
expect(result.output).toContain('does not advance past');
expect(result.status).toBe(1);
});
it('fails a head document that does not parse', () => {
// The guard must never pass vacuously: a broken document at the head
// is the pull request's own failure, named on the head's path.
const result = check(document('1.38.0'), raw('broken', '{"info":'));
expect(result.output).toContain('is not parseable JSON');
expect(result.output).toContain('broken.json');
expect(result.status).toBe(1);
});
it('fails a head document without a numeric-segment version', () => {
const result = check(document('1.38.0'), document('1.39-SNAPSHOT'));
expect(result.output).toContain('declares no numeric-segment info.version');
expect(result.status).toBe(1);
});
it('fails when the base ref copy does not parse', () => {
// Nothing comparable means nothing may pass — never a silent pass on a
// rotted fetch or a broken baseline.
const result = check(raw('base-broken', 'not json'), document('1.39.0'));
expect(result.output).toContain("base ref's copy is not parseable JSON");
expect(result.status).toBe(1);
});
it('fails when the head document is missing or empty', () => {
const missing = check(document('1.38.0'), join(root, 'gone.json'));
expect(missing.output).toContain('gone.json');
expect(missing.output).toContain('missing or empty');
expect(missing.status).toBe(1);
const empty = check(document('1.38.0'), raw('empty', ' \n'));
expect(empty.output).toContain('missing or empty');
expect(empty.status).toBe(1);
});
it('fails when the base document is missing or empty', () => {
const missing = check(join(root, 'no-base.json'), document('1.39.0'));
expect(missing.output).toContain('nothing to compare against');
expect(missing.status).toBe(1);
});
it('refuses to run without both documents', () => {
expect(check().status).toBe(2);
const one = check(document('1.38.0'));
expect(one.output).toContain('usage:');
expect(one.status).toBe(2);
// Three arguments can only be operator error, never a third document.
expect(check(document('1.38.0'), document('1.39.0'), 'x').status).toBe(2);
});
it.skipIf(!newlinePathsWork)(
'escapes a forged ::error:: line smuggled through the head path',
() => {
// The runner decodes a command's data where the log is rendered, so
// the byte alphabet reaching it is bound the way
// check-flyway-migrations.js binds contributor-controlled names.
const forged = join(root, 'a\n::error::forged.json');
writeFileSync(forged, '{"info":{"version":"1.38.0"}}');
const result = check(document('1.38.0', 'b'), forged);
expect(result.status).toBe(1);
expect(
result.output
.split('\n')
.every((line) => !line.startsWith('::error::forged')),
).toBe(true);
},
);
});
+114
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path';
import { parse } from 'yaml';
import { describe, expect, it } from 'vitest';
import { LOCATIONS } from '../flyway-migration-utils.js';
import { CONTRACT_DOCUMENT } from '../contract-version-utils.js';
const workflow = readFileSync('.github/workflows/sdk-java.yml', 'utf8');
const job = (name) => {
@@ -156,10 +157,47 @@ describe('SDK Java Flyway migration version guard', () => {
expect(yml.on[event].paths).toContain(
'scripts/flyway-migration-utils.js',
);
// The contract version guard of #13804 rides the same lane.
expect(yml.on[event].paths).toContain(
'scripts/check-contract-version.js',
);
expect(yml.on[event].paths).toContain(
'scripts/contract-version-utils.js',
);
}
});
});
// #13804: the OpenAPI document's info.version must strictly advance past
// the base ref's whenever the document changed. Consumers key capability
// detection on it, and a regressed or stalled number breaks nothing at
// runtime to be caught by — both modes were seen in flight on 2026-10-09.
// The check rides the same lane as the Flyway guard: a second job would
// spend another pool checkout on a five-second scan.
describe('SDK Java contract version guard', () => {
it('runs the advance check on every pull request in the Flyway lane', () => {
const parsed = parse(workflow).jobs['flyway-migrations'];
const names = parsed.steps.map((s) => s.name);
const index = names.indexOf('Check the API contract version advances');
expect(index).toBeGreaterThan(names.indexOf('Checkout'));
const step = parsed.steps[index];
// A push has no base ref to compare against; the races that remain
// after a pull request's check are the re-check workflow's below.
expect(step.if).toBe("${{ github.event_name == 'pull_request' }}");
expect(step.env).toEqual({
BASE_REF: '${{ github.event.pull_request.base.ref }}',
});
// The default checkout is one commit deep, so the comparison copy comes
// from a depth-one fetch of the base branch's head — not of a stale
// merge base.
expect(step.run).toContain(
'git fetch --quiet --depth=1 --filter=blob:none origin "refs/heads/${BASE_REF}"',
);
expect(step.run).toContain(CONTRACT_DOCUMENT);
expect(step.run).toContain('node scripts/check-contract-version.js');
});
});
// #13742: the guard above sees each PR merged with the main of the moment it
// ran. This workflow re-checks every open PR whenever main gains a
// migration, from the API's file lists, and marks each PR head with a status.
@@ -244,6 +282,78 @@ describe('SDK Java Flyway re-check of open PRs', () => {
});
});
// #13804: the advance check above sees each pull request against the base
// branch of the moment it ran. This workflow re-checks every open PR when
// main gains a contract change, from the API's file lists and the claiming
// PRs' documents — no PR code is fetched or run — and marks each PR head
// with the 'API contract version uniqueness (latest main)' status, the
// #13770 shape for the version number instead of the migrations.
describe('SDK Java contract re-check of open PRs', () => {
const recheck = parse(
readFileSync('.github/workflows/sdk-java-contract-open-prs.yml', 'utf8'),
);
const job = recheck.jobs.recheck;
it('runs after every push to main that changes the document, and on a schedule', () => {
expect(Object.keys(recheck.on).sort()).toEqual([
'push',
'schedule',
'workflow_dispatch',
]);
expect(recheck.on.schedule).toEqual([{ cron: '13,43 * * * *' }]);
expect(recheck.on.push.branches).toEqual(['main']);
expect(recheck.on.push.paths).toEqual([
CONTRACT_DOCUMENT,
'scripts/check-contract-open-prs.js',
'scripts/contract-version-utils.js',
'.github/workflows/sdk-java-contract-open-prs.yml',
]);
// A dispatch from another branch would run that branch's workflow.
expect(job.if).toBe(
"${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}",
);
});
it('holds only the token scopes the status write needs', () => {
expect(recheck.permissions).toEqual({ contents: 'read' });
expect(job.permissions).toEqual({
contents: 'read',
'pull-requests': 'read',
statuses: 'write',
});
const checkout = job.steps.find((s) =>
String(s.uses ?? '').startsWith('actions/checkout'),
);
// main, not the triggering commit: a re-run must not write statuses
// computed against an older main over a newer run's.
expect(checkout.with).toEqual({
ref: 'main',
'persist-credentials': false,
});
});
it('lets a newer main queue behind a running re-check instead of cancelling it', () => {
// Keyed by ref: a skipped dispatch from another branch must not replace
// a pending main run.
expect(recheck.concurrency).toEqual({
group: 'sdk-java-contract-open-prs-${{ github.ref }}',
'cancel-in-progress': false,
});
});
it('routes to the ECS pool behind the kill-switch', () => {
expect(job['runs-on']).toBe(
'${{ (github.repository == \'QwenLM/qwen-code\' && vars.MAINTAINER_ECS_RUNNER_DISABLED != \'true\') && fromJSON(\'["self-hosted", "linux", "x64", "ecs-qwen"]\') || fromJSON(\'["ubuntu-latest"]\') }}',
);
});
it('re-checks from the one script, reading main from the checkout', () => {
const run = job.steps.find((s) => s.name === 'Re-check open pull requests');
expect(run.run).toBe('node scripts/check-contract-open-prs.js');
expect(run.env).toEqual({ GH_TOKEN: '${{ github.token }}' });
});
});
// #13506: the pool-routed legs inherited only the bare ownership restore
// while ci.yml grew the rest of its pre-checkout hygiene across
// recorded incidents — the safe.directory trust after #12648 and the
@@ -259,6 +369,9 @@ describe('SDK Java pre-checkout hygiene on the ECS pool', () => {
'sdk-java-flyway-open-prs.yml': parse(
readFileSync('.github/workflows/sdk-java-flyway-open-prs.yml', 'utf8'),
),
'sdk-java-contract-open-prs.yml': parse(
readFileSync('.github/workflows/sdk-java-contract-open-prs.yml', 'utf8'),
),
};
const ci = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
const ciSteps = Object.values(ci.jobs).flatMap((job) => job.steps ?? []);
@@ -273,6 +386,7 @@ describe('SDK Java pre-checkout hygiene on the ECS pool', () => {
['sdk-java.yml', 'flyway-migrations'],
['sdk-java.yml', 'daemon-e2e'],
['sdk-java-flyway-open-prs.yml', 'recheck'],
['sdk-java-contract-open-prs.yml', 'recheck'],
];
it.each(poolJobs)(