ci(daemon): gate PRs on daemon protocol crossing from the newest release (#24089)

Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-30 23:23:28 -07:00
committed by GitHub
co-authored by m4air
parent 49a83deaef
commit 2a83c9536f
10 changed files with 471 additions and 28 deletions
+4
View File
@@ -852,6 +852,10 @@ jobs:
with:
native-runtime: node
# R1: an upgrade from the newest release must adopt its live terminal daemon.
- name: Daemon protocol crossing against newest release
run: pnpm run check:daemon-protocol-crossing
# A path filter that matches nothing exits 1 ("No test files found"), so this
# lane cannot report success while running zero tests.
- name: Old/new client and server compatibility journeys
@@ -0,0 +1,113 @@
// R1 gate: the working tree must attach the newest release's terminal daemon, or an
// upgrade strands every live terminal. Rollback crossing is reported, not enforced.
// Usage: node check-daemon-protocol-crossing.mjs [--release-ref <ref>]
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
DAEMON_PROTOCOL_SOURCE_PATH,
canAttach,
crossingRequirements,
parseDaemonProtocolFacts
} from './daemon-protocol-facts.mjs'
import { selectLatestStableReleaseTag } from './stable-release-tags.mjs'
// Same override the cross-version-wire harness honors, so both pair against one ref.
const RELEASE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF'
function git(repoRoot, args) {
return execFileSync('git', args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024
}).trim()
}
export function resolveReleaseRef(repoRoot, explicitRef, env = process.env) {
const override = explicitRef?.trim() || env[RELEASE_REF_ENV]?.trim()
if (override) {
return override
}
const tags = git(repoRoot, ['tag', '--list', 'v[0-9]*']).split('\n').filter(Boolean)
const latest = selectLatestStableReleaseTag(tags)
if (!latest) {
// Why: a shallow clone has no tags; passing here would silently skip the gate.
throw new Error(
`no stable release tags matching vX.Y.Z (saw ${tags.length} tag(s)). ` +
`Check out with fetch-depth: 0, or pass --release-ref / ${RELEASE_REF_ENV}.`
)
}
return latest
}
/**
* @param {{ release: ReturnType<typeof parseDaemonProtocolFacts>, candidate: ReturnType<typeof parseDaemonProtocolFacts>, releaseRef: string }} input
*/
export function assessDaemonProtocolCrossing({ release, candidate, releaseRef }) {
const upgrade = canAttach(candidate, release)
const rollback = canAttach(release, candidate)
const requirements = crossingRequirements(release)
const lines = [
`release ${releaseRef}: daemon protocol ${release.protocolVersion}`,
`candidate (working tree): daemon protocol ${candidate.protocolVersion}`,
upgrade
? `upgrade ${releaseRef} -> candidate: OK, live terminals are adopted`
: `upgrade ${releaseRef} -> candidate: FAIL, ${requirements.upgrade}; add ${release.protocolVersion} to PREVIOUS_DAEMON_PROTOCOL_VERSIONS`,
rollback
? `rollback candidate -> ${releaseRef}: OK, live terminals survive (info)`
: `rollback candidate -> ${releaseRef}: terminals from the candidate's daemon are unreachable until re-upgrade (info, expected after a protocol bump)`
]
return { upgrade, rollback, lines }
}
export function checkDaemonProtocolCrossing({ repoRoot, releaseRef: explicitRef, env }) {
const releaseRef = resolveReleaseRef(repoRoot, explicitRef, env)
let releaseSource
try {
releaseSource = git(repoRoot, ['show', `${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}`])
} catch (error) {
throw new Error(
`cannot read ${DAEMON_PROTOCOL_SOURCE_PATH} at ${releaseRef}: ${error.stderr?.trim() || String(error)}`
)
}
const release = parseDaemonProtocolFacts(
releaseSource,
`${releaseRef}:${DAEMON_PROTOCOL_SOURCE_PATH}`
)
const candidate = parseDaemonProtocolFacts(
readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'),
DAEMON_PROTOCOL_SOURCE_PATH
)
return assessDaemonProtocolCrossing({ release, candidate, releaseRef })
}
function parseArgs(argv) {
const index = argv.indexOf('--release-ref')
if (index === -1) {
return {}
}
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error('--release-ref requires a value')
}
return { releaseRef: value }
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const repoRoot = resolve(import.meta.dirname, '..', '..')
try {
const { releaseRef } = parseArgs(process.argv.slice(2))
const result = checkDaemonProtocolCrossing({ repoRoot, releaseRef })
for (const line of result.lines) {
console.log(line)
}
if (!result.upgrade) {
process.exitCode = 1
}
} catch (error) {
console.error(`daemon protocol crossing check failed: ${error.message}`)
process.exitCode = 1
}
}
@@ -0,0 +1,105 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { checkDaemonProtocolCrossing } from './check-daemon-protocol-crossing.mjs'
import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs'
import { selectLatestStableReleaseTag } from './stable-release-tags.mjs'
const repos = []
function git(repo, args) {
execFileSync('git', args, { cwd: repo, stdio: 'ignore' })
}
function writeProtocol(repo, current) {
const previous = Array.from({ length: current - 1 }, (_, index) => index + 1)
const file = join(repo, DAEMON_PROTOCOL_SOURCE_PATH)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(
file,
`export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n`
)
}
function repoWithTags(tagged) {
const repo = mkdtempSync(join(tmpdir(), 'daemon-protocol-crossing-'))
repos.push(repo)
git(repo, ['init', '-q'])
for (const [tag, protocol] of tagged) {
writeProtocol(repo, protocol)
git(repo, ['add', '.'])
git(repo, [
'-c',
'user.name=test',
'-c',
'user.email=test@example.com',
'commit',
'-q',
'--no-gpg-sign',
'-m',
tag
])
git(repo, ['tag', tag])
}
return repo
}
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true })
}
})
describe('selectLatestStableReleaseTag', () => {
it('orders numerically and skips prerelease tags', () => {
expect(selectLatestStableReleaseTag(['v1.4.9', 'v1.4.10', 'v1.5.0-rc.1', 'nightly'])).toBe(
'v1.4.10'
)
expect(selectLatestStableReleaseTag(['nightly'])).toBeNull()
})
})
describe('checkDaemonProtocolCrossing', () => {
const env = {}
it('passes a bump that lists the newest release version, reporting rollback as info', () => {
const repo = repoWithTags([
['v1.0.9', 5],
['v1.0.10', 6]
])
writeProtocol(repo, 7)
const result = checkDaemonProtocolCrossing({ repoRoot: repo, env })
expect(result).toMatchObject({ upgrade: true, rollback: false })
expect(result.lines[0]).toBe('release v1.0.10: daemon protocol 6')
})
it('fails when the working tree drops the newest release version', () => {
const repo = repoWithTags([['v1.0.0', 6]])
writeFileSync(
join(repo, DAEMON_PROTOCOL_SOURCE_PATH),
'export const PROTOCOL_VERSION = 7\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5] as const\n'
)
const result = checkDaemonProtocolCrossing({ repoRoot: repo, env })
expect(result.upgrade).toBe(false)
expect(result.lines.join('\n')).toContain('add 6 to PREVIOUS_DAEMON_PROTOCOL_VERSIONS')
})
it('refuses to pass silently without release tags', () => {
const repo = repoWithTags([['nightly', 6]])
expect(() => checkDaemonProtocolCrossing({ repoRoot: repo, env })).toThrow(
/no stable release tags/
)
})
it('honors an explicit release ref', () => {
const repo = repoWithTags([
['v1.0.0', 5],
['v1.0.1', 6]
])
const result = checkDaemonProtocolCrossing({ repoRoot: repo, releaseRef: 'v1.0.0', env })
expect(result).toMatchObject({ upgrade: true, rollback: false })
expect(result.lines[0]).toBe('release v1.0.0: daemon protocol 5')
})
})
+99
View File
@@ -0,0 +1,99 @@
// Reads a build's daemon protocol version and the older versions it can still attach to.
// Parsed from source text so a release tag and a candidate tree are read the same way.
// Usage: node daemon-protocol-facts.mjs <daemon-protocol-version.ts> [output.json]
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
export const DAEMON_PROTOCOL_SOURCE_PATH = 'src/main/daemon/daemon-protocol-version.ts'
const CURRENT_DECLARATION = /^export const PROTOCOL_VERSION\b[^=\n]*=\s*([^\n;]*)/gmu
const PREVIOUS_DECLARATION =
/^export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS\b[^=\n]*=\s*\[([^\]]*)\]/gmu
function singleMatch(source, pattern, name, label) {
const matches = [...source.matchAll(pattern)]
if (matches.length !== 1) {
throw new Error(
`${label}: expected exactly one \`export const ${name}\` declaration, found ${matches.length}`
)
}
return matches[0][1]
}
function parseProtocolInteger(text, name, label) {
const trimmed = text.trim()
// Why: a reference like `= NEXT_VERSION` must fail, not coerce to NaN or 0.
if (!/^\d+$/u.test(trimmed)) {
throw new Error(`${label}: ${name} must be an integer literal, got \`${trimmed}\``)
}
const value = Number(trimmed)
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`${label}: ${name} must be a positive integer, got \`${trimmed}\``)
}
return value
}
/**
* @param {string} source
* @param {string} [label] names the source in errors
* @returns {{ protocolVersion: number, previousProtocolVersions: number[] }}
*/
export function parseDaemonProtocolFacts(source, label = DAEMON_PROTOCOL_SOURCE_PATH) {
const protocolVersion = parseProtocolInteger(
singleMatch(source, CURRENT_DECLARATION, 'PROTOCOL_VERSION', label),
'PROTOCOL_VERSION',
label
)
const previousProtocolVersions = singleMatch(
source,
PREVIOUS_DECLARATION,
'PREVIOUS_DAEMON_PROTOCOL_VERSIONS',
label
)
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => parseProtocolInteger(entry, 'PREVIOUS_DAEMON_PROTOCOL_VERSIONS entry', label))
if (protocolVersion > 1 && previousProtocolVersions.length === 0) {
throw new Error(`${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS parsed as empty`)
}
const outOfRange = previousProtocolVersions.filter((version) => version >= protocolVersion)
if (outOfRange.length > 0) {
throw new Error(
`${label}: PREVIOUS_DAEMON_PROTOCOL_VERSIONS lists ${outOfRange.join(', ')}, not below PROTOCOL_VERSION ${protocolVersion}`
)
}
return { protocolVersion, previousProtocolVersions }
}
/** Whether `reader` can route sessions owned by a daemon speaking `owner`'s protocol. */
export function canAttach(reader, owner) {
return (
reader.protocolVersion === owner.protocolVersion ||
reader.previousProtocolVersions.includes(owner.protocolVersion)
)
}
/** What any candidate must declare for sessions to cross in each direction with `release`. */
export function crossingRequirements(release) {
const accepted = [...release.previousProtocolVersions, release.protocolVersion]
return {
upgrade: `candidate speaks ${release.protocolVersion} or lists ${release.protocolVersion} as previous`,
rollback: `candidate speaks one of ${Math.min(...accepted)}..${Math.max(...accepted)} (the release's own or previous list)`,
// Only an owner the release already speaks can survive a rollback, so a newer-protocol candidate cannot.
bothDirections: `candidate speaks ${release.protocolVersion}, or speaks an older release-listed version and lists ${release.protocolVersion} as previous`
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const [input, output] = process.argv.slice(2)
if (!input) {
throw new Error('usage: daemon-protocol-facts.mjs <daemon-protocol-version.ts> [output.json]')
}
const facts = parseDaemonProtocolFacts(readFileSync(input, 'utf8'), input)
if (output) {
writeFileSync(output, `${JSON.stringify(facts)}\n`)
}
console.log(JSON.stringify(facts))
}
@@ -0,0 +1,90 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
DAEMON_PROTOCOL_SOURCE_PATH,
canAttach,
crossingRequirements,
parseDaemonProtocolFacts
} from './daemon-protocol-facts.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
function source(current, previous) {
return [
`export const PROTOCOL_VERSION = ${current}`,
'export const OTHER_DAEMON_PROTOCOL_VERSION = 5',
`export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous}] as const`
].join('\n')
}
const facts = (protocolVersion, previousProtocolVersions) => ({
protocolVersion,
previousProtocolVersions
})
describe('parseDaemonProtocolFacts', () => {
it('reads the working tree declarations and keeps the append-only range', () => {
const parsed = parseDaemonProtocolFacts(
readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8')
)
expect(parsed.protocolVersion).toBeGreaterThan(1)
expect(parsed.previousProtocolVersions).toEqual(
Array.from({ length: parsed.protocolVersion - 1 }, (_, index) => index + 1)
)
})
it('parses multi-line lists and type annotations', () => {
const text = [
'// PROTOCOL_VERSION = 99 in a comment is ignored',
'export const PROTOCOL_VERSION: number = 4',
'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS: readonly number[] = [',
' 1, 2,',
' 3,',
']'
].join('\n')
expect(parseDaemonProtocolFacts(text)).toEqual(facts(4, [1, 2, 3]))
})
it.each([
['a missing PROTOCOL_VERSION', 'export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1]'],
['a missing previous list', 'export const PROTOCOL_VERSION = 2'],
['a non-literal PROTOCOL_VERSION', source('NEXT_VERSION', '1')],
['a spread in the previous list', source(3, '...LEGACY, 2')],
['a comment in the previous list', source(3, '1, // legacy\n 2')],
['an empty previous list past v1', source(3, '')],
['a previous version at or above current', source(3, '1, 2, 3')],
['a duplicate declaration', `${source(3, '1, 2')}\nexport const PROTOCOL_VERSION = 4`],
[
'a list moved behind a constant',
'export const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = LEGACY'
]
])('fails loudly on %s', (_name, text) => {
expect(() => parseDaemonProtocolFacts(text, 'fixture.ts')).toThrow(/fixture\.ts/)
})
})
describe('protocol crossing', () => {
const release = facts(37, [36, 35])
it('lets a same-version or listing candidate adopt the release daemon', () => {
expect(canAttach(facts(37, [36, 35]), release)).toBe(true)
expect(canAttach(facts(38, [37, 36, 35]), release)).toBe(true)
})
it('rejects a candidate that dropped the release version', () => {
expect(canAttach(facts(38, [36, 35]), release)).toBe(false)
})
it('cannot roll back past a protocol bump', () => {
expect(canAttach(release, facts(38, [37, 36, 35]))).toBe(false)
expect(canAttach(release, facts(36, [35]))).toBe(true)
})
it('describes the requirements in both directions', () => {
expect(crossingRequirements(release)).toMatchObject({
upgrade: 'candidate speaks 37 or lists 37 as previous',
rollback: expect.stringContaining('35..37')
})
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
describe('daemon protocol crossing gate routing', () => {
it('runs the gate when the protocol constants change', () => {
expect(classifyPrJobs(['src/main/daemon/daemon-protocol-version.ts'])).toMatchObject({
should_run: true,
'cross-version-wire': true,
package: true,
package_windows: true
})
})
it.each([
'config/scripts/daemon-protocol-facts.mjs',
'config/scripts/check-daemon-protocol-crossing.mjs',
'config/scripts/stable-release-tags.mjs'
])('runs the gate when its checker %s changes', (file) => {
expect(classifyPrJobs([file])).toMatchObject({ should_run: true, 'cross-version-wire': true })
})
})
+5
View File
@@ -144,6 +144,11 @@ function changesMobileWebApp(changedFiles) {
const CROSS_VERSION_WIRE_PREFIXES = [
'tests/e2e/cross-version-wire/',
'config/scripts/stable-release-tags',
// The R1 daemon protocol crossing gate runs in this job.
'config/scripts/daemon-protocol-facts',
'config/scripts/check-daemon-protocol-crossing',
'src/main/daemon/daemon-protocol-version.ts',
'src/shared/protocol-version',
'src/shared/terminal-stream-protocol',
'src/shared/browser-client-host-protocol',
+30
View File
@@ -0,0 +1,30 @@
// Stable desktop release tags (vX.Y.Z); shared by the cross-version harness and the R1 protocol gate.
export const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/
export function compareReleaseTags(a, b) {
const parts = (tag) =>
tag
.replace(/^v/, '')
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0))
const left = parts(a)
const right = parts(b)
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const diff = (left[index] ?? 0) - (right[index] ?? 0)
if (diff !== 0) {
return diff
}
}
return 0
}
/** @param {string[]} tags @returns {string | null} */
export function selectLatestStableReleaseTag(tags) {
return (
tags
.filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag))
.sort(compareReleaseTags)
.at(-1) ?? null
)
}
+1
View File
@@ -39,6 +39,7 @@
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"check:readme-local-links": "node config/scripts/check-readme-local-links.mjs",
"check:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs",
"check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs",
"build:orcad": "node config/scripts/build-orcad-bun.mjs",
"build:orcad-template": "node config/scripts/build-orcad-template.mjs",
@@ -7,6 +7,9 @@ import {
extractReleaseCheckoutTree,
scavengeReleaseCheckoutStaging
} from './release-checkout-tree.ts'
import { selectLatestStableReleaseTag } from '../../../config/scripts/stable-release-tags.mjs'
export { selectLatestStableReleaseTag }
export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..')
const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts')
@@ -15,7 +18,6 @@ const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-check
const CHECKOUT_FORMAT = 4
const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF'
const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/
export type ReleaseCheckout = {
/** The ref as requested, e.g. `v1.4.169`. */
@@ -86,24 +88,6 @@ function git(args: string[]): string {
}).trim()
}
function compareReleaseTags(a: string, b: string): number {
const parts = (tag: string): number[] =>
tag
.replace(/^v/, '')
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0))
const left = parts(a)
const right = parts(b)
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const diff = (left[index] ?? 0) - (right[index] ?? 0)
if (diff !== 0) {
return diff
}
}
return 0
}
/**
* The version point the harness pairs current code against. An explicit
* {@link BASELINE_REF_ENV} wins; otherwise the newest stable desktop release tag.
@@ -136,15 +120,6 @@ export function resolveBaselineReleaseRef(): string {
return latest
}
export function selectLatestStableReleaseTag(tags: string[]): string | null {
return (
tags
.filter((tag) => STABLE_DESKTOP_RELEASE_TAG.test(tag))
.sort(compareReleaseTags)
.at(-1) ?? null
)
}
function resolveCommit(ref: string): string {
try {
return git(['rev-parse', `${ref}^{commit}`])