From 1381f83af7f0a2955e0dbbee6d4c5e0f8719e061 Mon Sep 17 00:00:00 2001 From: Vladimir Haltakov Date: Sun, 14 Jun 2026 02:26:07 +0200 Subject: [PATCH] fix release packaging for workspace dependencies --- common/CHANGELOG.md | 6 + common/package.json | 2 +- gallery/CHANGELOG.md | 9 ++ gallery/package.json | 2 +- package.json | 3 +- scripts/release.mjs | 321 +++++++++++++++++++++++++++++++++++++ themes/modern/CHANGELOG.md | 6 + themes/modern/package.json | 2 +- 8 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 scripts/release.mjs diff --git a/common/CHANGELOG.md b/common/CHANGELOG.md index a3474f8..26ad6d1 100644 --- a/common/CHANGELOG.md +++ b/common/CHANGELOG.md @@ -1,5 +1,11 @@ # @simple-photo-gallery/common +## 2.1.9 + +### Patch Changes + +- Fix release packaging so published npm packages no longer contain raw `workspace:` dependency ranges. + ## 2.1.8 ### Patch Changes diff --git a/common/package.json b/common/package.json index 490da2f..3014a5e 100644 --- a/common/package.json +++ b/common/package.json @@ -1,6 +1,6 @@ { "name": "@simple-photo-gallery/common", - "version": "2.1.8", + "version": "2.1.9", "description": "Shared utilities and types for Simple Photo Gallery", "license": "MIT", "author": "Vladimir Haltakov, Tomasz Rusin", diff --git a/gallery/CHANGELOG.md b/gallery/CHANGELOG.md index 6dce7d2..0460177 100644 --- a/gallery/CHANGELOG.md +++ b/gallery/CHANGELOG.md @@ -1,5 +1,14 @@ # simple-photo-gallery +## 2.1.9 + +### Patch Changes + +- Fix release packaging so published npm packages no longer contain raw `workspace:` dependency ranges. +- Updated dependencies + - @simple-photo-gallery/common@2.1.9 + - @simple-photo-gallery/theme-modern@2.1.9 + ## 2.1.8 ### Patch Changes diff --git a/gallery/package.json b/gallery/package.json index 00c597b..4a6b974 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -1,6 +1,6 @@ { "name": "simple-photo-gallery", - "version": "2.1.8", + "version": "2.1.9", "description": "Simple Photo Gallery CLI", "license": "MIT", "author": "Vladimir Haltakov, Tomasz Rusin", diff --git a/package.json b/package.json index 181ad07..d9d5db9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "gallery": "yarn workspace simple-photo-gallery gallery", "changeset": "changeset", "version-packages": "changeset version", - "release": "changeset publish" + "release": "node scripts/release.mjs", + "release:dry-run": "node scripts/release.mjs --dry-run" }, "workspaces": { "packages": [ diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..4f7e437 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const dependencyFields = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const options = parseOptions(process.argv.slice(2)); +const rootPackageJson = readJson(join(rootDir, "package.json")); +const changesetConfig = readJson(join(rootDir, ".changeset", "config.json")); +const packages = sortPackages(loadWorkspacePackages(rootPackageJson)); +const archiveDir = mkdtempSync(join(tmpdir(), "simple-photo-gallery-release-")); + +let releasedPackages = 0; + +for (const pkg of packages) { + const tagName = `${pkg.packageJson.name}@${pkg.packageJson.version}`; + + if (!options.dryRun && packageVersionExists(pkg.packageJson)) { + console.log( + `${pkg.packageJson.name}@${pkg.packageJson.version} is already published on npm`, + ); + createTagIfNeeded(tagName, { dryRun: false, emitNewTag: true }); + continue; + } + + const archivePath = join( + archiveDir, + `${pkg.packageJson.name.replace(/^@/, "").replace("/", "-")}-${pkg.packageJson.version}.tgz`, + ); + + run("yarn", [ + "workspace", + pkg.packageJson.name, + "pack", + "--out", + archivePath, + ]); + + validatePackedManifest(archivePath, pkg); + + if (options.dryRun) { + console.log( + `[dry-run] Would publish ${pkg.packageJson.name}@${pkg.packageJson.version}`, + ); + continue; + } + + run("npm", [ + "publish", + archivePath, + "--access", + options.access ?? changesetConfig.access ?? "public", + "--tag", + options.tag, + ]); + + createTagIfNeeded(tagName, { dryRun: false, emitNewTag: true }); + releasedPackages += 1; +} + +if (options.dryRun) { + console.log(`Dry run complete. Packed ${packages.length} publishable packages.`); +} else if (releasedPackages === 0) { + console.log("No unpublished packages were published."); +} + +function parseOptions(args) { + const parsed = { + access: undefined, + dryRun: false, + tag: "latest", + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === "--dry-run") { + parsed.dryRun = true; + continue; + } + + if (arg === "--access") { + parsed.access = readOptionValue(args, index, arg); + index += 1; + continue; + } + + if (arg === "--tag") { + parsed.tag = readOptionValue(args, index, arg); + index += 1; + continue; + } + + throw new Error(`Unknown release option: ${arg}`); + } + + return parsed; +} + +function readOptionValue(args, index, name) { + const value = args[index + 1]; + + if (!value || value.startsWith("--")) { + throw new Error(`Missing value for ${name}`); + } + + return value; +} + +function loadWorkspacePackages(packageJson) { + const workspacePatterns = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces?.packages; + + if (!Array.isArray(workspacePatterns)) { + throw new Error("Root package.json does not define workspaces"); + } + + return workspacePatterns + .flatMap(expandWorkspacePattern) + .map((workspaceDir) => { + const packageJsonPath = join(workspaceDir, "package.json"); + + if (!existsSync(packageJsonPath)) { + throw new Error(`Workspace package.json not found: ${packageJsonPath}`); + } + + return { + dir: workspaceDir, + packageJson: readJson(packageJsonPath), + }; + }) + .filter((pkg) => !pkg.packageJson.private); +} + +function expandWorkspacePattern(pattern) { + if (!pattern.includes("*")) { + return [resolve(rootDir, pattern)]; + } + + if (!pattern.endsWith("/*") || pattern.slice(0, -2).includes("*")) { + throw new Error(`Unsupported workspace pattern: ${pattern}`); + } + + const baseDir = resolve(rootDir, pattern.slice(0, -2)); + + return readdirSync(baseDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(baseDir, entry.name)) + .filter((workspaceDir) => existsSync(join(workspaceDir, "package.json"))); +} + +function sortPackages(workspacePackages) { + const packagesByName = new Map( + workspacePackages.map((pkg) => [pkg.packageJson.name, pkg]), + ); + const sorted = []; + const visiting = new Set(); + const visited = new Set(); + + for (const pkg of workspacePackages) { + visit(pkg); + } + + return sorted; + + function visit(pkg) { + const name = pkg.packageJson.name; + + if (visited.has(name)) { + return; + } + + if (visiting.has(name)) { + throw new Error(`Workspace dependency cycle detected at ${name}`); + } + + visiting.add(name); + + for (const field of dependencyFields) { + for (const dependencyName of Object.keys(pkg.packageJson[field] ?? {})) { + const dependency = packagesByName.get(dependencyName); + + if (dependency) { + visit(dependency); + } + } + } + + visiting.delete(name); + visited.add(name); + sorted.push(pkg); + } +} + +function packageVersionExists(packageJson) { + const result = spawnSync( + "npm", + ["view", `${packageJson.name}@${packageJson.version}`, "version", "--json"], + { + cwd: rootDir, + encoding: "utf8", + }, + ); + const output = `${result.stdout}\n${result.stderr}`; + + if (result.status === 0) { + return true; + } + + if (output.includes("E404") || output.includes("No match found")) { + return false; + } + + throw new Error( + `Could not check npm for ${packageJson.name}@${packageJson.version}:\n${output}`, + ); +} + +function validatePackedManifest(archivePath, pkg) { + const packedPackageJson = JSON.parse( + execFileSync("tar", ["-xOf", archivePath, "package/package.json"], { + cwd: rootDir, + encoding: "utf8", + }), + ); + + for (const field of dependencyFields) { + for (const [dependencyName, range] of Object.entries( + packedPackageJson[field] ?? {}, + )) { + if (typeof range === "string" && range.startsWith("workspace:")) { + throw new Error( + `${pkg.packageJson.name} pack output still contains ${field}.${dependencyName}: ${range}`, + ); + } + } + } +} + +function createTagIfNeeded(tagName, { dryRun, emitNewTag }) { + if (remoteTagExists(tagName)) { + console.log(`Tag already exists on origin: ${tagName}`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would create tag ${tagName}`); + return; + } + + if (localTagExists(tagName)) { + ensureLocalTagPointsAtHead(tagName); + } else { + run("git", ["tag", tagName]); + } + + if (emitNewTag) { + console.log(`New tag: ${tagName}`); + } +} + +function remoteTagExists(tagName) { + const result = spawnSync( + "git", + ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tagName}`], + { + cwd: rootDir, + encoding: "utf8", + }, + ); + + return result.status === 0; +} + +function localTagExists(tagName) { + const result = spawnSync("git", ["rev-parse", "--verify", tagName], { + cwd: rootDir, + encoding: "utf8", + }); + + return result.status === 0; +} + +function ensureLocalTagPointsAtHead(tagName) { + const head = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: rootDir, + encoding: "utf8", + }).trim(); + const taggedCommit = execFileSync("git", ["rev-list", "-n", "1", tagName], { + cwd: rootDir, + encoding: "utf8", + }).trim(); + + if (head !== taggedCommit) { + throw new Error( + `Local tag ${tagName} exists but points at ${taggedCommit}, not HEAD ${head}`, + ); + } +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function run(command, args) { + execFileSync(command, args, { + cwd: rootDir, + stdio: "inherit", + }); +} diff --git a/themes/modern/CHANGELOG.md b/themes/modern/CHANGELOG.md index 3c8e8e8..f459751 100644 --- a/themes/modern/CHANGELOG.md +++ b/themes/modern/CHANGELOG.md @@ -1,5 +1,11 @@ # @simple-photo-gallery/theme-modern +## 2.1.9 + +### Patch Changes + +- Fix release packaging so published npm packages no longer contain raw `workspace:` dependency ranges. + ## 2.1.8 ### Patch Changes diff --git a/themes/modern/package.json b/themes/modern/package.json index 9478beb..20cac07 100644 --- a/themes/modern/package.json +++ b/themes/modern/package.json @@ -1,6 +1,6 @@ { "name": "@simple-photo-gallery/theme-modern", - "version": "2.1.8", + "version": "2.1.9", "description": "Modern theme for Simple Photo Gallery", "license": "MIT", "author": "Vladimir Haltakov, Tomasz Rusin",