From 5bc3523ee0b937cf4f0109e8f6341d9ded9eb40a Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:17:47 +0200 Subject: [PATCH 1/2] release: align desktop release versions --- .github/workflows/release.yml | 4 +- CONTRIBUTING.md | 2 + package.json | 3 +- release.config.cjs | 10 ++- scripts/apply-release-version.mjs | 65 ++++++++++++++++++++ scripts/test-apply-release-version.mjs | 84 ++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 scripts/apply-release-version.mjs create mode 100644 scripts/test-apply-release-version.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1aac82c1..ea0e12ad1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # release.config.cjs: GitHub Release + tags only (protected main, no root npm package). + # release.config.cjs: semantic-release computes the version and applies it + # to package metadata in the release workspace before publishing. - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -34,6 +35,7 @@ jobs: npx -y \ -p semantic-release@24 \ -p @semantic-release/commit-analyzer \ + -p @semantic-release/exec \ -p @semantic-release/release-notes-generator \ -p @semantic-release/github \ -p conventional-changelog-conventionalcommits@8 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b680a61b6..663d1b510 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,3 +52,5 @@ Broad roadmap discussions and unsupported configuration requests may be closed s ## Release process Maintainers merge to `main`. The release workflow runs semantic-release and creates GitHub releases from conventional commits. + +semantic-release's computed `nextRelease.version` is the source of truth for stable desktop releases. During the release workflow, `scripts/apply-release-version.mjs` applies that version to the root and frontend package metadata in the release workspace before desktop packaging or release asset publishing. The checked-in package versions are development fallbacks; released app metadata and artifact names should use the semantic-release version. diff --git a/package.json b/package.json index 2feaa1c33..ac084ca29 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "doctor": "bash scripts/doctor.sh", "release:notes": "node scripts/release-statement.mjs", "release:check-commits": "node scripts/check-conventional-commits.mjs --range origin/main..HEAD", + "test:release-version": "node --test scripts/test-apply-release-version.mjs", "setup:git-hooks": "git config core.hooksPath .githooks && chmod +x .githooks/*", - "check": "npm run check:contracts && npm run check:structure && npm run check:frontend && npm run check:controller && npm run check:cli", + "check": "npm run test:release-version && npm run check:contracts && npm run check:structure && npm run check:frontend && npm run check:controller && npm run check:cli", "check:contracts": "node scripts/validate-shared-contracts.mjs", "check:structure": "node scripts/validate-barrel-dir-siblings.mjs", "check:frontend": "npm --prefix frontend run check:quality", diff --git a/release.config.cjs b/release.config.cjs index 645af204b..4e715e80d 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -1,6 +1,8 @@ /** * Monorepo, protected `main`: no npm publish, no direct commits to main. - * Creates Git tag + GitHub Release only (release notes from commits). + * semantic-release is the release version source of truth. The prepare step + * applies the computed version to package metadata in the release workspace + * before any desktop artifacts are built or attached. * @type {import("semantic-release").GlobalConfig} */ module.exports = { @@ -43,6 +45,12 @@ module.exports = { }, }, ], + [ + "@semantic-release/exec", + { + prepareCmd: "node scripts/apply-release-version.mjs ${nextRelease.version}", + }, + ], "@semantic-release/github", ], }; diff --git a/scripts/apply-release-version.mjs b/scripts/apply-release-version.mjs new file mode 100644 index 000000000..350010484 --- /dev/null +++ b/scripts/apply-release-version.mjs @@ -0,0 +1,65 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const RELEASE_VERSION_PATTERN = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/; + +const PACKAGE_FILES = [ + "package.json", + "package-lock.json", + "frontend/package.json", + "frontend/package-lock.json", +]; + +export function isValidReleaseVersion(version) { + return RELEASE_VERSION_PATTERN.test(version); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function writeJson(filePath, value) { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function updatePackageFile(filePath, version) { + const json = await readJson(filePath); + json.version = version; + + if (json.packages?.[""] && typeof json.packages[""] === "object") { + json.packages[""].version = version; + } + + await writeJson(filePath, json); +} + +export async function applyReleaseVersion({ rootDir, version }) { + if (!isValidReleaseVersion(version)) { + throw new Error( + `Release version must be a plain semver string without a leading "v"; received ${JSON.stringify(version)}`, + ); + } + + for (const relativePath of PACKAGE_FILES) { + await updatePackageFile(path.join(rootDir, relativePath), version); + } +} + +async function main() { + const version = process.argv[2]; + if (!version) { + throw new Error("Usage: node scripts/apply-release-version.mjs "); + } + + await applyReleaseVersion({ rootDir: process.cwd(), version }); + console.log(`Applied release version ${version} to package metadata.`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/test-apply-release-version.mjs b/scripts/test-apply-release-version.mjs new file mode 100644 index 000000000..cceb3cf2e --- /dev/null +++ b/scripts/test-apply-release-version.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { applyReleaseVersion, isValidReleaseVersion } from "./apply-release-version.mjs"; + +async function writeJson(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function createFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "vllm-studio-release-version-")); + + await writeJson(path.join(root, "package.json"), { + name: "vllm-studio", + version: "0.2.9", + private: true, + }); + await writeJson(path.join(root, "package-lock.json"), { + name: "vllm-studio", + version: "0.2.9", + lockfileVersion: 3, + packages: { + "": { + name: "vllm-studio", + version: "0.2.9", + }, + }, + }); + await writeJson(path.join(root, "frontend", "package.json"), { + name: "frontend", + version: "0.2.9", + private: true, + }); + await writeJson(path.join(root, "frontend", "package-lock.json"), { + name: "frontend", + version: "0.2.9", + lockfileVersion: 3, + packages: { + "": { + name: "frontend", + version: "0.2.9", + }, + }, + }); + + return root; +} + +test("validates semantic-release version strings without tag prefixes", () => { + assert.equal(isValidReleaseVersion("1.49.1"), true); + assert.equal(isValidReleaseVersion("1.49.1-beta.1"), true); + assert.equal(isValidReleaseVersion("v1.49.1"), false); + assert.equal(isValidReleaseVersion("1.49"), false); + assert.equal(isValidReleaseVersion("latest"), false); +}); + +test("applies the release version to root and frontend package metadata", async () => { + const root = await createFixture(); + + await applyReleaseVersion({ rootDir: root, version: "1.49.1" }); + + assert.equal((await readJson(path.join(root, "package.json"))).version, "1.49.1"); + assert.equal((await readJson(path.join(root, "package-lock.json"))).version, "1.49.1"); + assert.equal( + (await readJson(path.join(root, "package-lock.json"))).packages[""].version, + "1.49.1", + ); + assert.equal((await readJson(path.join(root, "frontend", "package.json"))).version, "1.49.1"); + assert.equal( + (await readJson(path.join(root, "frontend", "package-lock.json"))).version, + "1.49.1", + ); + assert.equal( + (await readJson(path.join(root, "frontend", "package-lock.json"))).packages[""].version, + "1.49.1", + ); +}); From df048d6f1a58cae0b6508509538377171ec66d5b Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:04:07 +0200 Subject: [PATCH 2/2] ci(release): publish macOS desktop assets --- .github/workflows/release.yml | 10 ++- CONTRIBUTING.md | 2 + package.json | 3 +- release.config.cjs | 31 +++++++-- scripts/test-validate-desktop-release-env.mjs | 39 ++++++++++++ .../test-verify-desktop-release-assets.mjs | 45 +++++++++++++ scripts/validate-desktop-release-env.mjs | 34 ++++++++++ scripts/verify-desktop-release-assets.mjs | 63 +++++++++++++++++++ 8 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 scripts/test-validate-desktop-release-env.mjs create mode 100644 scripts/test-verify-desktop-release-assets.mjs create mode 100644 scripts/validate-desktop-release-env.mjs create mode 100644 scripts/verify-desktop-release-assets.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea0e12ad1..2d41ae0bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: release: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 with: @@ -27,10 +27,16 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" # release.config.cjs: semantic-release computes the version and applies it - # to package metadata in the release workspace before publishing. + # to package metadata, builds signed macOS artifacts, and attaches them to + # the GitHub Release before publishing. - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | npx -y \ -p semantic-release@24 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 663d1b510..c77309229 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,3 +54,5 @@ Broad roadmap discussions and unsupported configuration requests may be closed s Maintainers merge to `main`. The release workflow runs semantic-release and creates GitHub releases from conventional commits. semantic-release's computed `nextRelease.version` is the source of truth for stable desktop releases. During the release workflow, `scripts/apply-release-version.mjs` applies that version to the root and frontend package metadata in the release workspace before desktop packaging or release asset publishing. The checked-in package versions are development fallbacks; released app metadata and artifact names should use the semantic-release version. + +Stable macOS release assets are built on a macOS GitHub Actions runner with `npm --prefix frontend run desktop:dist` and attached by `@semantic-release/github`. Maintainers must configure the signing and notarization secrets before this workflow can publish usable assets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID`. The release workflow intentionally fails before building if any of those values are missing, so unsigned or unnotarized macOS artifacts are not published as stable release assets. diff --git a/package.json b/package.json index ac084ca29..658f1e381 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "doctor": "bash scripts/doctor.sh", "release:notes": "node scripts/release-statement.mjs", "release:check-commits": "node scripts/check-conventional-commits.mjs --range origin/main..HEAD", + "test:release-assets": "node --test scripts/test-validate-desktop-release-env.mjs scripts/test-verify-desktop-release-assets.mjs", "test:release-version": "node --test scripts/test-apply-release-version.mjs", "setup:git-hooks": "git config core.hooksPath .githooks && chmod +x .githooks/*", - "check": "npm run test:release-version && npm run check:contracts && npm run check:structure && npm run check:frontend && npm run check:controller && npm run check:cli", + "check": "npm run test:release-version && npm run test:release-assets && npm run check:contracts && npm run check:structure && npm run check:frontend && npm run check:controller && npm run check:cli", "check:contracts": "node scripts/validate-shared-contracts.mjs", "check:structure": "node scripts/validate-barrel-dir-siblings.mjs", "check:frontend": "npm --prefix frontend run check:quality", diff --git a/release.config.cjs b/release.config.cjs index 4e715e80d..17d508afd 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -1,8 +1,8 @@ /** * Monorepo, protected `main`: no npm publish, no direct commits to main. * semantic-release is the release version source of truth. The prepare step - * applies the computed version to package metadata in the release workspace - * before any desktop artifacts are built or attached. + * applies the computed version to package metadata, builds signed desktop + * artifacts, and then attaches those artifacts to the GitHub release. * @type {import("semantic-release").GlobalConfig} */ module.exports = { @@ -48,9 +48,32 @@ module.exports = { [ "@semantic-release/exec", { - prepareCmd: "node scripts/apply-release-version.mjs ${nextRelease.version}", + prepareCmd: + "node scripts/validate-desktop-release-env.mjs && node scripts/apply-release-version.mjs ${nextRelease.version} && npm --prefix frontend ci && npm --prefix frontend run desktop:dist && node scripts/verify-desktop-release-assets.mjs ${nextRelease.version}", + }, + ], + [ + "@semantic-release/github", + { + assets: [ + { + path: "frontend/dist-desktop/*.dmg", + label: "vLLM Studio macOS Apple Silicon DMG", + }, + { + path: "frontend/dist-desktop/*.zip", + label: "vLLM Studio macOS Apple Silicon ZIP", + }, + { + path: "frontend/dist-desktop/*.blockmap", + label: "vLLM Studio macOS blockmap", + }, + { + path: "frontend/dist-desktop/latest-mac.yml", + label: "vLLM Studio macOS update metadata", + }, + ], }, ], - "@semantic-release/github", ], }; diff --git a/scripts/test-validate-desktop-release-env.mjs b/scripts/test-validate-desktop-release-env.mjs new file mode 100644 index 000000000..8249332aa --- /dev/null +++ b/scripts/test-validate-desktop-release-env.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { missingDesktopReleaseSecrets } from "./validate-desktop-release-env.mjs"; + +test("reports every missing desktop release secret", () => { + assert.deepEqual(missingDesktopReleaseSecrets({}), [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "APPLE_ID", + "APPLE_APP_SPECIFIC_PASSWORD", + "APPLE_TEAM_ID", + ]); +}); + +test("treats blank desktop release secrets as missing", () => { + assert.deepEqual( + missingDesktopReleaseSecrets({ + CSC_LINK: " ", + CSC_KEY_PASSWORD: "password", + APPLE_ID: "", + APPLE_APP_SPECIFIC_PASSWORD: "app-password", + APPLE_TEAM_ID: "TEAMID", + }), + ["CSC_LINK", "APPLE_ID"], + ); +}); + +test("passes when all desktop release secrets are present", () => { + assert.deepEqual( + missingDesktopReleaseSecrets({ + CSC_LINK: "base64-p12", + CSC_KEY_PASSWORD: "password", + APPLE_ID: "release@example.com", + APPLE_APP_SPECIFIC_PASSWORD: "app-password", + APPLE_TEAM_ID: "TEAMID", + }), + [], + ); +}); diff --git a/scripts/test-verify-desktop-release-assets.mjs b/scripts/test-verify-desktop-release-assets.mjs new file mode 100644 index 000000000..fab63ca9d --- /dev/null +++ b/scripts/test-verify-desktop-release-assets.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { missingDesktopReleaseAssets } from "./verify-desktop-release-assets.mjs"; + +async function createDist(files) { + const root = await mkdtemp(path.join(os.tmpdir(), "vllm-studio-release-assets-")); + const distDir = path.join(root, "frontend", "dist-desktop"); + await mkdir(distDir, { recursive: true }); + + for (const file of files) { + await writeFile(path.join(distDir, file), ""); + } + + return distDir; +} + +test("accepts versioned Apple Silicon DMG, ZIP, and update metadata", async () => { + const distDir = await createDist([ + "vLLM Studio-1.49.1-arm64.dmg", + "vLLM Studio-1.49.1-arm64.dmg.blockmap", + "vLLM Studio-1.49.1-arm64.zip", + "vLLM Studio-1.49.1-arm64.zip.blockmap", + "latest-mac.yml", + ]); + + assert.deepEqual(await missingDesktopReleaseAssets({ distDir, version: "1.49.1" }), []); +}); + +test("reports missing versioned Apple Silicon assets", async () => { + const distDir = await createDist([ + "vLLM Studio-0.2.9-arm64.dmg", + "vLLM Studio-1.49.1-x64.zip", + ]); + + assert.deepEqual(await missingDesktopReleaseAssets({ distDir, version: "1.49.1" }), [ + "versioned arm64 DMG", + "versioned arm64 DMG blockmap", + "versioned arm64 ZIP", + "versioned arm64 ZIP blockmap", + "latest-mac.yml", + ]); +}); diff --git a/scripts/validate-desktop-release-env.mjs b/scripts/validate-desktop-release-env.mjs new file mode 100644 index 000000000..5f3b95dc4 --- /dev/null +++ b/scripts/validate-desktop-release-env.mjs @@ -0,0 +1,34 @@ +import { fileURLToPath } from "node:url"; + +const REQUIRED_SECRETS = [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "APPLE_ID", + "APPLE_APP_SPECIFIC_PASSWORD", + "APPLE_TEAM_ID", +]; + +export function missingDesktopReleaseSecrets(env) { + return REQUIRED_SECRETS.filter((name) => !String(env[name] ?? "").trim()); +} + +function main() { + const missing = missingDesktopReleaseSecrets(process.env); + if (missing.length === 0) { + console.log("Desktop release signing/notarization environment is configured."); + return; + } + + console.error( + [ + "Desktop release signing/notarization is not configured.", + `Missing required environment variables: ${missing.join(", ")}`, + "Configure these values as GitHub Actions secrets before publishing stable macOS assets.", + ].join("\n"), + ); + process.exitCode = 1; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/verify-desktop-release-assets.mjs b/scripts/verify-desktop-release-assets.mjs new file mode 100644 index 000000000..f2e60bce4 --- /dev/null +++ b/scripts/verify-desktop-release-assets.mjs @@ -0,0 +1,63 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function hasVersionedArm64Asset(files, version, extension) { + return files.some( + (file) => + file.endsWith(extension) && + file.includes(version) && + file.toLowerCase().includes("arm64"), + ); +} + +export async function missingDesktopReleaseAssets({ distDir, version }) { + const files = await readdir(distDir); + const missing = []; + + if (!hasVersionedArm64Asset(files, version, ".dmg")) { + missing.push("versioned arm64 DMG"); + } + + if (!hasVersionedArm64Asset(files, version, ".dmg.blockmap")) { + missing.push("versioned arm64 DMG blockmap"); + } + + if (!hasVersionedArm64Asset(files, version, ".zip")) { + missing.push("versioned arm64 ZIP"); + } + + if (!hasVersionedArm64Asset(files, version, ".zip.blockmap")) { + missing.push("versioned arm64 ZIP blockmap"); + } + + if (!files.includes("latest-mac.yml")) { + missing.push("latest-mac.yml"); + } + + return missing; +} + +async function main() { + const version = process.argv[2]; + if (!version) { + throw new Error("Usage: node scripts/verify-desktop-release-assets.mjs "); + } + + const distDir = path.join(process.cwd(), "frontend", "dist-desktop"); + const missing = await missingDesktopReleaseAssets({ distDir, version }); + + if (missing.length === 0) { + console.log(`Verified versioned macOS release assets for ${version}.`); + return; + } + + throw new Error(`Missing desktop release artifacts: ${missing.join(", ")}`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +}