mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
c3a6e43554
commit
ba8615f4c4
11 files changed
+1760
-1
No files matched your search
@@ -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'
|
||||
@@ -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` 生成。客户端
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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)(
|
||||
|
||||
Reference in new issue
Block a user