diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0f16ae..5645864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,25 @@ on: branches: - main workflow_dispatch: + workflow_call: permissions: contents: read jobs: + release-tools: + name: Product version and release tools + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.22.2" + - name: Verify product version + run: node scripts/product-version.mjs --check + - name: Test version synchronization and release aggregation + run: node --test scripts/product-version.test.mjs scripts/prepare-release.test.mjs + rust: name: Rust runs-on: ubuntu-latest diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 5110862..5e2b65c 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -3,6 +3,7 @@ name: Desktop Packages on: push: branches: [main] + tags: ['v*'] workflow_dispatch: permissions: @@ -13,8 +14,34 @@ concurrency: cancel-in-progress: false jobs: + version: + name: Validate product version and release tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.22.2" + - name: Verify synchronized product version + run: node scripts/product-version.mjs --check + - name: Require an annotated release tag on main + if: github.ref_type == 'tag' + run: | + test "$(git cat-file -t "$GITHUB_REF")" = tag + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + + ci: + name: Release CI + needs: version + if: github.ref_type == 'tag' + uses: ./.github/workflows/ci.yml + macos: name: macOS ${{ matrix.arch }} + needs: [version, ci] + if: ${{ !cancelled() && needs.version.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -92,6 +119,8 @@ jobs: windows: name: Windows x86_64 + needs: [version, ci] + if: ${{ !cancelled() && needs.version.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }} runs-on: windows-latest timeout-minutes: 150 steps: @@ -174,6 +203,8 @@ jobs: linux: name: Linux ${{ matrix.arch }} + needs: [version, ci] + if: ${{ !cancelled() && needs.version.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }} strategy: fail-fast: false matrix: @@ -257,6 +288,41 @@ jobs: compression-level: 0 retention-days: 7 + release: + name: Create draft GitHub Release + needs: [version, ci, macos, windows, linux] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22.22.2" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: Chat2DB-Rust-Desktop-*-${{ github.sha }} + path: target/release-artifacts + - name: Verify and collect all platform packages + run: node scripts/prepare-release.mjs target/release-artifacts target/release + - name: Create or update draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft' > "$RUNNER_TEMP/chat2db-release-is-draft"; then + if [[ "$(cat "$RUNNER_TEMP/chat2db-release-is-draft")" != true ]]; then + echo "Refusing to replace assets on an already published release" >&2 + exit 1 + fi + else + gh release create "$RELEASE_TAG" --verify-tag --draft \ + --title "Chat2DB Rust $(node scripts/product-version.mjs)" --generate-notes + fi + gh release upload "$RELEASE_TAG" target/release/* --clobber + notify-feishu: name: Feishu notification if: ${{ always() }} diff --git a/Cargo.lock b/Cargo.lock index b423af4..b85e546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,7 +863,7 @@ dependencies = [ [[package]] name = "chat2db-agent" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "bytes", @@ -883,7 +883,7 @@ dependencies = [ [[package]] name = "chat2db-cli" -version = "0.1.0" +version = "0.0.1" dependencies = [ "chat2db-contract", "chat2db-core", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "chat2db-contract" -version = "0.1.0" +version = "0.0.1" dependencies = [ "serde", "serde_json", @@ -907,7 +907,7 @@ dependencies = [ [[package]] name = "chat2db-core" -version = "0.1.0" +version = "0.0.1" dependencies = [ "aes 0.8.4", "async-trait", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "chat2db-desktop" -version = "0.1.0" +version = "0.0.1" dependencies = [ "cap-std", "chat2db-contract", @@ -977,7 +977,7 @@ dependencies = [ [[package]] name = "chat2db-engine-protocol" -version = "0.1.0" +version = "0.0.1" dependencies = [ "prost", "prost-build", @@ -988,7 +988,7 @@ dependencies = [ [[package]] name = "chat2db-java-bridge" -version = "0.1.0" +version = "0.0.1" dependencies = [ "chat2db-engine-protocol", "prost", @@ -1001,7 +1001,7 @@ dependencies = [ [[package]] name = "chat2db-local" -version = "0.1.0" +version = "0.0.1" dependencies = [ "base64 0.22.1", "chat2db-contract", @@ -1026,7 +1026,7 @@ dependencies = [ [[package]] name = "chat2db-local-ipc-windows" -version = "0.1.0" +version = "0.0.1" dependencies = [ "tokio", "windows-sys 0.61.2", @@ -1034,7 +1034,7 @@ dependencies = [ [[package]] name = "chat2db-mcp" -version = "0.1.0" +version = "0.0.1" dependencies = [ "chat2db-contract", "chat2db-core", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "chat2db-runtime" -version = "0.1.0" +version = "0.0.1" dependencies = [ "chat2db-core", "chat2db-java-bridge", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "chat2db-storage" -version = "0.1.0" +version = "0.0.1" dependencies = [ "aes-gcm 0.10.3", "base64 0.22.1", @@ -1086,7 +1086,7 @@ dependencies = [ [[package]] name = "chat2db-web" -version = "0.1.0" +version = "0.0.1" dependencies = [ "axum", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index e4cbcd9..113634f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.0.1" edition = "2024" rust-version = "1.88" authors = ["OtterMind"] diff --git a/Makefile b/Makefile index cf5b695..3ec6bed 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: verify rust rust-process-tests java ipc-integration jdbc-h2-integration \ +.PHONY: verify release-tools rust rust-process-tests java ipc-integration jdbc-h2-integration \ community-h2-classpath community-h2-reproducibility community-java-h2-integration \ community-h2-integration \ community-product-h2-integration product-h2-integration mysql-driver-pack h2-driver-pack dm-driver-pack \ @@ -17,11 +17,15 @@ MYSQL_TEST_HOST ?= 127.0.0.1 MYSQL_TEST_PORT ?= 3306 MYSQL_TEST_JDBC_PARAMETERS ?= sslMode=DISABLED&allowPublicKeyRetrieval=true&serverTimezone=UTC&zeroDateTimeBehavior=CONVERT_TO_NULL&tinyInt1isBit=false -verify: rust rust-process-tests java ipc-integration jdbc-h2-integration \ +verify: release-tools rust rust-process-tests java ipc-integration jdbc-h2-integration \ community-java-h2-integration community-h2-integration \ community-product-h2-integration product-h2-integration \ dm-driver-pack-integration dm-product-integration frontend desktop +release-tools: + node scripts/product-version.mjs --check + node --test scripts/product-version.test.mjs scripts/prepare-release.test.mjs + rust: cargo fmt --all --check cargo clippy --workspace --all-targets --all-features --locked -- -D warnings diff --git a/README.md b/README.md index ca81992..a10b5ed 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ for every platform supported by Chat2DB Community: Every desktop package embeds the matching `chat2db` headless CLI beside the shared Java, Community-classpath, and driver-pack resources. +Product versions and tagged Draft Releases follow [the release guide](docs/releases.md). + macOS packages are always signed with the configured Developer ID Application identity and notarized by Apple before upload. Packaging fails closed when the signing or notarization configuration is unavailable. diff --git a/apps/chat2db-desktop/tauri.conf.json b/apps/chat2db-desktop/tauri.conf.json index 999aa05..adaeb96 100644 --- a/apps/chat2db-desktop/tauri.conf.json +++ b/apps/chat2db-desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Chat2DB Rust", - "version": "0.1.0", + "version": "0.0.1", "identifier": "ai.chat2db.desktop", "build": { "beforeDevCommand": "npm --prefix ../frontend run dev", @@ -29,6 +29,10 @@ }, "bundle": { "active": false, - "icon": ["icons/icon.png", "icons/icon.icns", "icons/icon.ico"] + "icon": [ + "icons/icon.png", + "icons/icon.icns", + "icons/icon.ico" + ] } } diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 99c9ba0..bc9e31f 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chat2db/frontend", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chat2db/frontend", - "version": "0.1.0", + "version": "0.0.1", "dependencies": { "@tauri-apps/api": "2.11.1", "eventsource-parser": "3.1.0" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 45de922..bd219ce 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@chat2db/frontend", - "version": "0.1.0", + "version": "0.0.1", "private": true, "type": "module", "packageManager": "npm@10.9.7", diff --git a/contracts/openapi/chat2db-v1.json b/contracts/openapi/chat2db-v1.json index 5c8c70a..cce64e3 100644 --- a/contracts/openapi/chat2db-v1.json +++ b/contracts/openapi/chat2db-v1.json @@ -10,7 +10,7 @@ "name": "Apache-2.0", "identifier": "Apache-2.0" }, - "version": "0.1.0" + "version": "0.0.1" }, "paths": { "/api/v1/agent/providers": { diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..750dd3d --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,69 @@ +# Desktop releases + +The product version comes from `[workspace.package].version` in `Cargo.toml`. +Rust binaries, CLI status, MCP server information, and frontend build/dev +configuration use that version. Tauri and npm metadata are checked-in +projections, verified before packaging and by CI. + +## Prepare a version + +1. Change `Cargo.toml` to the intended product version. +2. Run `rtk proxy node scripts/product-version.mjs --sync`. This updates Tauri, + npm metadata, and only the local workspace packages in `Cargo.lock`. +3. Run `rtk make generate-contracts` to regenerate OpenAPI and TypeScript. +4. Run `rtk make verify`, `rtk make check-contracts`, and `rtk git diff --check`. +5. Review and merge the release preparation into `main`. + +The Java compatibility engine and internal protocol have independent versions. +Product version synchronization does not rewrite Java artifacts, dependency +versions, or the pinned Community source. + +## Build a release draft + +Create an annotated tag named exactly `v` on the release +commit in `main`. The tag annotation records: + +- `source_repository`: `OtterMind/Chat2DB-Rust`; +- `source_commit`: the complete release commit SHA; +- `community_ref`: the pinned submodule commit; +- `package_repository`: `OtterMind/Chat2DB-Rust`; +- `workflow_ref`: the complete release commit SHA; +- `product_workflow`: `.github/workflows/package.yml`; and +- `actions_parameters`: `none` (the workflow has no dispatch inputs). + +Read the annotation with `rtk git show --no-patch v0.0.1`, then push the tag +with `rtk git push origin v0.0.1`. The `Desktop Packages` workflow will: + +1. Check the product version, exact tag/version match, annotated tag type, + and that the release commit is in `main`. +2. Run the reusable repository CI on that same tagged revision. +3. Build macOS ARM64/x86_64, Windows x86_64, and Linux ARM64/x86_64 packages. + macOS signing and notarization remain required. +4. Require successful CI and every package job before preparing the release. +5. Verify package hashes and all five version/source/Community manifests. +6. Create a GitHub **Draft Release** with twelve package files, a combined + `SHA256SUMS`, and five uniquely named `BUILD-MANIFEST-.txt` files. + +Main-branch pushes and branch dispatches still produce Actions artifacts. +They do not create a Release. A tag can also be rebuilt with: + +```bash +rtk gh workflow run package.yml --repo OtterMind/Chat2DB-Rust --ref v0.0.1 +``` + +A rerun can replace assets on an existing draft. It refuses to replace assets +on a published release. Partial or failed builds do not reach the release job. + +## Publish + +Download the draft assets, verify `SHA256SUMS`, and validate installation, +startup, the displayed version, database connections, and CLI attachment on +the supported platforms. Review the draft notes and known limitations, and +complete the repository's Community distribution, NOTICE/SBOM, and installed +package acceptance requirements before publishing the draft. + +Publish through GitHub or: + +```bash +rtk gh release edit v0.0.1 --repo OtterMind/Chat2DB-Rust --draft=false +``` diff --git a/scripts/build-linux-package.sh b/scripts/build-linux-package.sh index 3b9f71e..c186eb6 100755 --- a/scripts/build-linux-package.sh +++ b/scripts/build-linux-package.sh @@ -3,6 +3,8 @@ set -euo pipefail repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" desktop_root="${repository_root}/apps/chat2db-desktop" +node "${repository_root}/scripts/product-version.mjs" --check +version="$(node "${repository_root}/scripts/product-version.mjs")" build_target="${CHAT2DB_LINUX_BUILD_TARGET:-${repository_root}/target/linux-package-build}" package_directory="${repository_root}/target/linux-package" license_resource_directory="${repository_root}/target/linux-license-resources" @@ -115,6 +117,9 @@ cp -- "${appimage_artifacts[0]}" "${deb_artifacts[0]}" "${rpm_artifacts[0]}" "${ sha256sum ./*.AppImage ./*.deb ./*.rpm > SHA256SUMS { echo "Chat2DB Rust Linux package" + echo "version=${version}" + echo "git_commit=$(git -C "${repository_root}" rev-parse HEAD)" + echo "community_commit=$(git -C "${repository_root}/third_party/chat2db-community" rev-parse HEAD)" echo "architecture=$(uname -m)" echo "target=linux" echo "rust_toolchain=${rust_toolchain}" diff --git a/scripts/build-macos-package.sh b/scripts/build-macos-package.sh index 5cb223d..1149fec 100755 --- a/scripts/build-macos-package.sh +++ b/scripts/build-macos-package.sh @@ -3,6 +3,8 @@ set -euo pipefail repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" desktop_root="${repository_root}/apps/chat2db-desktop" +node "${repository_root}/scripts/product-version.mjs" --check +version="$(node "${repository_root}/scripts/product-version.mjs")" build_target="${CHAT2DB_MACOS_BUILD_TARGET:-${repository_root}/target/macos-package-build}" app_path="${build_target}/release/bundle/macos/Chat2DB Rust.app" package_directory="${repository_root}/target/macos-package" @@ -251,21 +253,6 @@ CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE=true \ notarization_status="accepted" distribution_status="developer-id-notarized" -version="$(awk ' - /^\[workspace.package\]$/ { in_package = 1; next } - /^\[/ { in_package = 0 } - in_package && /^version[[:space:]]*=/ { - gsub(/[[:space:]\"]/ , "", $0) - sub(/^version=/, "", $0) - print - exit - } -' "${repository_root}/Cargo.toml")" -if [[ -z "${version}" ]]; then - echo "could not resolve workspace package version" >&2 - exit 1 -fi - case "${package_directory}" in "${repository_root}/target/macos-package") ;; *) diff --git a/scripts/build-windows-package.sh b/scripts/build-windows-package.sh index 0ae238c..a0e88a5 100755 --- a/scripts/build-windows-package.sh +++ b/scripts/build-windows-package.sh @@ -3,6 +3,8 @@ set -euo pipefail repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" desktop_root="${repository_root}/apps/chat2db-desktop" +node "${repository_root}/scripts/product-version.mjs" --check +version="$(node "${repository_root}/scripts/product-version.mjs")" build_target="${CHAT2DB_WINDOWS_BUILD_TARGET:-${repository_root}/target/windows-package-build}" package_directory="${repository_root}/target/windows-package" license_resource_directory="${repository_root}/target/windows-license-resources" @@ -117,6 +119,9 @@ cp -- "${msi_artifacts[0]}" "${package_directory}/" sha256sum ./*.exe ./*.msi > SHA256SUMS { echo "Chat2DB Rust Windows package" + echo "version=${version}" + echo "git_commit=$(git -C "${repository_root}" rev-parse HEAD)" + echo "community_commit=$(git -C "${repository_root}/third_party/chat2db-community" rev-parse HEAD)" echo "architecture=x86_64" echo "target=windows" echo "rust_toolchain=${rust_toolchain}" diff --git a/scripts/community-frontend.mjs b/scripts/community-frontend.mjs index e927292..a3acfc0 100644 --- a/scripts/community-frontend.mjs +++ b/scripts/community-frontend.mjs @@ -13,6 +13,7 @@ import { import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { productVersion } from './product-version.mjs'; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = resolve(SCRIPT_DIR, '..'); @@ -219,7 +220,7 @@ function build() { 'build:web:community', '--app_port=4200', '--public_path=./', - '--app_version=0.1.0', + `--app_version=${productVersion()}`, ], worktree, ); @@ -252,7 +253,7 @@ function dev() { { UMI_ENV: 'community', APP_NAME: 'chat2db-community', - APP_VERSION: '0.1.0', + APP_VERSION: productVersion(), DISABLE_MFSU: 'true', HOST: '127.0.0.1', UMI_DEV_SERVER_COMPRESS: 'none', diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..2f84c9c --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkProductVersion } from './product-version.mjs'; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const PLATFORM_PACKAGES = { + 'macos-arm64': ['.app.zip', '.dmg'], + 'macos-x64': ['.app.zip', '.dmg'], + 'windows-x86_64': ['.exe', '.msi'], + 'linux-x86_64': ['.AppImage', '.deb', '.rpm'], + 'linux-arm64': ['.AppImage', '.deb', '.rpm'], +}; + +async function sha256(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export async function prepareRelease({ artifactsDir, outputDir, version, commit, communityCommit }) { + const files = new Map(); + const manifests = []; + for (const [platform, extensions] of Object.entries(PLATFORM_PACKAGES)) { + const directory = join(artifactsDir, `Chat2DB-Rust-Desktop-${platform}-${commit}`); + const manifest = await readFile(join(directory, 'BUILD-MANIFEST.txt'), 'utf8'); + const fields = Object.fromEntries( + manifest.split(/\r?\n/).filter((line) => line.includes('=')) + .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), + ); + for (const [key, expected] of Object.entries({ + version, git_commit: commit, community_commit: communityCommit, + })) { + if (fields[key] !== expected) throw new Error(`${platform} manifest ${key} must be ${expected}`); + } + const sums = await readFile(join(directory, 'SHA256SUMS'), 'utf8'); + const packages = []; + for (const line of sums.trim().split(/\r?\n/)) { + const entry = line.match(/^([a-f0-9]{64}) [ *](.+)$/i); + if (!entry) throw new Error(`${platform} has an invalid SHA256SUMS entry`); + const [, digest, rawName] = entry; + const name = rawName.replace(/^\.\//, ''); + if (basename(name) !== name || name.includes('\\')) { + throw new Error(`${platform} checksum filename must be a basename`); + } + if (files.has(name)) throw new Error(`duplicate release asset ${name}`); + const source = join(directory, name); + if (await sha256(source) !== digest.toLowerCase()) { + throw new Error(`${platform} checksum mismatch for ${name}`); + } + packages.push(name); + files.set(name, { source, digest: digest.toLowerCase() }); + } + if (packages.length !== extensions.length || extensions.some((extension) => + packages.filter((name) => name.endsWith(extension)).length !== 1)) { + throw new Error(`${platform} must contain exactly one package for each of ${extensions.join(', ')}`); + } + manifests.push({ name: `BUILD-MANIFEST-${platform}.txt`, source: join(directory, 'BUILD-MANIFEST.txt') }); + } + + await mkdir(outputDir, { recursive: true }); + if ((await readdir(outputDir)).length !== 0) throw new Error('release output directory must be empty'); + for (const [name, { source }] of files) await copyFile(source, join(outputDir, name)); + for (const { name, source } of manifests) await copyFile(source, join(outputDir, name)); + const combined = [...files].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([name, { digest }]) => `${digest} ${name}\n`).join(''); + await writeFile(join(outputDir, 'SHA256SUMS'), combined); + return [...files.keys(), ...manifests.map(({ name }) => name), 'SHA256SUMS']; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const [artifactsDir, outputDir] = process.argv.slice(2); + if (!artifactsDir || !outputDir || !process.env.GITHUB_SHA) { + throw new Error('usage: GITHUB_SHA= node prepare-release.mjs '); + } + const version = checkProductVersion(ROOT_DIR, process.env.GITHUB_REF_NAME); + const community = JSON.parse(await readFile(join(ROOT_DIR, 'scripts/community-frontend.lock.json'), 'utf8')); + const assets = await prepareRelease({ + artifactsDir, outputDir, version, commit: process.env.GITHUB_SHA, communityCommit: community.commit, + }); + console.log(`prepared ${assets.length} release assets for v${version}`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs new file mode 100644 index 0000000..ce96a63 --- /dev/null +++ b/scripts/prepare-release.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { PLATFORM_PACKAGES, prepareRelease } from './prepare-release.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'chat2db-release-')); + t.after(() => rm(root, { recursive: true, force: true })); + const options = { + artifactsDir: join(root, 'artifacts'), outputDir: join(root, 'release'), + version: '0.0.1', commit: 'a'.repeat(40), communityCommit: 'b'.repeat(40), + }; + const directories = {}; + for (const [platform, extensions] of Object.entries(PLATFORM_PACKAGES)) { + const directory = join(options.artifactsDir, `Chat2DB-Rust-Desktop-${platform}-${options.commit}`); + directories[platform] = directory; + await mkdir(directory, { recursive: true }); + const sums = []; + for (const extension of extensions) { + const name = `Chat2DB Rust_0.0.1_${platform}${extension}`; + const contents = `${platform} ${extension}`; + await writeFile(join(directory, name), contents); + sums.push(`${createHash('sha256').update(contents).digest('hex')} ./${name}`); + } + await writeFile(join(directory, 'SHA256SUMS'), `${sums.join('\n')}\n`); + await writeFile(join(directory, 'BUILD-MANIFEST.txt'), + `version=${options.version}\ngit_commit=${options.commit}\ncommunity_commit=${options.communityCommit}\n`); + } + return { options, directories }; +} + +test('all twelve packages and five distinct manifests survive aggregation and checksum verification', async (t) => { + const { options } = await fixture(t); + const assets = await prepareRelease(options); + assert.equal(assets.length, 18); + assert.equal((await readdir(options.outputDir)).length, 18); + const sums = (await readFile(join(options.outputDir, 'SHA256SUMS'), 'utf8')).trim().split('\n'); + assert.equal(sums.length, 12); + for (const line of sums) { + const digest = line.slice(0, 64); + const contents = await readFile(join(options.outputDir, line.slice(66))); + assert.equal(createHash('sha256').update(contents).digest('hex'), digest); + } +}); + +test('a missing platform prevents release preparation', async (t) => { + const { options, directories } = await fixture(t); + await rm(directories['linux-arm64'], { recursive: true }); + await assert.rejects(prepareRelease(options), /ENOENT/); +}); + +test('a modified installer prevents release preparation', async (t) => { + const { options, directories } = await fixture(t); + await writeFile(join(directories['macos-arm64'], 'Chat2DB Rust_0.0.1_macos-arm64.dmg'), 'modified'); + await assert.rejects(prepareRelease(options), /checksum mismatch/); +}); + +test('a missing package format prevents release preparation', async (t) => { + const { options, directories } = await fixture(t); + const path = join(directories['windows-x86_64'], 'SHA256SUMS'); + const sums = await readFile(path, 'utf8'); + await writeFile(path, `${sums.split('\n')[0]}\n`); + await assert.rejects(prepareRelease(options), /must contain exactly one package/); +}); + +test('identical installer names across platforms cannot overwrite one another', async (t) => { + const { options, directories } = await fixture(t); + const directory = directories['macos-x64']; + const oldName = 'Chat2DB Rust_0.0.1_macos-x64.dmg'; + const newName = 'Chat2DB Rust_0.0.1_macos-arm64.dmg'; + await rename(join(directory, oldName), join(directory, newName)); + const path = join(directory, 'SHA256SUMS'); + await writeFile(path, (await readFile(path, 'utf8')).replace(oldName, newName)); + await assert.rejects(prepareRelease(options), /duplicate release asset/); +}); + +for (const key of ['version', 'git_commit', 'community_commit']) { + test(`a mismatched ${key} prevents mixing builds into one release`, async (t) => { + const { options, directories } = await fixture(t); + const path = join(directories['linux-x86_64'], 'BUILD-MANIFEST.txt'); + const manifest = await readFile(path, 'utf8'); + await writeFile(path, manifest.replace(new RegExp(`^${key}=.*$`, 'm'), `${key}=different`)); + await assert.rejects(prepareRelease(options), /manifest .* must be/); + }); +} diff --git a/scripts/product-version.mjs b/scripts/product-version.mjs new file mode 100644 index 0000000..0cbdbef --- /dev/null +++ b/scripts/product-version.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const JSON_VERSIONS = [ + ['apps/chat2db-desktop/tauri.conf.json', ['version']], + ['apps/frontend/package.json', ['version']], + ['apps/frontend/package-lock.json', ['version']], + ['apps/frontend/package-lock.json', ['packages', '', 'version']], +]; + +export function productVersion(root = ROOT_DIR) { + const cargo = readFileSync(join(root, 'Cargo.toml'), 'utf8'); + const section = cargo.split(/^\[/m).find((entry) => entry.startsWith('workspace.package]')); + const version = section?.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + if (!version) throw new Error('Cargo.toml must declare workspace.package.version'); + return version; +} + +function workspaceLockVersions(root, visit) { + const path = join(root, 'Cargo.lock'); + const contents = readFileSync(path, 'utf8'); + return contents.replace(/^\[\[package\]\][\s\S]*?(?=^\[\[package\]\]|$(?![\s\S]))/gm, (block) => { + const name = block.match(/^name = "(chat2db[^"]*)"/m)?.[1]; + if (!name || /^source = /m.test(block)) return block; + return visit(block, name); + }); +} + +export function syncProductVersion(root = ROOT_DIR) { + const version = productVersion(root); + for (const [relativePath, keys] of JSON_VERSIONS) { + const path = join(root, relativePath); + const document = JSON.parse(readFileSync(path, 'utf8')); + const parent = keys.slice(0, -1).reduce((value, key) => value[key], document); + parent[keys.at(-1)] = version; + writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`); + } + const lock = workspaceLockVersions(root, (block) => + block.replace(/^version = "[^"]+"/m, `version = "${version}"`)); + writeFileSync(join(root, 'Cargo.lock'), lock); + return version; +} + +export function checkProductVersion(root = ROOT_DIR, tag) { + const version = productVersion(root); + const projections = [ + ...JSON_VERSIONS, + ['contracts/openapi/chat2db-v1.json', ['info', 'version']], + ]; + for (const [relativePath, keys] of projections) { + const document = JSON.parse(readFileSync(join(root, relativePath), 'utf8')); + const actual = keys.reduce((value, key) => value[key], document); + if (actual !== version) { + throw new Error(`${relativePath} ${keys.join('.')} is ${actual}; expected ${version}`); + } + } + workspaceLockVersions(root, (block, name) => { + const actual = block.match(/^version = "([^"]+)"/m)?.[1]; + if (actual !== version) throw new Error(`Cargo.lock ${name} is ${actual}; expected ${version}`); + return block; + }); + if (tag !== undefined && tag !== `v${version}`) { + throw new Error(`release tag ${tag} does not match product version v${version}`); + } + return version; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + switch (process.argv[2]) { + case undefined: + console.log(productVersion()); + break; + case '--sync': + console.log(`synchronized product version ${syncProductVersion()}; run make generate-contracts`); + break; + case '--check': + console.log(`verified product version ${checkProductVersion( + ROOT_DIR, + process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : undefined, + )}`); + break; + default: + throw new Error('usage: product-version.mjs [--sync|--check]'); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/product-version.test.mjs b/scripts/product-version.test.mjs new file mode 100644 index 0000000..9409b18 --- /dev/null +++ b/scripts/product-version.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { checkProductVersion, productVersion, syncProductVersion } from './product-version.mjs'; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'chat2db-version-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const documents = { + 'Cargo.toml': '[workspace.package]\nversion = "0.0.1"\n[workspace.dependencies]\nversion = "9.9.9"\n', + 'Cargo.lock': 'version = 4\n\n[[package]]\nname = "chat2db-core"\nversion = "0.1.0"\n\n[[package]]\nname = "dependency"\nversion = "0.1.0"\nsource = "registry+https://example.test"\n', + 'apps/chat2db-desktop/tauri.conf.json': { version: '0.1.0', app: { windows: [] } }, + 'apps/frontend/package.json': { name: '@chat2db/frontend', version: '0.1.0' }, + 'apps/frontend/package-lock.json': { version: '0.1.0', packages: { '': { version: '0.1.0' }, dependency: { version: '0.1.0' } } }, + 'contracts/openapi/chat2db-v1.json': { info: { version: '0.0.1' } }, + }; + for (const [relativePath, contents] of Object.entries(documents)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents)); + } + return root; +} + +test('sync uses the workspace version and preserves dependency versions and other configuration', (t) => { + const root = fixture(t); + assert.equal(productVersion(root), '0.0.1'); + assert.equal(syncProductVersion(root), '0.0.1'); + assert.equal(checkProductVersion(root, 'v0.0.1'), '0.0.1'); + const lock = readFileSync(join(root, 'Cargo.lock'), 'utf8'); + assert.match(lock, /name = "chat2db-core"\nversion = "0.0.1"/); + assert.match(lock, /name = "dependency"\nversion = "0.1.0"/); + const npm = JSON.parse(readFileSync(join(root, 'apps/frontend/package-lock.json'), 'utf8')); + assert.equal(npm.packages.dependency.version, '0.1.0'); + const tauri = JSON.parse(readFileSync(join(root, 'apps/chat2db-desktop/tauri.conf.json'), 'utf8')); + assert.deepEqual(tauri.app, { windows: [] }); +}); + +test('check rejects a tag that would label the binaries with a different version', (t) => { + const root = fixture(t); + syncProductVersion(root); + assert.throws(() => checkProductVersion(root, 'v0.1.0'), /does not match/); +}); + +for (const relativePath of [ + 'apps/chat2db-desktop/tauri.conf.json', + 'apps/frontend/package.json', + 'apps/frontend/package-lock.json', + 'contracts/openapi/chat2db-v1.json', +]) { + test(`check rejects version drift in ${relativePath}`, (t) => { + const root = fixture(t); + syncProductVersion(root); + const path = join(root, relativePath); + writeFileSync(path, readFileSync(path, 'utf8').replace('0.0.1', '0.2.0')); + assert.throws(() => checkProductVersion(root), /expected 0.0.1/); + }); +} + +test('check rejects stale Rust workspace package versions', (t) => { + const root = fixture(t); + syncProductVersion(root); + const path = join(root, 'Cargo.lock'); + writeFileSync(path, readFileSync(path, 'utf8').replace('version = "0.0.1"', 'version = "0.1.0"')); + assert.throws(() => checkProductVersion(root), /Cargo.lock chat2db-core/); +});