Skip to content
Open
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
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:

jobs:
release:
runs-on: ubuntu-latest
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -26,14 +26,22 @@ 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, 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 \
-p @semantic-release/commit-analyzer \
-p @semantic-release/exec \
-p @semantic-release/release-notes-generator \
-p @semantic-release/github \
-p conventional-changelog-conventionalcommits@8 \
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ 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.

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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +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 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",
Expand Down
35 changes: 33 additions & 2 deletions release.config.cjs
Original file line number Diff line number Diff line change
@@ -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, builds signed desktop
* artifacts, and then attaches those artifacts to the GitHub release.
* @type {import("semantic-release").GlobalConfig}
*/
module.exports = {
Expand Down Expand Up @@ -43,6 +45,35 @@ module.exports = {
},
},
],
"@semantic-release/github",
[
"@semantic-release/exec",
{
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",
},
],
},
],
],
};
65 changes: 65 additions & 0 deletions scripts/apply-release-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <version>");
}

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;
});
}
84 changes: 84 additions & 0 deletions scripts/test-apply-release-version.mjs
Original file line number Diff line number Diff line change
@@ -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",
);
});
39 changes: 39 additions & 0 deletions scripts/test-validate-desktop-release-env.mjs
Original file line number Diff line number Diff line change
@@ -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",
}),
[],
);
});
45 changes: 45 additions & 0 deletions scripts/test-verify-desktop-release-assets.mjs
Original file line number Diff line number Diff line change
@@ -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",
]);
});
34 changes: 34 additions & 0 deletions scripts/validate-desktop-release-env.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading