Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ZoneTree.Analytics.Dashboard/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zonetree-analytics-dashboard",
"version": "0.1.0",
"version": "1.0.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"private": true,
"type": "module",
"name": "analytics.zonetree.dev",
"version": "1.0.1",
"scripts": {
"check": "tsc --noEmit",
"build": "purestack build --content ./content --clean",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"node": ">=22.17.0"
},
"scripts": {
"bump-version": "tsx scripts/bump-version.ts",
"check-dependencies": "tsx scripts/check-dependencies.ts && yarn dedupe --check",
"check:scripts": "tsc --project scripts/tsconfig.json",
"check:workspaces": "yarn workspaces foreach --all --exclude zonetree-analytics-repo run check",
Expand Down
118 changes: 118 additions & 0 deletions scripts/bump-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'vitest'

const root = fileURLToPath(new URL('../', import.meta.url))
const script = fileURLToPath(new URL('./bump-version.ts', import.meta.url))
const loader = import.meta.resolve('tsx')

const props = (versions: string) =>
`<Project>\r\n <PropertyGroup>\r\n${versions} <IsPackable>false</IsPackable>\r\n </PropertyGroup>\r\n</Project>\r\n`

const prefix = (version: string) =>
` <VersionPrefix>${version}</VersionPrefix>\r\n`

const manifest = (name: string, version: string) =>
`{\n "name": "${name}",\n "version": "${version}",\n "private": true\n}\n`

// The files the script edits, by their path relative to the repository root.
type Files = Record<string, string>

const versioned = (version: string, props: string = propsOf(version)) => ({
'src/Directory.Build.props': props,
'apps/ZoneTree.Analytics.Dashboard/ui/package.json': manifest(
'zonetree-analytics-dashboard',
version,
),
'docs-site/package.json': manifest('analytics.zonetree.dev', version),
})

function propsOf(version: string) {
return props(prefix(version))
}

function fixture(
sources: Files,
run: (
read: () => Files,
bump: (...args: string[]) => ReturnType<typeof spawnSync>,
) => void,
) {
const temporaryRoot = path.join(root, '.tmp')
mkdirSync(temporaryRoot, { recursive: true })
const directory = mkdtempSync(path.join(temporaryRoot, 'bump-version-'))
try {
for (const [relativePath, source] of Object.entries(sources)) {
const file = path.join(directory, relativePath)
mkdirSync(path.dirname(file), { recursive: true })
writeFileSync(file, source)
}
run(
() =>
Object.fromEntries(
Object.keys(sources).map((relativePath) => [
relativePath,
readFileSync(path.join(directory, relativePath), 'utf8'),
]),
),
(...args) =>
spawnSync(process.execPath, ['--import', loader, script, ...args], {
cwd: directory,
encoding: 'utf8',
}),
)
} finally {
assert.equal(path.dirname(directory), temporaryRoot)
assert(path.basename(directory).startsWith('bump-version-'))
rmSync(directory, { recursive: true, force: true })
}
}

test('bumps every version declaration and keeps the rest of each file', () => {
fixture(versioned('1.0.0'), (read, bump) => {
const first = bump()
assert.equal(first.status, 0, String(first.stderr))
assert.deepEqual(read(), versioned('1.0.1'))
assert.match(String(first.stdout), /1\.0\.0 -> 1\.0\.1: 3 file\(s\)/)

// A patch past 99 carries into the minor version.
const carried = bump('100')
assert.equal(carried.status, 0, String(carried.stderr))
assert.deepEqual(read(), versioned('1.1.1'))
})
})

test('changes nothing for an ambiguous or mismatched version or an invalid delta', () => {
const mismatched = {
...versioned('1.0.0'),
'docs-site/package.json': manifest('analytics.zonetree.dev', '0.1.0'),
}
for (const sources of [
versioned('1.0.0', props(prefix('1.0.0') + prefix('1.0.0'))),
mismatched,
]) {
fixture(sources, (read, bump) => {
const result = bump()
assert.notEqual(result.status, 0)
assert.match(String(result.stderr), /No files have been changed/)
assert.deepEqual(read(), sources)
})
}
const single = versioned('1.0.0')
fixture(single, (read, bump) => {
for (const delta of ['0', '-1', 'two']) {
const result = bump(delta)
assert.notEqual(result.status, 0, delta)
assert.deepEqual(read(), single, delta)
}
})
})
85 changes: 85 additions & 0 deletions scripts/bump-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { bumpVersion, versionPattern } from './version'

// Run from the repository root: yarn bump-version [delta]
const projectRoot = process.cwd()
if (process.argv.length > 3) {
throw new Error('Usage: yarn bump-version [delta] (default: 1)')
}
const delta = process.argv[2] ?? '1'

// Both NuGet packages share VersionPrefix; package and assembly versions derive from it.
const versionSource = 'src/Directory.Build.props'
const versionDeclaration = /(<VersionPrefix>)([^<\r\n]*)(<\/VersionPrefix>)/g

// The private workspaces are not published, but carry the product version too.
const manifests = [
'apps/ZoneTree.Analytics.Dashboard/ui/package.json',
'docs-site/package.json',
]
const manifestVersion = /^(\s*"version"\s*:\s*")([^"\r\n]+)(")/gm

// Every file that declares the product version, with the pattern of its declaration.
const targets: { relativePath: string; pattern: RegExp }[] = [
{ relativePath: versionSource, pattern: versionDeclaration },
...manifests.map((relativePath) => ({
relativePath,
pattern: manifestVersion,
})),
]

function read(relativePath: string) {
return readFileSync(path.join(projectRoot, relativePath), 'utf8')
}

function declarations(relativePath: string, source: string, pattern: RegExp) {
const matches = [...source.matchAll(pattern)]
if (matches.length !== 1) {
throw new Error(
`Expected exactly one version declaration in ${relativePath}. No files have been changed.`,
)
}
return matches[0]
}

const currentVersion = declarations(
versionSource,
read(versionSource),
versionDeclaration,
)[2]
if (!versionPattern.test(currentVersion)) {
throw new Error(
`Invalid version ${currentVersion} in ${versionSource}. No files have been changed.`,
)
}
const nextVersion = bumpVersion(currentVersion, delta)

type Update = { relativePath: string; source: string }
const updates: Update[] = []
for (const { relativePath, pattern } of targets) {
const source = read(relativePath)
const [, , version] = declarations(relativePath, source, pattern)
if (version !== currentVersion) {
throw new Error(
`${relativePath} declares ${version}, but ${versionSource} declares ${currentVersion}. No files have been changed.`,
)
}
const updated = source.replace(
pattern,
(_match, prefix: string, _version: string, suffix: string) =>
`${prefix}${nextVersion}${suffix}`,
)
if (updated !== source) updates.push({ relativePath, source: updated })
}

// Read and validate every target before writing; keep formatting and line endings.
for (const update of updates) {
writeFileSync(
path.join(projectRoot, update.relativePath),
update.source,
'utf8',
)
console.log(`Updated ${update.relativePath}`)
}
console.log(`${currentVersion} -> ${nextVersion}: ${updates.length} file(s)`)
23 changes: 23 additions & 0 deletions scripts/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d?)$/

export function bumpVersion(version: string, delta = '1') {
const increment = Number(delta)
if (!/^\+?[1-9]\d*$/.test(delta) || !Number.isSafeInteger(increment)) {
throw new Error(`Expected a positive integer delta, received ${delta}.`)
}

const current = versionPattern.exec(version)
if (!current) {
throw new Error(
`Expected a major.minor.patch version with patch 0–99, received ${version}.`,
)
}

const [, major, minor, patch] = current
const totalPatch = Number(patch) + increment
const nextMinor = Number(minor) + Math.floor(totalPatch / 100)
if (!Number.isSafeInteger(totalPatch) || !Number.isSafeInteger(nextMinor)) {
throw new Error('The resulting version exceeds the safe integer range.')
}
return `${major}.${nextMinor}.${totalPatch % 100}`
}
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
matching ZoneTree.Analytics. Pass VersionSuffix (for example preview.1) to pack a pre-release.
-->
<PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix>
<VersionPrefix>1.0.1</VersionPrefix>
<IsPackable>false</IsPackable>
<Authors>Ahmed Yasin Koculu</Authors>
<Company>ZoneTree</Company>
Expand Down
Loading
Loading