From 752e6dc36d045a95a304a6f5a90344fb09d5c3fb Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 24 Oct 2026 20:19:48 +0000 Subject: [PATCH] fix(land-and-deploy): Step 3.4 reads the ship policy and stops only on a real VERSION collision (#3318) Under stamp-at-merge, verify the exact head stamp (base + one bump, changelog headings bound to it) and ignore claims. Under claim-at-open, stop only when another open PR holds the same VERSION or the base is at or above it. MICRO bumps and versions below a sibling's claim pass. Reported-by: Evgenii Lopatin (@exGeni) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- land-and-deploy/SKILL.md | 58 ++++--- land-and-deploy/SKILL.md.tmpl | 58 ++++--- test/land-and-deploy-version-check.test.ts | 193 +++++++++++++++++++++ 3 files changed, 261 insertions(+), 48 deletions(-) create mode 100644 test/land-and-deploy-version-check.test.ts diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 62d201db9..9c6571b01 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -708,35 +708,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --bump patch \ - --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') -NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') -OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 8b292abb2..6243389b1 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -191,35 +191,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --bump patch \ - --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') -NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty') -OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/test/land-and-deploy-version-check.test.ts b/test/land-and-deploy-version-check.test.ts new file mode 100644 index 000000000..687bbc686 --- /dev/null +++ b/test/land-and-deploy-version-check.test.ts @@ -0,0 +1,193 @@ +/** + * /land-and-deploy Step 3.4 VERSION check (#3318). The old step compared the + * branch VERSION with the next free patch slot, so it stopped stamp-at-merge + * PRs that share a tentative stamp, a claim-at-open PR whose sibling claimed a + * higher slot, and every MICRO bump. These tests run the template's own block + * against a real git fixture and the real gstack-ship-policy, with a stubbed + * gstack-next-version reply, and pin each verdict, including the real stops. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const ROOT = join(import.meta.dir, '..'); +const STUB = `process.stdout.write(process.env.FAKE_QUEUE_JSON ?? ''); +process.exit(Number(process.env.FAKE_QUEUE_CODE ?? 0)); +`; + +function step34(): string { + const tmpl = readFileSync(join(ROOT, 'land-and-deploy', 'SKILL.md.tmpl'), 'utf8'); + const step = tmpl.slice(tmpl.indexOf('## Step 3.4:'), tmpl.indexOf('{{SECTION:readiness-gate}}')); + const blocks = [...step.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(m => m[1]); + expect(blocks).toHaveLength(1); + return blocks[0]; +} + +let root: string; +let dir: string; +let repo: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'lad-version-')); + writeFileSync(join(root, 'next-version.ts'), STUB); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function git(...args: string[]): string { + const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: repo, encoding: 'utf8', timeout: 15_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); +} + +const changelog = (...versions: string[]) => `# Changelog\n\n${versions.map(v => `## [${v}] - 2026-10-24\n\n- entry\n`).join('\n')}`; + +interface Tree { version?: string; changelog?: string; policy?: string } +function commit(tree: Tree, message: string): string { + for (const [file, body] of [['VERSION', tree.version], ['CHANGELOG.md', tree.changelog], ['.gstack/ship-policy.json', tree.policy]] as const) { + if (body === undefined) continue; + mkdirSync(join(repo, file, '..'), { recursive: true }); + writeFileSync(join(repo, file), body.endsWith('\n') ? body : `${body}\n`); + } + git('add', '-A'); + git('commit', '-q', '--allow-empty', '-m', message); + return git('rev-parse', 'HEAD'); +} + +interface Case { + mode?: 'stamp-at-merge' | 'claim-at-open' | 'none' | 'invalid'; + base: Tree; + head: Tree; + queue?: object | string; + queueCode?: number; + shell?: 'bash' | 'zsh'; +} +function verdict(c: Case): string { + dir = mkdtempSync(join(root, 'case-')); + repo = join(dir, 'repo'); + mkdirSync(repo); + git('init', '-q', '-b', 'main'); + const policy = c.mode === 'none' || c.mode === undefined ? undefined + : c.mode === 'invalid' ? '{"queue_mode": ' + : JSON.stringify({ queue_mode: c.mode, bump: 'patch' }); + const baseSha = commit({ ...c.base, policy }, 'base'); + spawnSync('git', ['init', '-q', '--bare', join(dir, 'origin.git')], { timeout: 15_000 }); + git('remote', 'add', 'origin', join(dir, 'origin.git')); + git('push', '-q', 'origin', 'main'); + git('checkout', '-q', '-b', 'feature'); + const head = commit(c.head, 'head'); + const queue = c.queue ?? { offline: false, claimed: [], complete: true }; + const block = step34() + .replaceAll('~/.claude/skills/gstack/bin/gstack-ship-policy', join(ROOT, 'bin', 'gstack-ship-policy')) + .replaceAll('~/.claude/skills/gstack/bin/gstack-next-version', join(root, 'next-version.ts')); + const r = spawnSync(c.shell ?? 'bash', ['-c', block], { + cwd: repo, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + PR_HEAD: head, BASE_SHA: baseSha, BASE_BRANCH: 'main', PR_NUMBER: '7', + FAKE_QUEUE_JSON: typeof queue === 'string' ? queue : JSON.stringify(queue), + FAKE_QUEUE_CODE: String(c.queueCode ?? 0), + }, + }); + const line = r.stdout.split('\n').find(l => l.startsWith('VERSION_CHECK: ')); + if (!line) throw new Error(`no VERSION_CHECK line (exit ${r.status}):\n${r.stdout}\n${r.stderr}`); + return line; +} + +const claim = (pr: number, version: string) => ({ pr, branch: `b${pr}`, version }); +const BASE: Tree = { version: '1.91.186.0', changelog: changelog('1.91.186.0') }; +const stamped = (version: string, below = '1.91.186.0'): Tree => ({ version, changelog: changelog(version, below) }); + +describe('stamp-at-merge: verify the exact head stamp, ignore claims', () => { + test('a sibling sharing the tentative stamp no longer stops the PR', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (stamp-at-merge: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a numeric one-bump stamp passes (9 -> 10)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: { version: '1.91.9.0', changelog: changelog('1.91.9.0') }, head: { version: '1.91.10.0', changelog: changelog('1.91.10.0', '1.91.9.0') } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('a MICRO stamp is one bump and passes', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('claims offline do not matter under stamp-at-merge', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: '', queueCode: 1 })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('stops when the base already reached the stamp (a sibling landed first)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })) + .toBe('VERSION_CHECK: STOP stamp stale (stamp-at-merge: 1.91.187.0, base 1.91.187.0)'); + }); + + test('stops when the base moved under a stamp that is still one bump above it', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); + + test('stops on an unstamped [Unreleased] entry and on a stamp more than one bump up', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: { version: '1.91.187.0', changelog: changelog('Unreleased', '1.91.186.0') } })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.188.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); +}); + +describe('claim-at-open: stop only on a real collision', () => { + test('versions compare numerically, and 3-digit semver works', () => { + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.9.0' }, head: { version: '1.91.10.0' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.10.0' }, head: { version: '1.91.9.0' } })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.10' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.9.1' } })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('a sibling holding a higher claim no longer stops the PR', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a MICRO bump passes', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + }); + + test('stops when another open PR holds the same VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0'), claim(9, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: STOP also claimed by #8 (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('stops when the base is at or above the branch VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.188.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); + + test('a repo with no policy file gets the claim-at-open default', () => { + expect(verdict({ mode: 'none', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('offline claims and unreadable claims are unavailable, but a known collision still stops', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: true } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); +}); + +describe('either mode', () => { + test.skipIf(!Bun.which('zsh'))('the block gives the same verdicts under zsh', () => { + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ shell: 'zsh', mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('no configured version source is not applicable', () => { + expect(verdict({ mode: 'stamp-at-merge', base: {}, head: {}, queue: { no_version: true, version: null } })).toStartWith('VERSION_CHECK: NO_VERSION'); + }); + + test('a VERSION missing on one side is unavailable, not green', () => { + expect(verdict({ mode: 'claim-at-open', base: { changelog: changelog('1.91.186.0') }, head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('an invalid policy is unavailable, but a base at or above the branch still stops', () => { + expect(verdict({ mode: 'invalid', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: unavailable: no policy'); + expect(verdict({ mode: 'invalid', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); +});