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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 52 additions & 18 deletions .github/workflows/update-ecs-runner-qwen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,74 @@ permissions:
contents: 'read'

jobs:
update:
name: 'Update Qwen on ${{ matrix.runner }}'
# Resolving on a hosted runner, once, keeps the registry wait below off the
# ECS pools (which queue behind real review/triage work) and guarantees every
# pool installs the SAME version even when their jobs start hours apart.
resolve:
name: 'Resolve version'
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
strategy:
matrix:
runner: ['ecs-update-sg', 'ecs-update-64c', 'ecs-update-hk-1', 'ecs-update-hk-2']
fail-fast: false
runs-on: ['self-hosted', 'linux', 'x64', '${{ matrix.runner }}']
concurrency:
group: 'update-ecs-runner-qwen-${{ matrix.runner }}'
cancel-in-progress: false
timeout-minutes: 10
runs-on: 'ubuntu-latest'
timeout-minutes: 30
Comment on lines +27 to +28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-5: A single npm view can itself run ~16 minutes under npm's built-in fetch defaults (fetch-retries=2, fetch-timeout=300000ms, 10s/60s backoff — verified via npm config ls -l; the fresh ubuntu-latest resolve job has no overrides), so two slow attempts overshoot the 30-minute job timeout. During a registry stall — a different failure class from the fast-404 propagation this loop was written for — attempt 1 burns the full retry budget (~16 min) and ends still under the 25-minute deadline, the loop sleeps and retries, and attempt 2 crosses timeout-minutes: 30: Actions cancels the job mid-npm view before the designed failure path ever runs. The oncall then sees "The operation was canceled" with no replayed registry stderr and no ::error:: annotation, and the four update jobs show only "skipped" — indistinguishable from an infra flake, defeating exactly the diagnostics this PR added.

Witness:

stalling-registry probe at npm built-in defaults (npm 10.9.8):
conn 00:29:08 -> conn 00:34:18 -> conn 00:40:18   (delta 310s, 360s)
npm error network timeout at: http://127.0.0.1:.../@qwen-code%2fqwen-code
npm-start 00:29:08Z / npm-end 00:45:18Z / npm-exit=1   (16m10s for one invocation)
loop arithmetic: attempt 2 starts ~1000s, can run to ~1970s = 32.8min > 1800s job timeout

Bound each attempt so the deadline check retains control (GNU timeout is available on ubuntu-latest):

version="$(timeout 90 npm view "${specifier}" version 2>"${err_log}" | tail -n 1)" || true

The per-attempt bound must keep the worst-case total (deadline + one final attempt) under the job timeout — RESOLVE_TIMEOUT_SECONDS: '1500' sits beneath timeout-minutes: 30 in this file. A runResolve variant whose stub npm sleeps past the per-attempt bound pins the fix: without the bound the synchronous spawnSync hangs and vitest's testTimeout fails the test; with it, the script exits 1 within budget.

中文说明

在 npm 内建的 fetch 默认值下(fetch-retries=2、fetch-timeout=300000ms、10s/60s 退避——已用 npm config ls -l 验证;全新的 ubuntu-latest resolve job 没有任何覆盖),单次 npm view 本身就可能跑约 16 分钟,两次慢速尝试就会越过 30 分钟的 job 超时。当 registry 卡住时(与这个轮询循环要解决的快速 404 传播不同的故障类别),第 1 次尝试耗尽全部重试预算(约 16 分钟)后仍未到 25 分钟的 deadline,循环休眠后重试,第 2 次尝试会越过 timeout-minutes: 30:Actions 会在 npm view 中途取消 job,设计好的失败路径(回放 stderr + ::error:: 注解)永远走不到。值班人员只会看到「The operation was canceled」,没有回放的 registry stderr、没有 ::error:: 注解,四个 update job 只显示「skipped」——与基础设施抖动无法区分,恰好废掉了本 PR 新增的诊断能力。

建议给每次尝试加上界,让 deadline 检查保持控制权(ubuntu-latest 上有 GNU timeout,见上方代码块)。每次尝试的上界必须保证最坏情况总时长(deadline + 最后一次尝试)不超过 job 超时——本文件中 RESOLVE_TIMEOUT_SECONDS: '1500' 位于 timeout-minutes: 30 之下。验收标准:新增一个 runResolve 变体,让其 stub npm 在第一次调用时 sleep 超过每次尝试的上界——没有上界时同步的 spawnSync 会挂起、vitest 的 testTimeout 使测试失败;有了上界,脚本会在预算内以 1 退出。

