Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 861fdff

Browse files
objectstack-fleet[bot]claudeos-zhuang
authored
fix(ci): the affected-tests fast path runs: the shard step compares the output value, not the selector's key=value line (objectui#13350) (#13358)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuUbtHKs1BJTq2BeSm3x6GNc)_ Fixes #13350 Clause-②: no Metadata-protocol: none ## 维护者速读(草稿) **改了什么**:`ci.yml` 测试分片步骤里的一行比较,从 `'mode=affected'` 改成 `'affected'`;另加一个钉测,把"选择步骤写出的输出"和"分片步骤读到并比较的值"按 `ci.yml` 原样真跑一遍对上。 **为什么改**:两速 CI(#12214)设计成 PR 和合并队列只跑受影响的测试,但这行比较永远为假,快速通道从没生效,每个 PR 和每次队列构建都跑全部 16 片(约 150 分片分钟)。 **风险与代价(含回滚)**:快速通道第一次真正生效。它漏掉的测试由 main 上每次 push 和每小时的全量运行兜底,这是 #12214 已接受的取舍;选择器出错仍按全量跑。回滚就是 revert 这一行。 **席位意见**: **你要做的**:看过后在本 PR 上点 Approve(os-zhuang 或 hotlong)。 ## What was wrong The `test` job's select step (`Select the tests this change affects`, id `select`) appends the selector's verdict line to `$GITHUB_OUTPUT`: the literal `mode=affected` or `mode=full`. The runner splits that line at its first `=`, so the step output is `mode` with the value `affected` or `full`. The shard step (`Run tests (shard N/16)`) reads `steps.select.outputs.mode` into `$MODE` and compared it with `'mode=affected'`, which is never true. Every pull request and merge-group build therefore took the `else` branch and ran the full suite on all 16 legs. The type-check scope step in the same file already uses the right shape (it writes `scope=affected` and compares against `'affected'`). ## The fix One line: the shard step compares `$MODE` with `'affected'`. A comment above the step says what `$MODE` holds and names the pin. No other workflow logic changes. ## The pin: `scripts/__tests__/select-affected-tests-wiring.test.ts` It restates neither end. It extracts both steps' `env:` and `run:` blocks from `ci.yml` (textually, as `markdown-test-inputs.test.ts` does), runs the select step with `bash -e` against this checkout (the real selector, based on HEAD), parses what it appended the way the runner does, gives the shard step whatever output its `env:` names, and reads which command the shard step chose off a `pnpm` stand-in that records its argv. - `pull_request` and `merge_group`: vitest gets exactly the filters the selector wrote, and the leg prints `Affected speed: N vitest filter(s), shard 1 of 16.` - `push`, and a pull request with no base sha (fail-open): the full `pnpm test` command. - Controls: the comparison reverted to the whole line, the output written under another key, or another value under the same key each put a pull request on the full suite. - It also asserts that both steps sit in one job, the select step first, and that the shard step binds exactly one select output. The selector runs once (about 3 s) and is replayed for the other legs. The file runs in about 3.5 s. ## Verification - New pin: 8 passed. Ablations on the committed fix, each restored from HEAD afterwards: the comparison reverted to `'mode=affected'` fails 2 tests (pull_request, merge_group); the select step alone writing `speed=...` instead of `mode=...` fails 3. - Every test file that reads `ci.yml` (`git grep` over `**/__tests__/**` and `*.test.*`, plus `shard-headroom.test.ts`): 62 files, 2064 tests passed, run under the verify lock. - One real affected leg, run locally exactly as the step spells it (`pnpm exec vitest run --shard=1/16` plus this branch's 669 filters): 36 files, 576 tests passed. The sequencer printed `shard 1/16 runs 36 of 669 files, 118.5 s of 1895.2 s by the per-file timings`. - The planner over this branch's selection gives all 16 legs between 36 and 43 files, about 118 s of measured weight each. - `pnpm run type-check:scripts` exit 0; `eslint` on the new file exit 0; `pnpm check:new-line-citations` 0 new; `check:control-bytes`, `check:shell-escape-residue`, `check:test-path-roots`, `check:bash32-floor` and `check:unreferenced-sources` exit 0. - `node scripts/check-changeset-presence.mjs`: no changeset owed (no released package source changed), so none is added. - `node scripts/check-governed-queue-guard.mjs --test` on both paths: GOVERNED (Tier H, `.github/workflows/**`). ## Acceptance (read on this PR's own CI) This PR is the scripts-only class: the selector on this branch answers `mode=affected` with 669 of 5721 test files (the always-on set plus the new test). So this PR's `Test (shard K/16)` legs should each print `Affected speed: 669 vitest filter(s), shard K of 16.`, and the `Test` aggregator's headroom notice is the total-shard-time reading the card asks for. For the other direction, the selector replayed on `b911ebb` (10 files, `packages/react` and `packages/app-shell`) answers `mode=affected` with 5056 of 5721 files, which is near-full as the card expects. ## Acceptance notes - The headroom step (`The longest test shard's headroom against the cap`) is `if: always()` and `continue-on-error: true`, so it cannot red `Test`. On affected runs its suggested width will read low. That is the sibling dynamic-shard-width card's subject, not this one. - An affected leg passes filters, so the root config's `passWithNoTests` is false and a leg handed zero files would red. The planner puts a file on every leg whenever the selection holds at least 16 files. Today the always-on set alone is 668 files, and the selector already refuses an empty selection (exit 2, which the step turns into a full run). No guard is added, because this card adds no behaviour. - Affected legs do not write per-file timings. Only `push` and `schedule` upload them, unchanged. - Sibling objectui#13349 (weekly timings refresh) touches `test-shard-timings-refresh.yml` and the timings file, which is disjoint from this diff. ## Governed surface `.github/workflows/ci.yml` is Tier H. This PR stays draft until an authorized approval (`os-zhuang` / `hotlong`) is on record. ⛔ No seat approves it. The maintainer's direct-dispatch word is quoted on the card (comment 6357584416). - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) Implemented by the ui lane card thread, session `https://claude.ai/code/session_01LyeJkFWzSqWc3SDQPAqpsn`, branch `claude/issue-13350-affected-fast-path`. The claim is on the card, comment 6364039593. --- _Generated by [Claude Code](https://claude.ai/code/session_01LyeJkFWzSqWc3SDQPAqpsn)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LyeJkFWzSqWc3SDQPAqpsn Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <jack@objectstack.ai>
1 parent cf24190 commit 861fdff

