mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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}`])
|
||||
|
||||
Reference in New Issue
Block a user