— qwen3.8-max via Qwen Code /review (v0.22.3)

outputs:
version: '${{ steps.version.outputs.version }}'
steps:
- name: 'Resolve version'
id: 'version'
env:
INPUT_VERSION: '${{ inputs.version || github.event.client_payload.version }}'
# npm publishes asynchronously: `npm publish --provenance` returns
# "Your package is being processed and may take a few minutes to
# become available", and release.yml dispatches this workflow as soon
# as it returns. For v0.22.3 the gap was ~16 minutes (published
# 17:14Z, resolvable 17:30Z), so the single un-retried `npm view` this
# step used to run 404'd on 3 of the 4 pools and left the fleet split
# across two CLI versions until a maintainer noticed. Wait the
# registry out rather than losing the race.
RESOLVE_TIMEOUT_SECONDS: '1500'
RESOLVE_INTERVAL_SECONDS: '30'
run: |-
set -euo pipefail
specifier="@qwen-code/qwen-code@${INPUT_VERSION#v}"
if [[ "${specifier}" == '@qwen-code/qwen-code@' ]]; then
specifier='@qwen-code/qwen-code@latest'
fi
version="$(npm view "${specifier}" version | tail -n 1)" || true
if [[ -z "${version}" ]]; then
echo "::error::No published qwen version matches '${INPUT_VERSION:-latest}'."
exit 1
fi
# Per-attempt stderr is held back so ~50 identical 404 blocks do not
# bury the log; the last one is replayed when the wait gives up.
err_log="$(mktemp)"
deadline=$(( SECONDS + RESOLVE_TIMEOUT_SECONDS ))
while :; do
version="$(npm view "${specifier}" version 2>"${err_log}" | tail -n 1)" || true
if [[ -n "${version}" ]]; then
break
fi
if (( SECONDS >= deadline )); then
cat "${err_log}" >&2
echo "::error::No published qwen version matches '${INPUT_VERSION:-latest}' after ${RESOLVE_TIMEOUT_SECONDS}s."
exit 1
fi
echo "'${specifier}' is not on the registry yet; retrying in ${RESOLVE_INTERVAL_SECONDS}s."
sleep "${RESOLVE_INTERVAL_SECONDS}"
done
echo "version=${version}" >> "${GITHUB_OUTPUT}"
echo "Resolved qwen version: ${version}"

update:
name: 'Update Qwen on ${{ matrix.runner }}'
needs: 'resolve'
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
strategy:
matrix:
runner: ['ecs-update-sg', 'ecs-update-64c', 'ecs-update-hk-1', 'ecs-update-hk-2']
fail-fast: false
runs-on: ['self-hosted', 'linux', 'x64', '${{ matrix.runner }}']
concurrency:
group: 'update-ecs-runner-qwen-${{ matrix.runner }}'
cancel-in-progress: false
timeout-minutes: 10
steps:
- name: 'Update qwen'
env:
VERSION: '${{ steps.version.outputs.version }}'
VERSION: '${{ needs.resolve.outputs.version }}'
run: |-
set -euo pipefail
# The runner service resolves the system-wide qwen binary. Do not
Expand Down Expand Up @@ -80,7 +114,7 @@ jobs:

- name: 'Verify version'
env:
VERSION: '${{ steps.version.outputs.version }}'
VERSION: '${{ needs.resolve.outputs.version }}'
run: |-
set -euo pipefail
qwen_path="$(command -v qwen)"
Expand Down
142 changes: 137 additions & 5 deletions scripts/tests/update-ecs-runner-qwen-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,93 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';

