Merge pull request #7384 from deepseek-harness/feat/pre-merge-check

feat(ci): run one script for the merged tree in the pre-merge check
This commit is contained in:
imccyu authored and GitHub committed 2026-10-27 01:44:02 +08:00
commit 9c69007eca
7 files changed
+588 -10

No files matched your search

+1 -1
View File
@@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+15 -4
View File
@@ -93,15 +93,26 @@ jobs:
node-version: '24'
package-manager-cache: false
- name: Run the pre-merge command
- name: Run the tree's pre-merge check
if: steps.merge.outputs.merged == 'true'
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
run: |
if ! bash -euxo pipefail -c 'pnpm install --frozen-lockfile && pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts && pnpm exec tsx scripts/compile-referenced-projects.ts client'; then
# The check names one file and knows nothing else about how the merged tree builds itself.
if [ ! -f scripts/pre-merge-check.ts ]; then
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::the merged tree does not install or compile"
echo "::error title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 1
fi
echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block"
echo "::warning title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 0
fi
if node scripts/pre-merge-check.ts; then
exit 0
fi
# A check that crashes reports nothing, so the step reports its exit the way the mode asks.
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
exit 1
fi
echo "::warning title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
+28 -5
View File
@@ -1,3 +1,4 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { runInNewContext } from 'node:vm'
@@ -1687,6 +1688,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
describe('Policy workflow shells', () => {
it.each(['pr-check-on-head-branch.yml', 'pr-check-after-approve.yml', 'pr-check-on-base-branch.yml'])(
'%s runs shell blocks that parse',
(file) => {
if (process.platform === 'win32') return
const workflow = loadWorkflow('.github/workflows/' + file)
if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
for (const job of Object.values(workflow.jobs)) {
if (!isRecord(job) || !Array.isArray(job.steps)) continue
for (const step of job.steps) {
if (!isRecord(step) || typeof step.run !== 'string') continue
const parsed = spawnSync('bash', ['-n'], { input: step.run, encoding: 'utf8' })
expect(`${String(step.name ?? step.id)}: ${parsed.status}: ${parsed.stderr}`).toBe(`${String(step.name ?? step.id)}: 0: `)
}
}
},
)
})
describe('Policy workflows', () => {
const workflows = [
['pr-check-on-head-branch.yml', ['master']],
@@ -1716,7 +1736,7 @@ describe('Policy workflows', () => {
it('keeps the pre-merge build behind the merge step, which honours the mode on its failures', () => {
const steps = stepsOf(workflowJob(loadWorkflow('.github/workflows/pr-check-on-head-branch.yml'), 'pre-merge'))
const merge = steps.findIndex(step => step.id === 'merge')
const build = steps.findIndex(step => step.name === 'Run the pre-merge command')
const build = steps.findIndex(step => step.name === "Run the tree's pre-merge check")
expect(merge).toBeGreaterThanOrEqual(0)
expect(build).toBeGreaterThan(merge)
const merging = steps[merge]
@@ -1726,9 +1746,12 @@ describe('Policy workflows', () => {
if (typeof step.run !== 'string') continue
expect(step.run).toContain('"$CHECK_MODE" = block')
}
for (const step of steps.slice(merge + 1)) {
if (step.name === 'Run the pre-merge command') continue
expect(step.if).toBe("steps.merge.outputs.merged == 'true'")
}
for (const step of steps.slice(merge + 1)) expect(step.if).toBe("steps.merge.outputs.merged == 'true'")
const run = steps[build]?.run
if (typeof run !== 'string') throw new TypeError('the pre-merge check step must run a command')
expect(run).toContain('node scripts/pre-merge-check.ts')
// The script annotates its own failures; the step keeps a mode-aware guard for a check that crashes.
expect(run).toContain('exited without reporting')
expect(run).toContain('"$CHECK_MODE" = block')
})
})
+70
View File
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { checkMode, failureDecision, preMergeCheck, preMergeSteps, type PreMergeStep } from './pre-merge-check.ts'
/**
* Steps that exit with the given codes, so the check runs without pnpm.
* @param codes - Exit code of each step, in order.
* @returns The steps.
*/
function steps(codes: readonly number[]): PreMergeStep[] {
return codes.map((code, index) => ({ name: `step-${index}`, command: process.execPath, args: ['-e', `process.exit(${code})`] }))
}
describe('pre-merge check', () => {
it('runs every step in order and passes when they all succeed', async () => {
const lines: string[] = []
const outcome = await preMergeCheck(process.cwd(), { steps: steps([0, 0, 0]), report: line => void lines.push(line) })
expect(outcome).toEqual({ ok: true })
expect(lines).toEqual(['pre-merge-check: running step-0', 'pre-merge-check: running step-1', 'pre-merge-check: running step-2'])
})
it('stops at the first step that fails', async () => {
const lines: string[] = []
const outcome = await preMergeCheck(process.cwd(), { steps: steps([1, 0]), report: line => void lines.push(line) })
expect(outcome).toEqual({ ok: false, reason: 'the step-0 step failed' })
expect(lines).not.toContain('pre-merge-check: running step-1')
})
it('reports a command that cannot start', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-pre-merge-'))
try {
const outcome = await preMergeCheck(root, {
steps: [{ name: 'missing', command: join(root, 'no-such-command'), args: [] }],
report: () => {},
})
expect(outcome.ok).toBe(false)
expect(outcome.reason).toContain('cannot run')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('checks the versions before it installs, and installs before the builds', () => {
const steps = preMergeSteps('/repo')
expect(steps.map(step => step.name)).toEqual(['versions', 'install', 'host contracts', 'client'])
expect(steps[0]).toEqual({ name: 'versions', command: process.execPath, args: ['/repo/scripts/verify-version-sync.ts', '/repo'] })
expect(steps[1]).toEqual({ name: 'install', command: 'pnpm', args: ['install', '--frozen-lockfile'] })
expect(steps[3]?.args).toEqual(['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'client'])
})
it('annotates a failure as a warning and passes in report mode', () => {
expect(checkMode('report')).toBe('report')
expect(checkMode('block')).toBe('block')
expect(checkMode('off')).toBe('off')
expect(checkMode('nonsense')).toBe('report')
expect(checkMode(undefined)).toBe('report')
expect(checkMode('')).toBe('report')
expect(failureDecision('report', 'the install step failed')).toEqual({
line: '::warning title=Pre-Merge Check::the merged tree fails its pre-merge check: the install step failed',
exitCode: 0,
})
expect(failureDecision('block', 'the install step failed')).toEqual({
line: '::error title=Pre-Merge Check::the merged tree fails its pre-merge check: the install step failed',
exitCode: 1,
})
expect(failureDecision('off', 'the client step failed').exitCode).toBe(0)
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* Validate the tree a pull request produces once merged with its base branch: the version
* synchronization of the released manifests, the install, and the Host and Client builds.
*
* The `Policy / Pre-Merge Check` job runs this file with `node`, and knows nothing else about the
* merged tree, so what that tree needs changes here without touching the check. The file runs under
* plain Node and spawns its steps itself, so no dependency is installed before it starts.
*/
import { spawn } from 'node:child_process'
import { join, resolve } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
/** One command of the check. */
export interface PreMergeStep {
/** Short name for the log. */
readonly name: string
readonly command: string
readonly args: readonly string[]
}
/**
* The steps that check the merged tree, in order. The versions come first because reading the
* manifests needs no install, so a mistyped release stops the check before anything is built.
* @param root - Repository root.
* @returns Steps in run order.
*/
export function preMergeSteps(root: string): PreMergeStep[] {
return [
{ name: 'versions', command: process.execPath, args: [join(root, 'scripts', 'verify-version-sync.ts'), root] },
{ name: 'install', command: 'pnpm', args: ['install', '--frozen-lockfile'] },
{ name: 'host contracts', command: 'pnpm', args: ['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'host-contracts'] },
{ name: 'client', command: 'pnpm', args: ['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'client'] },
]
}
/** How the job that runs this file reacts to a failure. */
export type CheckMode = 'off' | 'report' | 'block'
/** What a failure prints and the exit code it leaves. */
export interface FailureDecision {
readonly line: string
readonly exitCode: number
}
/**
* Read the job's mode. The check owns its own mode handling, so the workflow only calls it.
* @param value - The `CHECK_MODE` value, when the environment provides one.
* @returns The mode; a missing or unknown value reports instead of blocking.
*/
export function checkMode(value: string | undefined): CheckMode {
return value === 'off' || value === 'block' ? value : 'report'
}
/**
* Decide how a failed tree is reported, so `report` annotates and passes while `block` fails the job.
* @param mode - Mode of the job.
* @param reason - Why the tree failed.
* @returns The annotation and exit code for that mode.
*/
export function failureDecision(mode: CheckMode, reason: string): FailureDecision {
const summary = `the merged tree fails its pre-merge check: ${reason}`
if (mode === 'block') return { line: `::error title=Pre-Merge Check::${summary}`, exitCode: 1 }
if (mode === 'report') return { line: `::warning title=Pre-Merge Check::${summary}`, exitCode: 0 }
return { line: `pre-merge-check: ${summary}`, exitCode: 0 }
}
/** Options of the check. */
export interface PreMergeOptions {
/** Sink for the check's own lines. */
readonly report?: (line: string) => void
/** Steps to run; the repository's own steps when absent. */
readonly steps?: readonly PreMergeStep[]
}
/** What the check decided. */
export interface PreMergeOutcome {
readonly ok: boolean
/** Why the tree failed, when it did. */
readonly reason?: string
}
/**
* Run one step with the caller's terminal, so its output reaches the job log as it arrives.
* @param step - Step to run.
* @param root - Working directory.
* @returns Why the step failed, or `undefined` when it succeeded.
*/
function run(step: PreMergeStep, root: string): Promise<string | undefined> {
return new Promise((settle) => {
const child = spawn(step.command, [...step.args], { cwd: root, stdio: 'inherit' })
child.once('error', (error: Error) => {
settle(`cannot run ${step.command}: ${error.message}`)
})
child.once('close', (code) => {
settle(code === 0 ? undefined : `the ${step.name} step failed`)
})
})
}
/**
* Run every step of the merged tree in order, stopping at the first failure.
* @param root - Repository root.
* @param options - Sink for the check's own lines, and the steps to run.
* @returns Whether the tree passed, with the reason when it did not.
*/
export async function preMergeCheck(root: string, options: PreMergeOptions = {}): Promise<PreMergeOutcome> {
const report = options.report ?? ((line: string): void => {
process.stdout.write(`${line}\n`)
})
for (const step of options.steps ?? preMergeSteps(root)) {
report(`pre-merge-check: running ${step.name}`)
const failure = await run(step, root)
if (failure !== undefined) return { ok: false, reason: failure }
}
return { ok: true }
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const outcome = await preMergeCheck(resolve(import.meta.dirname, '..'))
if (outcome.ok) {
process.stdout.write('pre-merge-check: passed\n')
} else {
const decision = failureDecision(checkMode(process.env.CHECK_MODE), outcome.reason ?? 'unknown reason')
;(decision.exitCode === 0 ? process.stdout : process.stderr).write(`${decision.line}\n`)
process.exitCode = decision.exitCode
}
}
+133
View File
@@ -0,0 +1,133 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describeVersions, mismatchedVersions, readVersions, releasedManifests, runVersionCheck, versionsSynchronized } from './verify-version-sync.ts'
/** Path of the command the pre-merge check spawns. */
const script = join(import.meta.dirname, 'verify-version-sync.ts')
/**
* Build a repository holding the given manifests.
* @param files - Manifest path to version, or to `null` for a manifest without one.
* @returns The temporary root.
*/
function repository(files: Record<string, string | null>): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-version-sync-'))
for (const [path, version] of Object.entries(files)) {
const absolute = join(root, path)
mkdirSync(dirname(absolute), { recursive: true })
writeFileSync(absolute, JSON.stringify(version === null ? { name: 'x' } : { name: 'x', version }))
}
return root
}
describe('released manifests', () => {
it('collects the package groups and the applications, and nothing deeper or beside them', () => {
const root = repository({
'package.json': '1.0.0',
'packages/core/session/package.json': '1.0.0',
'packages/core/package.json': null,
'packages/README.md': null,
'apps/cli/package.json': '1.0.0',
'apps/cli/nested/package.json': '1.0.0',
'vendor/cordis/package.json': '4.0.5',
'native/system/package.json': '0.1.2',
})
try {
expect(releasedManifests(root)).toEqual(['apps/cli/package.json', 'packages/core/session/package.json'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails instead of reporting a narrowed corpus', () => {
const root = repository({ 'package.json': '1.0.0', 'packages/README.md': null, 'apps/README.md': null })
try {
expect(() => releasedManifests(root)).toThrow(/no released manifest/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('fails when a directory cannot be read', () => {
const root = repository({ 'package.json': '1.0.0', 'packages/core/session/package.json': '1.0.0' })
try {
rmSync(join(root, 'apps'), { recursive: true, force: true })
expect(() => releasedManifests(root)).toThrow(/cannot read apps\//)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('version synchronization', () => {
it('accepts a tree whose released manifests all carry the root version', () => {
const root = repository({ 'package.json': '3.0.0', 'packages/core/session/package.json': '3.0.0', 'apps/web/package.json': '3.0.0' })
try {
const report = readVersions(root)
expect(mismatchedVersions(report.rootVersion ?? '', report.manifests)).toEqual([])
expect(versionsSynchronized(report)).toBe(true)
expect(describeVersions(report)).toEqual(['2 released manifest(s) carry 3.0.0'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('rejects a manifest on another version or without one', () => {
const root = repository({
'package.json': '2.0.0',
'packages/core/session/package.json': '2.0.0',
'packages/core/agent/package.json': '1.0.0',
'apps/cli/package.json': null,
})
try {
const report = readVersions(root)
expect(versionsSynchronized(report)).toBe(false)
expect(describeVersions(report)).toEqual([
'2 of 3 released manifest(s) do not carry the root version 2.0.0:',
' apps/cli/package.json: no version',
' packages/core/agent/package.json: 1.0.0',
])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('rejects an empty corpus and a missing root version', () => {
expect(versionsSynchronized({ rootVersion: '1.0.0', manifests: [] })).toBe(false)
expect(versionsSynchronized({ rootVersion: undefined, manifests: [{ path: 'apps/cli/package.json', version: '1.0.0' }] })).toBe(false)
expect(versionsSynchronized({ rootVersion: undefined, manifests: [] })).toBe(false)
})
})
describe('verify-version-sync command', () => {
it('exits 0 on a synchronized tree and 1 with the mismatch named', () => {
const good = repository({ 'package.json': '1.0.0', 'packages/core/session/package.json': '1.0.0', 'apps/web/package.json': '1.0.0' })
const bad = repository({ 'package.json': '2.0.0', 'packages/core/session/package.json': '1.0.0', 'apps/web/package.json': '2.0.0' })
try {
expect(runVersionCheck(good).ok).toBe(true)
const passed = spawnSync(process.execPath, [script, good], { encoding: 'utf8' })
expect(passed.status).toBe(0)
expect(passed.stdout).toContain('2 released manifest(s) carry 1.0.0')
const failed = spawnSync(process.execPath, [script, bad], { encoding: 'utf8' })
expect(failed.status).toBe(1)
expect(failed.stderr).toContain('packages/core/session/package.json: 1.0.0')
} finally {
rmSync(good, { recursive: true, force: true })
rmSync(bad, { recursive: true, force: true })
}
})
it('exits 1 when the tree cannot be read', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-version-sync-empty-'))
try {
const result = spawnSync(process.execPath, [script, root], { encoding: 'utf8' })
expect(result.status).toBe(1)
expect(result.stderr).toContain('verify-version-sync:')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
+210
View File
@@ -0,0 +1,210 @@
/**
* Check that every released manifest carries the root version. A release synchronization bumps
* them together, so a mismatch means a merge combined two version lines: a pull request that bumps
* versions merged onto a master that added a package, for instance.
*
* The released manifests are the ones the release tooling rewrites, `packages/<group>/<package>`
* and `apps/<application>`. Reading them needs no installed dependency, so the check runs before an
* install.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
/** One manifest's version, with `undefined` when the file does not declare one. */
export interface ManifestVersion {
/** Repository-relative path of the manifest. */
readonly path: string
readonly version: string | undefined
}
/** Whether a tree is synchronized, with the lines describing it. */
export interface VersionOutcome {
readonly ok: boolean
readonly lines: string[]
}
/** The root version and every released manifest's version. */
export interface VersionReport {
readonly rootVersion: string | undefined
readonly manifests: ManifestVersion[]
}
/** Where released manifests live: the package groups and the applications. */
const PACKAGE_ROOT = 'packages'
const APPLICATION_ROOT = 'apps'
/**
* Report whether an error only means the path is not there.
* @param error - Caught value.
* @returns Whether the entry is absent, as a missing file or a parent that is one.
*/
function isMissing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code
return code === 'ENOENT' || code === 'ENOTDIR'
}
/**
* Describe an error for a reader.
* @param error - Caught value.
* @returns Its message.
*/
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Test whether a path is a regular file, treating only "no such file" as a no.
* @param path - Absolute path.
* @returns Whether the file exists.
*/
function isFile(path: string): boolean {
try {
return statSync(path).isFile()
} catch (error) {
if (isMissing(error)) return false
throw new Error(`cannot read ${path}: ${messageOf(error)}`)
}
}
/**
* Test whether a path is a directory.
* @param path - Absolute path.
* @returns Whether the entry is a directory.
*/
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch (error) {
if (isMissing(error)) return false
throw new Error(`cannot read ${path}: ${messageOf(error)}`)
}
}
/**
* List the entries of a directory, naming the directory when it cannot be read.
* @param path - Absolute directory path.
* @param label - Repository-relative label for the message.
* @returns Entry names.
*/
function entries(path: string, label: string): string[] {
try {
return readdirSync(path)
} catch (error) {
throw new Error(`cannot read ${label}: ${messageOf(error)}`)
}
}
/**
* Read the version one `package.json` declares.
* @param path - Absolute path of the manifest.
* @returns The declared version, or `undefined` when it declares none.
*/
function versionOf(path: string): string | undefined {
try {
const manifest: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (typeof manifest !== 'object' || manifest === null) return undefined
const version: unknown = (manifest as { version?: unknown }).version
return typeof version === 'string' ? version : undefined
} catch (error) {
throw new Error(`cannot read ${path}: ${messageOf(error)}`)
}
}
/**
* Collect the manifests the release tooling rewrites: `packages/<group>/<package>/package.json`
* and `apps/<application>/package.json`. A directory that cannot be read, and a walk that finds
* nothing, are errors rather than a clean tree, so a narrowed corpus cannot pass the check.
* @param root - Repository root.
* @returns Manifest paths relative to the root, sorted.
*/
export function releasedManifests(root: string): string[] {
const paths: string[] = []
for (const group of entries(join(root, PACKAGE_ROOT), `${PACKAGE_ROOT}/`)) {
if (!isDirectory(join(root, PACKAGE_ROOT, group))) continue
for (const entry of entries(join(root, PACKAGE_ROOT, group), `${PACKAGE_ROOT}/${group}/`)) {
const path = `${PACKAGE_ROOT}/${group}/${entry}/package.json`
if (isFile(join(root, path))) paths.push(path)
}
}
for (const application of entries(join(root, APPLICATION_ROOT), `${APPLICATION_ROOT}/`)) {
const path = `${APPLICATION_ROOT}/${application}/package.json`
if (isFile(join(root, path))) paths.push(path)
}
if (paths.length === 0) throw new Error(`no released manifest was found under ${PACKAGE_ROOT}/ or ${APPLICATION_ROOT}/`)
return paths.sort()
}
/**
* Compare every released manifest with the root version.
* @param rootVersion - Version of the root `package.json`.
* @param manifests - Each manifest's path and version.
* @returns The manifests that carry another version or none, in path order.
*/
export function mismatchedVersions(rootVersion: string, manifests: readonly ManifestVersion[]): ManifestVersion[] {
return manifests.filter(manifest => manifest.version !== rootVersion).sort((left, right) => left.path.localeCompare(right.path))
}
/**
* Read the root version and the released manifests of a repository.
* @param root - Repository root.
* @returns Root version and manifests.
*/
export function readVersions(root: string): VersionReport {
const rootVersion = versionOf(join(root, 'package.json'))
const manifests = releasedManifests(root).map(path => ({ path, version: versionOf(join(root, path)) }))
return { rootVersion, manifests }
}
/**
* Report whether every released manifest carries the root version. A tree without a root version,
* or with no released manifest at all, is not synchronized.
* @param report - Root version and manifests.
* @returns Whether the tree is synchronized.
*/
export function versionsSynchronized(report: VersionReport): boolean {
const { rootVersion, manifests } = report
return rootVersion !== undefined && manifests.length > 0 && mismatchedVersions(rootVersion, manifests).length === 0
}
/**
* Describe a version report for a reader.
* @param report - Root version and manifests.
* @returns One line when the tree is consistent, otherwise a header and one line per mismatched manifest.
*/
export function describeVersions(report: VersionReport): string[] {
const { rootVersion, manifests } = report
if (rootVersion === undefined) return ['the root package.json declares no version']
const mismatched = mismatchedVersions(rootVersion, manifests)
if (mismatched.length === 0) return [`${manifests.length} released manifest(s) carry ${rootVersion}`]
return [
`${mismatched.length} of ${manifests.length} released manifest(s) do not carry the root version ${rootVersion}:`,
...mismatched.map(manifest => ` ${manifest.path}: ${manifest.version ?? 'no version'}`),
]
}
/**
* Run the check over one repository.
* @param root - Repository root.
* @returns Whether the tree is synchronized, with the lines describing it.
*/
export function runVersionCheck(root: string): VersionOutcome {
const report = readVersions(root)
return { ok: versionsSynchronized(report), lines: describeVersions(report) }
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const root = process.argv[2] ?? resolve(import.meta.dirname, '..')
try {
const { ok, lines } = runVersionCheck(resolve(root))
for (const line of lines) (ok ? console.log : console.error)(`verify-version-sync: ${line}`)
if (!ok) process.exitCode = 1
} catch (error) {
console.error(`verify-version-sync: ${messageOf(error)}`)
process.exitCode = 1
}
}