2 files changed

Lines changed: 311 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,13 @@ jobs:
12881288
# positionals -- paths relative to the repository root, no `--`, which is
12891289
# the only spelling `scripts/vitest-invocation-guard.mjs` admits.
12901290
#
1291+
# `$MODE` is the VALUE of the step output `mode`: the selector step's
1292+
# `mode=affected` line is GitHub's `key=value` form, so what arrives here
1293+
# is `affected` or `full`, never the line itself. Comparing against the
1294+
# whole line kept this speed from ever running (objectui#13350);
1295+
# `scripts/__tests__/select-affected-tests-wiring.test.ts` runs both
1296+
# steps as written, so neither end can be renamed alone.
1297+
#
12911298
# ⭐ Which files a leg runs is decided by measured per-file duration, not
12921299
# by path hash (objectui#12208, ruling 6121816480, letter B): the root
12931300
# config's `sequence.sequencer` is `scripts/test-shard-sequencer.mjs`,
@@ -1307,7 +1314,7 @@ jobs:
13071314
MODE: ${{ steps.select.outputs.mode }}
13081315
OBJECTUI_TEST_TIMINGS_OUT: ${{ runner.temp }}/test-file-timings/shard-${{ matrix.shard }}.json
13091316
run: |
1310-
if [ "$MODE" = 'mode=affected' ]; then
1317+
if [ "$MODE" = 'affected' ]; then
13111318
mapfile -t filters < "$RUNNER_TEMP/affected-tests.txt"
13121319
echo "Affected speed: ${#filters[@]} vitest filter(s), shard ${{ matrix.shard }} of 16."
13131320
pnpm exec vitest run --shard=${{ matrix.shard }}/16 "${filters[@]}"
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2+
import { execFileSync, spawnSync } from 'node:child_process';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
/**
9+
* objectui#13350 — the two steps of the `test` job that make the affected
10+
* speed, held together by RUNNING them.
11+
*
12+
* `Select the tests this change affects` (id `select`) appends the selector's
13+
* verdict line to `$GITHUB_OUTPUT`; `Run tests (shard N/16)` reads one output of
14+
* that step into an env var and branches on it. The runner splits each appended
15+
* line at its first `=`, so the line `mode=affected` is the output `mode` with
16+
* the value `affected`. From objectui#12214 until this card the run step
17+
* compared its env var with the whole line -- never true -- and every pull
18+
* request and merge-group build ran the full sixteen-shard suite. Each end was
19+
* pinned on its own (`select-affected-tests.test.ts` reads the selector's last
20+
* line, and both commands as text); nothing held the key the first step writes
21+
* to the key the second reads, or the value it writes to the literal the second
22+
* compares with.
23+
*
24+
* So this file restates neither end. It extracts both steps' `env:` and `run:`
25+
* from `ci.yml`, runs the select step with bash against this checkout -- the
26+
* real selector, based on HEAD -- parses what it appended the way the runner
27+
* does, hands the run step whatever output its `env:` names, and reads which
28+
* command the run step chose off a `pnpm` stand-in that records its argv.
29+
* Textual rather than YAML-parsed, for the reason `markdown-test-inputs.test.ts`
30+
* gives: what is under test is the bytes CI runs.
31+
*
32+
* affected a `pull_request` and a `merge_group` event hand vitest exactly the
33+
* filters the selector wrote.
34+
* full a `push` event, and a pull request with no base sha (the
35+
* fail-open path), run the whole suite.
36+
* controls the run step's comparison reverted to the whole line, and the
37+
* select step's output written under another key or value, each
38+
* ALONE, put a pull request on the full suite -- so this file
39+
* cannot pass while the two ends disagree.
40+
*/
41+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
42+
const CI_YML = path.join(repoRoot, '.github', 'workflows', 'ci.yml');
43+
44+
const SELECT_HEADER = ' - name: Select the tests this change affects\n';
45+
const RUN_HEADER = ' - name: Run tests (shard ${{ matrix.shard }}/16)\n';
46+
/** The output reference a run step's `env:` value is, when it reads the select step. */
47+
const SELECT_OUTPUT = /^\$\{\{ steps\.select\.outputs\.([A-Za-z0-9_-]+) \}\}$/;
48+
49+
type Step = { at: number; id: string | undefined; env: Array<[string, string]>; run: string };
50+
51+
/** One step of `ci.yml`, from its `- name:` line to the first line outside it. */
52+
function readStep(yaml: string, header: string): Step {
53+
const at = yaml.indexOf(header);
54+
if (at === -1) throw new Error(`ci.yml has no step headed ${JSON.stringify(header.trim())}`);
55+
if (yaml.indexOf(header, at + header.length) !== -1) {
56+
throw new Error(`ci.yml has two steps headed ${JSON.stringify(header.trim())}`);
57+
}
58+
const lines: string[] = [];
59+
for (const line of yaml.slice(at + header.length).split('\n')) {
60+
if (line.trim() !== '' && !line.startsWith(' ')) break;
61+
lines.push(line);
62+
}
63+
const id = lines.map((line) => /^ {8}id: (\S+)$/.exec(line)?.[1]).find(Boolean);
64+
65+
const env: Array<[string, string]> = [];
66+
const envAt = lines.findIndex((line) => /^ {8}env:$/.test(line));
67+
if (envAt !== -1) {
68+
for (const line of lines.slice(envAt + 1)) {
69+
const entry = /^ {10}([A-Za-z_][A-Za-z0-9_]*): (.+)$/.exec(line);
70+
if (!entry) break;
71+
env.push([entry[1], entry[2]]);
72+
}
73+
}
74+
75+
const runAt = lines.findIndex((line) => /^ {8}run: \|$/.test(line));
76+
if (runAt === -1) throw new Error(`${header.trim()} has no \`run: |\` block`);
77+
const body: string[] = [];
78+
for (const line of lines.slice(runAt + 1)) {
79+
if (line.trim() !== '' && !line.startsWith(' ')) break;
80+
body.push(line.slice(10));
81+
}
82+
return { at, id, env, run: `${body.join('\n').trimEnd()}\n` };
83+
}
84+
85+
const YAML = fs.readFileSync(CI_YML, 'utf8');
86+
const SELECT = readStep(YAML, SELECT_HEADER);
87+
const RUN = readStep(YAML, RUN_HEADER);
88+
89+
/** The run step's env var that carries a select-step output, and that output's key. */
90+
function modeBinding(): { variable: string; key: string } {
91+
const bound = RUN.env.flatMap(([variable, value]) => {
92+
const key = SELECT_OUTPUT.exec(value)?.[1];
93+
return key ? [{ variable, key }] : [];
94+
});
95+
if (bound.length !== 1) throw new Error(`the run step binds ${bound.length} select-step outputs; this file reads exactly one`);
96+
return bound[0];
97+
}
98+
99+
/* ── the runner, as far as these two steps need it ──────────────────────── */
100+
101+
type Event = { name: 'pull_request' | 'merge_group' | 'push'; base: string };
102+
103+
/** `${{ … }}` resolved the way the runner would for this event, shard 1. */
104+
function expand(text: string, event: Event, temp: string, outputs: Record<string, string> = {}): string {
105+
return text.replace(/\$\{\{ ([^}]+?) \}\}/g, (whole, expr: string) => {
106+
const output = SELECT_OUTPUT.exec(whole)?.[1];
107+
if (output !== undefined) return outputs[output] ?? ''; // an output never written reads as ''
108+
switch (expr) {
109+
case 'github.event_name':
110+
return event.name;
111+
case 'github.event.pull_request.base.sha':
112+
return event.name === 'pull_request' ? event.base : '';
113+
case 'github.event.merge_group.base_sha':
114+
return event.name === 'merge_group' ? event.base : '';
115+
case 'matrix.shard':
116+
return '1';
117+
case 'runner.temp':
118+
return temp;
119+
default:
120+
throw new Error(`no stand-in for \`\${{ ${expr} }}\`; add one here before reading this step`);
121+
}
122+
});
123+
}
124+
125+
/** `$GITHUB_OUTPUT` read the way the runner reads it: each line split at its first `=`. */
126+
function parseOutputs(text: string): Record<string, string> {
127+
const outputs: Record<string, string> = {};
128+
for (const line of text.split('\n')) {
129+
if (line === '') continue;
130+
const eq = line.indexOf('=');
131+
if (eq === -1) throw new Error(`\`${line}\` is not a key=value output line (a heredoc output needs a reader here)`);
132+
outputs[line.slice(0, eq)] = line.slice(eq + 1);
133+
}
134+
return outputs;
135+
}
136+
137+
let scratch = '';
138+
const nodeDir = path.dirname(process.execPath);
139+
140+
/** Write an executable stand-in for `name` into a fresh bin directory. */
141+
function standIn(name: string, body: string): string {
142+
const bin = fs.mkdtempSync(path.join(scratch, `bin-${name}-`));
143+
fs.writeFileSync(path.join(bin, name), `#!/usr/bin/env bash\n${body}`, { mode: 0o755 });
144+
return bin;
145+
}
146+
147+
type Selected = { outputs: Record<string, string>; log: string };
148+
149+
/** Run the select step, with `bash -e` as the runner does, in a fresh `RUNNER_TEMP`. */
150+
function runSelect(event: Event, temp: string, bin?: string): Selected {
151+
const env = Object.fromEntries(SELECT.env.map(([name, value]) => [name, expand(value, event, temp)]));
152+
const script = path.join(temp, 'select.sh');
153+
fs.writeFileSync(script, expand(SELECT.run, event, temp));
154+
const githubOutput = path.join(temp, 'github-output-select');
155+
fs.writeFileSync(githubOutput, '');
156+
const run = spawnSync('bash', ['-e', script], {
157+
cwd: repoRoot,
158+
encoding: 'utf8',
159+
env: {
160+
...process.env,
161+
...env,
162+
RUNNER_TEMP: temp,
163+
GITHUB_OUTPUT: githubOutput,
164+
PATH: [bin, nodeDir, process.env.PATH].filter(Boolean).join(path.delimiter),
165+
},
166+
});
167+
if (run.status !== 0) throw new Error(`the select step exited ${run.status}:\n${run.stdout}${run.stderr}`);
168+
return { outputs: parseOutputs(fs.readFileSync(githubOutput, 'utf8')), log: run.stdout };
169+
}
170+
171+
type Shard = { argv: string[]; log: string };
172+
173+
/** Run the shard step with the outputs the select step left, `pnpm` recording its argv. */
174+
function runShard(event: Event, temp: string, outputs: Record<string, string>, run = RUN.run): Shard {
175+
const env = Object.fromEntries(RUN.env.map(([name, value]) => [name, expand(value, event, temp, outputs)]));
176+
const script = path.join(temp, 'shard.sh');
177+
fs.writeFileSync(script, expand(run, event, temp, outputs));
178+
const argvFile = path.join(temp, 'pnpm-argv');
179+
const bin = standIn('pnpm', `printf '%s\\n' "$@" > ${JSON.stringify(argvFile)}\n`);
180+
const result = spawnSync('bash', ['-e', script], {
181+
cwd: repoRoot,
182+
encoding: 'utf8',
183+
env: { ...process.env, ...env, RUNNER_TEMP: temp, PATH: `${bin}${path.delimiter}${process.env.PATH}` },
184+
});
185+
if (result.status !== 0) throw new Error(`the shard step exited ${result.status}:\n${result.stdout}${result.stderr}`);
186+
return { argv: fs.readFileSync(argvFile, 'utf8').split('\n').slice(0, -1), log: result.stdout };
187+
}
188+
189+
/* ── one real selector run, replayed where only the wiring is in question ── */
190+
191+
let head = '';
192+
/** The real selector's answer on this checkout, taken through the real select step. */
193+
let real: Selected & { filters: string[]; temp: string } = { outputs: {}, log: '', filters: [], temp: '' };
194+
/** A `node` that replays `real` for the select step, so each leg does not re-walk the tree. */
195+
let replay = '';
196+
197+
function freshTemp(leg: string): string {
198+
return fs.mkdtempSync(path.join(scratch, `${leg}-`));
199+
}
200+
201+
beforeAll(() => {
202+
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'select-affected-wiring-13350-'));
203+
head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
204+
const temp = freshTemp('real');
205+
// The select step itself names these two files under `$RUNNER_TEMP`.
206+
const filters = path.join(temp, 'affected-tests.txt');
207+
const stdout = path.join(temp, 'select.out');
208+
real = { ...runSelect({ name: 'pull_request', base: head }, temp), filters: [], temp };
209+
real.filters = fs.readFileSync(filters, 'utf8').split('\n').filter(Boolean);
210+
replay = standIn(
211+
'node',
212+
[
213+
'while [ "$#" -gt 0 ]; do',
214+
` if [ "$1" = '--out' ]; then cp ${JSON.stringify(filters)} "$2"; fi`,
215+
' shift',
216+
'done',
217+
`cat ${JSON.stringify(stdout)}`,
218+
'',
219+
].join('\n'),
220+
);
221+
}, 60_000);
222+
223+
afterAll(() => {
224+
if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
225+
});
226+
227+
/* ── the two steps ───────────────────────────────────────────────────────── */
228+
229+
describe('the affected speed, both steps run as ci.yml writes them (objectui#13350)', () => {
230+
it('declares both in one job, the select step first, the run step reading exactly one of its outputs', () => {
231+
expect(SELECT.id).toBe('select');
232+
expect(SELECT.at).toBeLessThan(RUN.at);
233+
// `steps.<id>` resolves only inside its own job: no job key between the two.
234+
const between = YAML.slice(SELECT.at, RUN.at)
235+
.split('\n')
236+
.filter((line) => !/^\s*#/.test(line));
237+
expect(between.filter((line) => /^ {2}[A-Za-z0-9_-]+:\s*$/.test(line))).toEqual([]);
238+
expect(modeBinding().key).not.toBe('');
239+
});
240+
241+
it('a pull_request hands vitest exactly the filters the real selector wrote', () => {
242+
expect(real.filters.length).toBeGreaterThan(0);
243+
// The same `RUNNER_TEMP` the select step wrote its filters into, as one job's steps share it.
244+
const shard = runShard({ name: 'pull_request', base: head }, real.temp, real.outputs);
245+
expect(shard.argv.slice(-real.filters.length)).toEqual(real.filters);
246+
expect(shard.argv).toContain('--shard=1/16');
247+
expect(shard.log).toContain(`Affected speed: ${real.filters.length} vitest filter(s), shard 1 of 16.`);
248+
});
249+
250+
it('a merge_group build hands vitest the same filters', () => {
251+
const temp = freshTemp('queue');
252+
const event: Event = { name: 'merge_group', base: head };
253+
const selected = runSelect(event, temp, replay);
254+
expect(selected.outputs).toEqual(real.outputs);
255+
const shard = runShard(event, temp, selected.outputs);
256+
expect(shard.argv.slice(-real.filters.length)).toEqual(real.filters);
257+
});
258+
259+
it('a push runs the whole suite', () => {
260+
const temp = freshTemp('push');
261+
const event: Event = { name: 'push', base: '' };
262+
const shard = runShard(event, temp, runSelect(event, temp, replay).outputs);
263+
expect(shard.argv[0]).toBe('test');
264+
expect(shard.argv).toContain('--shard=1/16');
265+
expect(shard.argv.filter((arg) => real.filters.includes(arg))).toEqual([]);
266+
});
267+
268+
it('a pull request with no base sha runs the whole suite (the step fails open)', () => {
269+
const temp = freshTemp('no-base');
270+
const event: Event = { name: 'pull_request', base: '' };
271+
const selected = runSelect(event, temp, replay);
272+
expect(selected.log).toContain('running the full suite rather than guessing');
273+
const shard = runShard(event, temp, selected.outputs);
274+
expect(shard.argv[0]).toBe('test');
275+
});
276+
});
277+
278+
describe('controls: either end edited alone is a full run, so the legs above cannot pass by accident', () => {
279+
it('the run step comparing against the whole verdict line (the objectui#13350 defect) runs the whole suite', () => {
280+
const { variable, key } = modeBinding();
281+
const comparison = new RegExp(`\\[ "\\$${variable}" = '([^']*)' \\]`);
282+
const found = comparison.exec(RUN.run);
283+
expect(found, `the run step no longer compares $${variable} with a quoted literal; rewrite this control`).not.toBeNull();
284+
const reverted = RUN.run.replace(comparison, `[ "$${variable}" = '${key}=${found![1]}' ]`);
285+
expect(reverted).not.toBe(RUN.run);
286+
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('reverted'), real.outputs, reverted);
287+
expect(shard.argv[0]).toBe('test');
288+
});
289+
290+
it('the select step writing its verdict under another key runs the whole suite', () => {
291+
const renamed = Object.fromEntries(Object.entries(real.outputs).map(([key, value]) => [`${key}_renamed`, value]));
292+
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('renamed-key'), renamed);
293+
expect(shard.argv[0]).toBe('test');
294+
});
295+
296+
it('the select step writing another value under the same key runs the whole suite', () => {
297+
const { key } = modeBinding();
298+
expect(real.outputs[key], `the select step wrote no \`${key}\` output`).toBeDefined();
299+
const changed = { ...real.outputs, [key]: `${real.outputs[key]}-renamed` };
300+
const shard = runShard({ name: 'pull_request', base: head }, freshTemp('renamed-value'), changed);
301+
expect(shard.argv[0]).toBe('test');
302+
});
303+
});

0 commit comments

Comments
 (0)