describe('ECS runner qwen update workflow', () => {
const workflow = readFileSync(
'.github/workflows/update-ecs-runner-qwen.yml',
'utf8',
const workflow = readFileSync(
'.github/workflows/update-ecs-runner-qwen.yml',
'utf8',
);

function step(name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = workflow.match(
new RegExp(
`\\n\\s+- name:\\s*(['"])${escaped}\\1[\\s\\S]*?(?=\\n\\s+- name:\\s*['"]|\\n\\s{2}[a-zA-Z0-9_-]+:|$)`,
),
);
return match?.[0] ?? '';
}

// The body of a step's `run: |-` block, dedented to column zero.
function stepBody(name) {
const body = step(name).match(/run: \|-\n([\s\S]*)$/)?.[1] ?? '';
return body.replace(/^ {10}/gm, '');
}
Comment on lines +35 to +38

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-6: The added step()/stepBody() workflow-parsing helpers are byte-identical copies of the ones already in scripts/tests/qwen-repo-hygiene-workflow.test.js and scripts/tests/qwen-resolve-workflow.test.js — a third copy of a fragile YAML-scanning regex that silently returns '' when it stops matching — while scripts/tests/workflow-helpers.js is the established shared module (7 importers). Any workflow-format change (quote style, step indentation) or a fix to a parsing quirk must now be made in three places, and the copies can drift into differently-weakened assertions with no error. The silent-empty failure mode is not hypothetical: verification demonstrated it live — renaming the step makes step() return '' and every behavioural test dies with a bare ENOENT on a random temp path.

Witness:

mechanical extraction + comparison of the three source files:
  all three regex literals identical: true
  update-ecs vs repo-hygiene stepBody identical: true
  importers of scripts/tests/workflow-helpers.js: 7 files

Consume the shared module instead (getWorkflowJob/getWorkflowStep), hoisting the run: |- body-dedent into workflow-helpers.js so the repo-hygiene copy can collapse into it too:

import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
const resolveStep = getWorkflowStep(getWorkflowJob(workflow, 'resolve'), 'Resolve version');

Reuse is constrained by one existing fact: scripts/tests/workflow-helpers.js hard-codes the marker - name: '${stepName}' (6-space indent, single quotes), so the workflow's step naming/indentation must stay exactly that shape — it currently does ( - name: 'Resolve version').

中文说明

新增的 step()/stepBody() workflow 解析助手与 scripts/tests/qwen-repo-hygiene-workflow.test.jsscripts/tests/qwen-resolve-workflow.test.js 中已有的实现逐字节相同——这是同一个脆弱的 YAML 扫描正则的第三份拷贝(它在不再匹配时会静默返回 '')——而 scripts/tests/workflow-helpers.js 才是既有的共享模块(7 处引用)。今后任何 workflow 格式变化(引号风格、步骤缩进)或解析怪癖的修复都必须改三个地方,这些拷贝还可能在无人察觉的情况下各自漂移成不同强度的断言。这种「静默返回空」的失效模式并非假设:验证阶段实际复现了它——把步骤改名后 step() 返回 '',所有行为测试都以随机临时路径上的裸 ENOENT 死去。

建议改为消费共享模块(getWorkflowJob/getWorkflowStep),并把 run: |- 主体去缩进的逻辑上提到 workflow-helpers.js,让 repo-hygiene 里的那份拷贝也能收敛进去(见上方代码块)。复用受一个既有事实约束:scripts/tests/workflow-helpers.js 硬编码了标记 - name: '${stepName}'(6 空格缩进、单引号),因此 workflow 的步骤命名/缩进必须保持这一形状——目前确实如此( - name: 'Resolve version')。

— qwen3.8-max via Qwen Code /review (v0.22.3)


// Runs the 'Resolve version' step body against a stubbed `npm` that 404s for
// its first `failures` invocations and then reports `version`.
function runResolve({ failures = 0, version = '0.22.3', env = {} } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'ecs-update-'));
try {
const counter = join(dir, 'attempts');
const npmStub = join(dir, 'npm');
writeFileSync(
npmStub,
[
'#!/usr/bin/env bash',
`attempt=$(( $(cat ${counter} 2>/dev/null || echo 0) + 1 ))`,
`echo "$attempt" > ${counter}`,
`if (( attempt <= ${failures} )); then`,
' echo "npm error code E404" >&2',
' echo "npm error 404 No match found for version" >&2',
' exit 1',
'fi',
`echo '${version}'`,
].join('\n'),
{ mode: 0o755 },
);
chmodSync(npmStub, 0o755);

const script = join(dir, 'resolve.sh');
writeFileSync(script, stepBody('Resolve version'));
const ghOutput = join(dir, 'github-output');
writeFileSync(ghOutput, '');

const result = spawnSync('bash', [script], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R1-1: [fails-closed] [regression] The new bash-driven behavioural tests are not covered by any win32 exclude entry in scripts/tests/vitest.config.ts, so the merge_group/schedule/dispatch-gated test_windows lane ('Test (windows-latest, Node 22.x)') collects the suite via npm run test:citest:scripts and fails. The exclude list holds pr-self-report-label.test.js, qwen-*-workflow.test.js and serve-ab-workflow.test.js — the glob is anchored at the qwen- prefix, and this file starts with update-, so nothing matches it. On a Windows runner, PATH: \${dir}:${process.env.PATH ?? ''}`joins with:where PATH is;-separated, so the stub npm directory never becomes a discrete PATH entry; the backslash mkdtempSync paths are also interpolated unquoted into the generated bash stub (cat ${counter}), where bash consumes the backslashes as escapes. The extracted script then invokes the real npm(or fails on missing coreutils — ci.yml itself notes "Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH"),readFileSync(counter)throws ENOENT, and all threerunResolve` tests go red. Because the lane is gated, this PR's check page reports it skipped — the first red lands in the merge queue or the nightly, which ci.yml's own comment designates "the repository's only signal about a host that is not Linux" and says to "treat a red nightly as a blocker, not as noise". Before this PR the file was pure-YAML assertions and passed on Windows.

Witness:

win32 collection probe (repo's own matcher, exact exclude list):
  target collected on win32 lane: true
  controls — serve-ab excluded? true | qwen-resolve excluded? true
picomatch: all three patterns => false for the target
Linux baseline: Tests 7 passed (7)

Suggested fix — add the file to the win32 exclude list (the production step under test is genuinely single-platform, runs-on: 'ubuntu-latest'). The alternative that keeps the two pure-YAML assertions running on Windows is a capability probe with it.runIf(...) on the three runResolve tests:

// scripts/tests/vitest.config.ts — win32 exclude list
        'scripts/tests/serve-ab-workflow.test.js',
        'scripts/tests/update-ecs-runner-qwen-workflow.test.js',

The exclusion must stay win32-conditional and an explicit entry, not a widened glob: scripts/tests/vitest.config.ts keeps the list under process.platform === 'win32' and its comment states "pure YAML-parse workflow suites still do" run on Windows, which non-qwen-* YAML-parse suites rely on. The witness for the fix is the test_windows lane itself: with the entry removed, npm run test:scripts on Windows runs the three runResolve tests and they fail at readFileSync(counter) ENOENT / the status assertions; no in-repo test pins vitest's exclude list.

中文说明

新的 bash 驱动行为测试没有被 scripts/tests/vitest.config.ts 的任何 win32 exclude 条目覆盖,因此仅在 merge_group/schedule/dispatch 触发的 test_windows lane('Test (windows-latest, Node 22.x)')会通过 npm run test:citest:scripts 收集到该套件并失败。现有排除列表为 pr-self-report-label.test.jsqwen-*-workflow.test.jsserve-ab-workflow.test.js——glob 锚定在 qwen- 前缀,而本文件以 update- 开头,任何模式都匹配不到。在 Windows runner 上,PATH: \${dir}:${process.env.PATH ?? ''}`:拼接,而 Windows 的 PATH 以;分隔,因此 stubnpm 目录不会成为独立的 PATH 条目;mkdtempSync 产生的反斜杠路径还被不带引号地插值进生成的 bash stub(cat ${counter}),bash 会把反斜杠当转义符吃掉。于是提取出的脚本会调用真实的 npm(或因缺少 coreutils 而失败——ci.yml 自己注明「Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH」),readFileSync(counter)抛 ENOENT,三个runResolve` 测试全部变红。由于该 lane 受触发条件限制,本 PR 的检查页显示它为 skipped——第一次变红将出现在合并队列或 nightly,而 ci.yml 的注释明确说这是「the repository's only signal about a host that is not Linux」,并要求「treat a red nightly as a blocker, not as noise」。本 PR 之前该文件只有纯 YAML 字符串断言,在 Windows 上是通过的。

建议修复:把该文件加入 win32 排除列表(被测的生产步骤本就只在 ubuntu-latest 上运行);若想保留两个纯 YAML 断言在 Windows 上运行,可改用能力探测 + it.runIf(...) 只门控三个 runResolve 测试。修复必须保持在 process.platform === 'win32' 分支内、用显式条目而非放宽 glob——配置注释写明「pure YAML-parse workflow suites still do」,其他非 qwen-* 的纯 YAML 套件依赖这一点。验收标准:移除该排除条目后,test_windows lane 上三个 runResolve 测试会以 ENOENT / 状态断言失败;仓库内没有测试能钉住 vitest 的排除列表,该 lane 本身就是见证。

— qwen3.8-max via Qwen Code /review (v0.22.3)

GITHUB_OUTPUT: ghOutput,
INPUT_VERSION: '0.22.3',
Comment on lines +74 to +75

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-7: The resolve step's env: declarations (INPUT_VERSION, RESOLVE_TIMEOUT_SECONDS: '1500', RESOLVE_INTERVAL_SECONDS: '30') are pinned by no test, because this harness re-injects its own values for all three. Deleting or renaming any of them in the workflow leaves the whole suite green while breaking every real dispatch: under set -u, deadline=$(( SECONDS + RESOLVE_TIMEOUT_SECONDS )) aborts with "unbound variable" on every release/repository dispatch, all four update jobs show "skipped", and no fleet update happens. Nothing pins the '1500' value itself either — lowering it below the ~16-minute npm propagation delay documented in the workflow's own comment silently reintroduces the fleet-split regression this PR exists to fix. Same unpinned-wiring class as the step-id gap, different edge: this one breaks the workflow→script env edge.

Witness:

ARM 2 (RESOLVE_TIMEOUT_SECONDS line deleted, suite as-is): Tests 7 passed (7)
real dispatch of extracted step body:
  line 9: RESOLVE_TIMEOUT_SECONDS: unbound variable, exit=1
ARM 3b (same deletion + suggested fix):
  x resolves once on a hosted runner and feeds every pool
  -> expected ... to contain 'RESOLVE_TIMEOUT_SECONDS: '1500''

Assert the env declarations in the wiring test:

expect(workflow).toContain("INPUT_VERSION: '${{ inputs.version || github.event.client_payload.version }}'");
expect(workflow).toContain("RESOLVE_TIMEOUT_SECONDS: '1500'");
expect(workflow).toContain("RESOLVE_INTERVAL_SECONDS: '30'");

The assertions must match the workflow literals exactly as declared in the resolve step's env block (.github/workflows/update-ecs-runner-qwen.yml:35,44-45). Removing any of the three env: lines from the workflow must turn the wiring test red — proven in verification (ARM 3b / ARM 4).

中文说明

resolve 步骤的 env: 声明(INPUT_VERSIONRESOLVE_TIMEOUT_SECONDS: '1500'RESOLVE_INTERVAL_SECONDS: '30')没有任何测试钉住,因为这个测试助手自己重新注入了这三个值。在 workflow 里删除或改名其中任何一项,整个测试套件仍是绿的,但每一次真实 dispatch 都会挂:在 set -u 下,deadline=$(( SECONDS + RESOLVE_TIMEOUT_SECONDS )) 会以「unbound variable」中止,四个 update job 全部显示「skipped」,fleet 更新彻底不发生,且没有任何 CI 信号。'1500' 这个值本身也没被钉住——把它降到 workflow 注释里记录的约 16 分钟 npm 传播延迟之下,就会静默重新引入本 PR 要修复的 fleet 版本割裂回归。这与 step id 未钉住属于同一类「接线未钉住」问题,只是断的是另一条边:这条断的是 workflow→脚本的 env 边。

建议在接线测试中断言这三个 env 声明(见上方代码块)。断言必须与 resolve 步骤 env 块中声明的 workflow 字面量完全一致(.github/workflows/update-ecs-runner-qwen.yml:35,44-45)。验收标准:从 workflow 中删除三条 env: 行中的任意一条,接线测试必须变红——已在验证中证明(ARM 3b / ARM 4)。

— qwen3.8-max via Qwen Code /review (v0.22.3)

RESOLVE_TIMEOUT_SECONDS: '60',
RESOLVE_INTERVAL_SECONDS: '0',
...env,
},
});
return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
output: readFileSync(ghOutput, 'utf8'),
attempts: Number(readFileSync(counter, 'utf8').trim()),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

describe('ECS runner qwen update workflow', () => {
it('installs without the selected runner npm prefix', () => {
expect(workflow).toContain('cd "${RUNNER_TEMP:?}"');
expect(workflow).toContain('sudo env -u NPM_CONFIG_PREFIX npm install -g');
Expand All @@ -37,4 +115,58 @@ describe('ECS runner qwen update workflow', () => {
expect(workflow).toContain('if [[ "${attempt}" -lt 3 ]]; then');
expect(workflow).toContain('sudo rm -rf "${PKG_DIR}"/.qwen-code-*');
});

it('resolves once on a hosted runner and feeds every pool', () => {
// One resolution shared by the matrix is what keeps pools that start
// hours apart from installing different versions; it also keeps the
// registry wait off the ECS runners.
expect(workflow).toContain(" runs-on: 'ubuntu-latest'");
expect(workflow).toContain(
" version: '${{ steps.version.outputs.version }}'",
);
Comment on lines +124 to +126

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-4: This wiring test asserts the job outputs: declaration, needs: 'resolve', and both needs.resolve.outputs.version consumers — but nothing pins id: 'version' on the resolve step, the one link that makes steps.version.outputs.version non-empty. Renaming the id leaves the whole suite green while at runtime outputs.version expands to the empty string, so every pool's Update qwen step installs @qwen-code/qwen-code@. npm resolves the empty tag as latest (measured), so the install itself succeeds and the empty VERSION then fails 'Verify version' loudly — a red fleet update instead of the pinned release, discovered only when the next release runs, not at PR time. The test's own comment ("a leftover step reference would silently expand to an empty version") shows this empty-expansion class is exactly what it intends to guard.

Witness:

mutant (id: 'version' -> id: 'ver', declaration untouched): Tests 7 passed (7)
control: grep for `id: 'version'` in scripts/tests -> zero hits
Suggested change
expect(workflow).toContain(
" version: '${{ steps.version.outputs.version }}'",
);
expect(workflow).toContain(
" version: '${{ steps.version.outputs.version }}'",
);
expect(step('Resolve version')).toContain("id: 'version'");

The pinned id must stay version — it is the referent of the job output version: '${{ steps.version.outputs.version }}' in .github/workflows/update-ecs-runner-qwen.yml. Renaming id: 'version' in the workflow must turn the new assertion red.

中文说明

这个接线测试断言了 job 的 outputs: 声明、needs: 'resolve' 以及两处 needs.resolve.outputs.version 消费者——但没有任何断言钉住 resolve 步骤上的 id: 'version',而它正是让 steps.version.outputs.version 非空的唯一一环。把该 id 改名后整个套件仍是绿的,但运行时 outputs.version 会展开为空字符串,每个池的 Update qwen 步骤就会去安装 @qwen-code/qwen-code@。实测 npm 会把空 tag 解析为 latest,所以安装本身会成功,随后空的 VERSION 会在 'Verify version' 处大声失败——结果是 fleet 更新变红而不是装上钉住的版本,而且要等到下一次发版才被发现,不是在 PR 阶段。该测试自己的注释(「残留的步骤引用会静默展开成空版本」)表明它想防的正是这一类空展开。

验收标准:在 workflow 中把 id: 'version' 改名,新增断言必须变红。钉住的 id 必须保持 version——它是 .github/workflows/update-ecs-runner-qwen.yml 中 job 输出 version: '${{ steps.version.outputs.version }}' 的引用目标。

— qwen3.8-max via Qwen Code /review (v0.22.3)

expect(workflow).toContain(" needs: 'resolve'");
// Both consumers read the job output; a leftover step reference would
// silently expand to an empty version and install `@qwen-code/qwen-code@`.
const consumers = workflow.match(
/VERSION: '\$\{\{ needs\.resolve\.outputs\.version \}\}'/g,
);
expect(consumers).toHaveLength(2);
expect(workflow).not.toContain(
"VERSION: '${{ steps.version.outputs.version }}'",
);
});

it('waits out npm publish propagation instead of failing the race', () => {
// `npm publish --provenance` returns before the version is resolvable
// (~16 minutes for v0.22.3), and release.yml dispatches this workflow as
// soon as it returns.
const resolved = runResolve({ failures: 3 });
expect(resolved.status).toBe(0);
expect(resolved.attempts).toBe(4);
expect(resolved.output.trim()).toBe('version=0.22.3');
expect(resolved.stdout).toContain('is not on the registry yet');
// The per-attempt 404 noise stays out of the log on the happy path.
expect(resolved.stderr).not.toContain('E404');
});

it('fails with the registry error once the wait budget is spent', () => {
const resolved = runResolve({
failures: 99,
env: { RESOLVE_TIMEOUT_SECONDS: '0' },
});
expect(resolved.status).toBe(1);
expect(resolved.output.trim()).toBe('');
// The suppressed stderr is replayed, so the log still says *why*.
expect(resolved.stderr).toContain('npm error code E404');
// The annotation stays on stdout, where Actions parses workflow commands.
expect(resolved.stdout).toContain(
"::error::No published qwen version matches '0.22.3' after 0s.",
);
});

it('resolves the latest dist-tag when dispatched without a version', () => {
const resolved = runResolve({ env: { INPUT_VERSION: '' } });
expect(resolved.status).toBe(0);
expect(resolved.output.trim()).toBe('version=0.22.3');
});
});
Loading