diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c760166..170cad5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,17 @@ jobs: decide node "$NODE" decide java "$JAVA" + versions: + name: Version consistency + runs-on: ubuntu-latest + # Unfiltered: the version spans every suite, and the check costs seconds. + steps: + - name: Check out repository + uses: actions/checkout@v7.0.1 + + - name: Every manifest agrees on one version + run: node scripts/version.mjs --check + rust: name: Rust needs: changes @@ -124,7 +135,7 @@ jobs: ci-status: name: CI status if: always() - needs: [changes, rust, python, node, java] + needs: [changes, versions, rust, python, node, java] runs-on: ubuntu-latest steps: - name: Check that no required job failed diff --git a/.github/workflows/publish-java.yml b/.github/workflows/publish-java.yml index 3b840df4..02cc7bec 100644 --- a/.github/workflows/publish-java.yml +++ b/.github/workflows/publish-java.yml @@ -53,9 +53,10 @@ jobs: echo "::error::Invalid version format: ${version}" exit 1 fi - gradle_ver=$(grep -oP '^version = "\K[^"]+' sdks/java/build.gradle.kts) - if [ "$gradle_ver" != "$version" ]; then - echo "::error::Resolved ${version} != sdks/java/build.gradle.kts ${gradle_ver}" + node scripts/version.mjs --check + repo_version="$(node scripts/version.mjs --current)" + if [ "$repo_version" != "$version" ]; then + echo "::error::Resolved ${version} but the repo declares ${repo_version}" exit 1 fi printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish-node.yml b/.github/workflows/publish-node.yml index 7ddef88e..50f30e1d 100644 --- a/.github/workflows/publish-node.yml +++ b/.github/workflows/publish-node.yml @@ -60,12 +60,14 @@ jobs: fi printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - - name: Verify version matches package.json + - name: Verify version matches codebase + env: + VERSION: ${{ steps.resolve.outputs.version }} run: | - version="${{ steps.resolve.outputs.version }}" - pkg_ver=$(node -p "require('./sdks/node/package.json').version") - if [ "$pkg_ver" != "$version" ]; then - echo "::error::Resolved version ${version} != sdks/node/package.json ${pkg_ver}" + node scripts/version.mjs --check + repo_version="$(node scripts/version.mjs --current)" + if [ "$repo_version" != "$VERSION" ]; then + echo "::error::Resolved ${VERSION} but the repo declares ${repo_version}" exit 1 fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f8fd4428..77b858c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,15 +47,13 @@ jobs: printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - name: Verify version matches codebase + env: + VERSION: ${{ steps.resolve.outputs.version }} run: | - version="${{ steps.resolve.outputs.version }}" - pyproject_ver=$(python3 -c " - import re, pathlib - m = re.search(r'^version\s*=\s*\"(.+?)\"', pathlib.Path('sdks/python/pyproject.toml').read_text(), re.M) - print(m.group(1)) - ") - if [ "$pyproject_ver" != "$version" ]; then - echo "::error::Input version ${version} != pyproject.toml version ${pyproject_ver}" + node scripts/version.mjs --check + repo_version="$(node scripts/version.mjs --current)" + if [ "$repo_version" != "$VERSION" ]; then + echo "::error::Requested ${VERSION} but the repo declares ${repo_version}" exit 1 fi diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs index 86a45420..2a3f7641 100644 --- a/scripts/sync-changelog.mjs +++ b/scripts/sync-changelog.mjs @@ -9,6 +9,17 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`Regenerate the docs changelog page and version constant from CHANGELOG.md. + +usage: node scripts/sync-changelog.mjs + +Takes no options. Writes docs/content/docs/resources/changelog.mdx and +docs/app/lib/version.ts; both are generated — edit CHANGELOG.md instead. +Runs automatically before \`docs\` dev/build.`); + process.exit(0); +} + const repoRoot = new URL("../", import.meta.url); const source = fileURLToPath(new URL("CHANGELOG.md", repoRoot)); const target = fileURLToPath( diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 00000000..66afe789 --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// One version for the whole repo: `[workspace.package].version` in the root +// Cargo.toml. Everything else either derives it natively — the seven crates via +// `version.workspace`, the Python wheel via maturin, the Gradle subprojects via +// gradle.properties — or is mirrored here. +// +// node scripts/version.mjs --check verify nothing has drifted (CI gate) +// node scripts/version.mjs --current print the version the repo declares +// node scripts/version.mjs --set 0.21.0 bump the source and every mirror +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const repoRoot = new URL("../", import.meta.url); +const abs = (relative) => fileURLToPath(new URL(relative, repoRoot)); +const read = (relative) => readFileSync(abs(relative), "utf8"); + +const SEMVER = /^\d+\.\d+\.\d+[\w.-]*$/; + +// The canonical declaration. Every mirror below is rewritten to match it. +const SOURCE = { + file: "Cargo.toml", + pattern: /^version = "(.+?)"$/m, + label: "workspace.package", +}; + +// Manifests with no way to reference the Cargo version natively. +const MIRRORS = [ + { + file: "sdks/node/package.json", + pattern: /^( "version": ")(.+?)(",)$/m, + label: "npm package", + }, + { + file: "sdks/java/gradle.properties", + pattern: /^(version=)(.+)()$/m, + label: "Gradle projects", + }, + { + file: "sdks/python/taskito/__init__.py", + pattern: /^( __version__ = ")(.+?)(")$/m, + label: "Python source-tree fallback", + }, +]; + +// Checked, never written: release notes are authored by hand, but shipping a +// version with no section of its own is a mistake worth failing CI over. +const CHANGELOG = { + file: "CHANGELOG.md", + pattern: /^## (\d+\.\d+\.\d+[\w.-]*)$/m, + label: "latest CHANGELOG section", +}; + +// Files that must keep deriving the version instead of restating it — a +// hardcoded literal here would silently win over the source. +function guards() { + const crates = readdirSync(abs("crates")).map((crate) => ({ + file: `crates/${crate}/Cargo.toml`, + pattern: /^version = "/m, + hint: "use `version.workspace = true`", + })); + return [ + ...crates, + { + file: "sdks/python/pyproject.toml", + pattern: /^version = "/m, + hint: 'use `dynamic = ["version"]` so maturin reads Cargo.toml', + }, + ...["", "spring/", "processor/", "test-support/", "graalvm-smoke/"].map( + (project) => ({ + file: `sdks/java/${project}build.gradle.kts`, + pattern: /^version = "/m, + hint: "set `version` in sdks/java/gradle.properties", + }), + ), + ]; +} + +// Reads the single capture the pattern is expected to find, or explains where +// the file stopped matching — a silent miss would let drift through the gate. +function extract({ file, pattern, label }, group = 1) { + const found = read(file).match(pattern); + if (!found) { + throw new Error(`${file}: no ${label} version found (pattern drifted?)`); + } + return found[group]; +} + +function sourceVersion() { + const version = extract(SOURCE); + if (!SEMVER.test(version)) { + throw new Error(`${SOURCE.file}: "${version}" is not a semantic version`); + } + return version; +} + +function check() { + const expected = sourceVersion(); + const problems = []; + + for (const mirror of [...MIRRORS, CHANGELOG]) { + const actual = extract(mirror, mirror === CHANGELOG ? 1 : 2); + const status = actual === expected ? "ok" : `MISMATCH (${actual})`; + console.log(` ${actual === expected ? "✓" : "✗"} ${mirror.file} — ${status}`); + if (actual !== expected) { + problems.push(`${mirror.file} declares ${actual}, expected ${expected}`); + } + } + + for (const { file, pattern, hint } of guards()) { + if (pattern.test(read(file))) { + problems.push(`${file} hardcodes a version — ${hint}`); + } + } + + if (problems.length > 0) { + console.error(`\nVersion drift (source of truth: ${expected}):`); + for (const problem of problems) console.error(` - ${problem}`); + console.error("\nRun `node scripts/version.mjs --set ` to resync."); + process.exit(1); + } + console.log(`\nAll manifests agree on ${expected}.`); +} + +function set(next) { + if (!SEMVER.test(next)) { + throw new Error(`"${next}" is not a semantic version`); + } + const previous = sourceVersion(); + + writeFileSync( + abs(SOURCE.file), + read(SOURCE.file).replace(SOURCE.pattern, `version = "${next}"`), + ); + for (const { file, pattern } of MIRRORS) { + writeFileSync( + abs(file), + read(file).replace(pattern, (_, before, __, after) => `${before}${next}${after}`), + ); + } + + console.log(`${previous} -> ${next}`); + console.log(` ${[SOURCE.file, ...MIRRORS.map((m) => m.file)].join("\n ")}`); + console.log(`\nAdd a \`## ${next}\` section to CHANGELOG.md to complete the bump.`); +} + +const USAGE = `Keep one version across the repo, sourced from ${SOURCE.file}. + +usage: node scripts/version.mjs + + --check verify every manifest agrees; exits 1 on drift (CI gate) + --current print the version the repo declares + --set rewrite the source and every mirror + --help show this message + +Mirrors rewritten by --set: + ${MIRRORS.map((mirror) => `${mirror.file} (${mirror.label})`).join("\n ")} + +${CHANGELOG.file} is checked but never written — add its \`## \` section by hand.`; + +const [command, argument] = process.argv.slice(2); +try { + if (command === "--check") check(); + else if (command === "--current") console.log(sourceVersion()); + else if (command === "--set" && argument) set(argument); + else if (command === "--help" || command === "-h") console.log(USAGE); + else { + console.error(USAGE); + process.exit(2); + } +} catch (error) { + console.error(`version: ${error.message}`); + process.exit(1); +} diff --git a/sdks/java/build.gradle.kts b/sdks/java/build.gradle.kts index 3832d48a..11dccecd 100644 --- a/sdks/java/build.gradle.kts +++ b/sdks/java/build.gradle.kts @@ -6,9 +6,6 @@ plugins { id("com.vanniktech.maven.publish") version "0.37.0" } -group = "org.byteveda" -version = "0.20.0" - java { // Sources + javadoc jars are added by the maven-publish plugin below. // Compile to Java 17 bytecode with whatever JDK (>= 17) runs Gradle, rather diff --git a/sdks/java/graalvm-smoke/build.gradle.kts b/sdks/java/graalvm-smoke/build.gradle.kts index 957221ec..b6ad1fcd 100644 --- a/sdks/java/graalvm-smoke/build.gradle.kts +++ b/sdks/java/graalvm-smoke/build.gradle.kts @@ -8,9 +8,6 @@ plugins { id("org.graalvm.buildtools.native") version "0.10.6" } -group = "org.byteveda" -version = "0.20.0" - repositories { mavenCentral() } diff --git a/sdks/java/gradle.properties b/sdks/java/gradle.properties new file mode 100644 index 00000000..63fef1b5 --- /dev/null +++ b/sdks/java/gradle.properties @@ -0,0 +1,5 @@ +# Coordinates for every project in the build — Gradle applies these to the root +# project and each subproject. `version` mirrors the root Cargo.toml and is +# rewritten by scripts/version.mjs; do not hand-edit it. +group=org.byteveda +version=0.20.0 diff --git a/sdks/java/processor/build.gradle.kts b/sdks/java/processor/build.gradle.kts index f3b997ec..9afce784 100644 --- a/sdks/java/processor/build.gradle.kts +++ b/sdks/java/processor/build.gradle.kts @@ -8,9 +8,6 @@ plugins { id("com.vanniktech.maven.publish") version "0.37.0" } -group = "org.byteveda" -version = "0.20.0" - mavenPublishing { publishToMavenCentral() signAllPublications() diff --git a/sdks/java/spring/build.gradle.kts b/sdks/java/spring/build.gradle.kts index e5b42e60..240f33c3 100644 --- a/sdks/java/spring/build.gradle.kts +++ b/sdks/java/spring/build.gradle.kts @@ -7,10 +7,6 @@ plugins { id("com.vanniktech.maven.publish") version "0.37.0" } -group = "org.byteveda" - -version = "0.20.0" - mavenPublishing { publishToMavenCentral() signAllPublications() diff --git a/sdks/java/test-support/build.gradle.kts b/sdks/java/test-support/build.gradle.kts index 2b888d48..37c3fb5f 100644 --- a/sdks/java/test-support/build.gradle.kts +++ b/sdks/java/test-support/build.gradle.kts @@ -8,9 +8,6 @@ plugins { id("com.vanniktech.maven.publish") version "0.37.0" } -group = "org.byteveda" -version = "0.20.0" - mavenPublishing { publishToMavenCentral() signAllPublications() diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 382f3818..e3a2dd9e 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -4,7 +4,9 @@ build-backend = "maturin" [project] name = "taskito" -version = "0.20.0" +# maturin reads the version off the crate at `tool.maturin.manifest-path`, so +# the wheel can never drift from the Rust core it wraps. +dynamic = ["version"] description = "Rust-powered task queue for Python. No broker required." requires-python = ">=3.10" license = { file = "LICENSE" } diff --git a/sdks/python/taskito/__init__.py b/sdks/python/taskito/__init__.py index 757e0dda..0fd7624e 100644 --- a/sdks/python/taskito/__init__.py +++ b/sdks/python/taskito/__init__.py @@ -144,4 +144,6 @@ __version__ = _get_version("taskito") except PackageNotFoundError: + # Running from a source tree with no installed distribution. Kept in sync + # with the root Cargo.toml by scripts/version.mjs — do not hand-edit. __version__ = "0.20.0"