From 3718234aea795ed6309f41ee64bd999d60ea5e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Dobies?= Date: Thu, 24 Sep 2026 13:06:11 +0200 Subject: [PATCH] fix: publish all public workspaces and resume partial releases --- .github/workflows/publish.yml | 73 +++++++++-------- scripts/publish-packages.test.ts | 134 +++++++++++++++++++++++++++++++ scripts/publish-packages.ts | 75 +++++++++++++++++ 3 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 scripts/publish-packages.test.ts create mode 100644 scripts/publish-packages.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f65c4f1f..7a70e3f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,7 +36,21 @@ jobs: - name: Build packages run: bun run build + - name: Plan release packages + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + RELEASE_VERSION="${GITHUB_REF#refs/tags/v}" + else + RELEASE_VERSION=$(jq -r .version packages/core/package.json) + fi + PUBLISH_PLAN="$RUNNER_TEMP/publish-packages.txt" + echo "PUBLISH_PLAN=$PUBLISH_PLAN" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_ENV" + bun run scripts/publish-packages.ts "$RELEASE_VERSION" > "$PUBLISH_PLAN" + - name: Setup npm auth + if: ${{ !inputs.dry_run }} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | @@ -57,12 +71,6 @@ jobs: PUBLISH_VERIFY_ATTEMPTS="${PUBLISH_VERIFY_ATTEMPTS:-30}" PUBLISH_VERIFY_SLEEP_SECONDS="${PUBLISH_VERIFY_SLEEP_SECONDS:-10}" - # Resolve release version: from tag (e.g. v0.2.10 -> 0.2.10) or from core's package.json - if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - RELEASE_VERSION="${GITHUB_REF#refs/tags/v}" - else - RELEASE_VERSION=$(jq -r .version packages/core/package.json) - fi echo "Using release version: $RELEASE_VERSION" echo "Using npm registry: $NPM_REGISTRY" @@ -141,6 +149,26 @@ jobs: # Helper function: pack with bun, rewrite workspace deps, validate packed manifest, then publish extracted contents. publish_pkg() { local dir="$1" + local pkg_name + local pkg_version + local pkg_encoded + local status_code + pkg_name="$(jq -r .name "$dir/package.json")" + pkg_version="$(jq -r .version "$dir/package.json")" + pkg_encoded="$(jq -nr --arg v "$pkg_name" '$v|@uri')" + status_code="$(curl -sS -o /dev/null -w "%{http_code}" "${NPM_REGISTRY}/${pkg_encoded}/${pkg_version}")" + case "$status_code" in + 200) + echo "Skipping ${pkg_name}@${pkg_version}: already published" + return 0 + ;; + 404) ;; + *) + echo "ERROR: Could not check ${pkg_name}@${pkg_version} (HTTP ${status_code})" + return 1 + ;; + esac + pushd "$dir" >/dev/null rm -f ./*.tgz @@ -161,8 +189,6 @@ jobs: # Replace internal workspace:* deps with the release version. rewrite_workspace_data_slot_deps "$tmp/package/package.json" - local pkg_name - local pkg_version pkg_name="$(jq -r .name "$tmp/package/package.json")" pkg_version="$(jq -r .version "$tmp/package/package.json")" @@ -181,39 +207,20 @@ jobs: popd >/dev/null } - # Publish in dependency order (core first) - publish_pkg packages/core - publish_pkg packages/accordion - publish_pkg packages/dialog - publish_pkg packages/drawer - publish_pkg packages/alert-dialog - publish_pkg packages/collapsible - publish_pkg packages/navigation-menu - publish_pkg packages/popover - publish_pkg packages/tabs - publish_pkg packages/tooltip - publish_pkg packages/dropdown-menu - publish_pkg packages/slider - publish_pkg packages/toggle - publish_pkg packages/toggle-group - publish_pkg packages/select - publish_pkg packages/combobox - publish_pkg packages/hover-card - publish_pkg packages/command - publish_pkg packages/switch - publish_pkg packages/radio-group - - publish_pkg packages/ui + while IFS= read -r dir; do + publish_pkg "$dir" + done < "$PUBLISH_PLAN" - name: Dry run - show what would be published if: ${{ inputs.dry_run }} run: | + set -euo pipefail echo "Would publish the following packages:" - for pkg in packages/*/; do + while IFS= read -r pkg; do name=$(jq -r .name "$pkg/package.json") version=$(jq -r .version "$pkg/package.json") echo " $name@$version" - done + done < "$PUBLISH_PLAN" - name: Generate Release Notes if: ${{ !inputs.dry_run && startsWith(github.ref, 'refs/tags/') }} diff --git a/scripts/publish-packages.test.ts b/scripts/publish-packages.test.ts new file mode 100644 index 00000000..edb55acd --- /dev/null +++ b/scripts/publish-packages.test.ts @@ -0,0 +1,134 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { getPublishPackages } from "./publish-packages"; + +const temporaryRoots: string[] = []; +const repoRoot = join(import.meta.dir, ".."); +const workflow = readFileSync(join(repoRoot, ".github/workflows/publish.yml"), "utf8"); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "publish-packages-")); + temporaryRoots.push(root); + writeFileSync(join(root, "package.json"), JSON.stringify({ private: true, workspaces: ["packages/*"] })); + return root; +} + +function addPackage(root: string, name: string, overrides: Record = {}): void { + const dir = join(root, "packages", name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ + name: `@data-slot/${name}`, version: "1.0.0", ...overrides, + })); +} + +function workflowScript(step: string): string { + const section = workflow.split(` - name: ${step}\n`)[1]?.split("\n - name:")[0]; + const script = section?.split(" run: |\n")[1]; + if (!script) throw new Error(`Missing workflow step: ${step}`); + return script.replace(/^ /gm, ""); +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +test("new workspaces are discovered and ordered after all internal runtime dependencies", () => { + const root = fixture(); + addPackage(root, "a-ui", { dependencies: { "@data-slot/new-component": "workspace:*" } }); + addPackage(root, "new-component", { optionalDependencies: { "@data-slot/z-core": "workspace:*" } }); + addPackage(root, "z-core", { peerDependencies: { "@data-slot/zz-base": "workspace:*", external: "^1" } }); + addPackage(root, "zz-base"); + addPackage(root, "private", { private: true, version: "0.0.0" }); + expect(getPublishPackages(root, "1.0.0")).toEqual([ + "packages/zz-base", "packages/z-core", "packages/new-component", "packages/a-ui", + ]); +}); + +test("missing or private internal dependencies fail before any package is published", () => { + const root = fixture(); + addPackage(root, "ui", { dependencies: { "@data-slot/missing": "workspace:*" } }); + expect(() => getPublishPackages(root, "1.0.0")).toThrow("not a public workspace"); + addPackage(root, "missing", { private: true }); + expect(() => getPublishPackages(root, "1.0.0")).toThrow("not a public workspace"); +}); + +test("cyclic dependencies and mismatched release versions fail before publishing", () => { + const root = fixture(); + addPackage(root, "a", { dependencies: { "@data-slot/b": "workspace:*" } }); + addPackage(root, "b", { dependencies: { "@data-slot/a": "workspace:*" } }); + expect(() => getPublishPackages(root, "1.0.0")).toThrow("Circular package dependency"); + expect(() => getPublishPackages(root, "1.0.1")).toThrow("expected version 1.0.1"); +}); + +test("the real release plan includes the omitted packages and the dry run reads that plan", () => { + const version = JSON.parse(readFileSync(join(repoRoot, "packages/core/package.json"), "utf8")).version; + const plan = getPublishPackages(repoRoot, version); + expect(plan[0]).toBe("packages/core"); + for (const name of ["carousel", "toast", "resizable"]) expect(plan).toContain(`packages/${name}`); + expect(plan.indexOf("packages/ui")).toBeGreaterThan(plan.indexOf("packages/carousel")); + expect(plan.indexOf("packages/ui")).toBeGreaterThan(plan.indexOf("packages/toast")); + + const root = fixture(); + const planPath = join(root, "plan.txt"); + writeFileSync(planPath, plan.join("\n") + "\n"); + const result = spawnSync("bash", ["-c", workflowScript("Dry run - show what would be published")], { + cwd: repoRoot, encoding: "utf8", env: { ...process.env, PUBLISH_PLAN: planPath }, + }); + expect(result.status).toBe(0); + for (const dir of plan) expect(result.stdout).toContain(`@data-slot/${dir.split("/")[1]}@${version}`); +}); + +function runPublish(registryStatus: string) { + const root = fixture(); + addPackage(root, "core"); + // Use an older dependency version to verify the packed manifest is rewritten. + addPackage(root, "carousel", { dependencies: { "@data-slot/core": "0.9.0" } }); + addPackage(root, "ui", { dependencies: { "@data-slot/carousel": "workspace:*" } }); + const planPath = join(root, "plan.txt"); + writeFileSync(planPath, getPublishPackages(root, "1.0.0").join("\n") + "\n"); + const bin = join(root, "bin"); + mkdirSync(bin); + const commands = { + npm: 'case "$*" in "config get registry") echo https://registry.npmjs.org;; "config get dry-run") echo false;; *) exit 90;; esac', + curl: 'case "$*" in *carousel*) if [ -f "$REGISTRY_STATE" ]; then printf 200; else printf "%s" "$REGISTRY_STATUS"; fi;; *) printf 200;; esac', + bunx: `set -eu +test "$*" = "npm publish --access public --provenance --registry https://registry.npmjs.org" +test "$(jq -r '.dependencies["@data-slot/core"]' package.json)" = "1.0.0" +jq -r .name package.json >> "$PUBLISHED_LOG" +touch "$REGISTRY_STATE"`, + }; + for (const [name, script] of Object.entries(commands)) { + writeFileSync(join(bin, name), `#!/bin/bash\n${script}\n`, { mode: 0o755 }); + } + const log = join(root, "published.txt"); + writeFileSync(log, ""); + const result = spawnSync("bash", ["-c", workflowScript("Publish packages")], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, PATH: `${bin}:${process.env.PATH}`, PUBLISH_PLAN: planPath, + RELEASE_VERSION: "1.0.0", REGISTRY_STATUS: registryStatus, + REGISTRY_STATE: join(root, "available"), PUBLISHED_LOG: log, PUBLISH_VERIFY_ATTEMPTS: "1", + }, + }); + return { ...result, published: readFileSync(log, "utf8") }; +} + +test("a partial release skips existing versions, packs and publishes the missing package, and verifies it", () => { + const result = runPublish("404"); + expect({ status: result.status, stderr: result.status ? result.stderr : "" }).toEqual({ status: 0, stderr: "" }); + expect(result.published).toBe("@data-slot/carousel\n"); + expect(result.stdout).toContain("Skipping @data-slot/core@1.0.0"); + expect(result.stdout).toContain("Skipping @data-slot/ui@1.0.0"); + expect(result.stdout).toContain("[OK] @data-slot/carousel@1.0.0 is available"); +}); + +test("registry failures abort instead of treating packages as unpublished", () => { + const result = runPublish("503"); + expect(result.status).not.toBe(0); + expect(result.published).toBe(""); + expect(result.stdout).toContain("HTTP 503"); +}); diff --git a/scripts/publish-packages.ts b/scripts/publish-packages.ts new file mode 100644 index 00000000..2149c1eb --- /dev/null +++ b/scripts/publish-packages.ts @@ -0,0 +1,75 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +interface Manifest { + name: string; + version: string; + private?: boolean; + workspaces?: string[]; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +} + +function readManifest(path: string): Manifest { + return JSON.parse(readFileSync(path, "utf8")); +} + +/** Discover public workspaces and order them after their internal dependencies. */ +export function getPublishPackages(root: string, version: string): string[] { + const workspaces = readManifest(join(root, "package.json")).workspaces; + if (!workspaces?.length) throw new Error("No workspaces configured"); + + const paths = new Set(); + for (const workspace of workspaces) { + for (const path of new Bun.Glob(`${workspace}/package.json`).scanSync(root)) { + paths.add(path); + } + } + + const packages = new Map(); + for (const path of [...paths].sort()) { + const manifest = readManifest(join(root, path)); + if (manifest.private) continue; + if (manifest.version !== version) { + throw new Error(`${manifest.name}: expected version ${version}, got ${manifest.version}`); + } + if (packages.has(manifest.name)) throw new Error(`Duplicate package: ${manifest.name}`); + packages.set(manifest.name, { dir: dirname(path), manifest }); + } + if (!packages.size) throw new Error("No public packages found"); + + const ordered: string[] = []; + const visiting = new Set(); + const visited = new Set(); + function visit(name: string): void { + if (visited.has(name)) return; + if (visiting.has(name)) throw new Error(`Circular package dependency: ${name}`); + const pkg = packages.get(name); + if (!pkg) throw new Error(`Internal dependency is not a public workspace: ${name}`); + + visiting.add(name); + const dependencies = { + ...pkg.manifest.dependencies, + ...pkg.manifest.optionalDependencies, + ...pkg.manifest.peerDependencies, + }; + for (const dependency of Object.keys(dependencies).sort()) { + if (dependency === name) continue; + if (packages.has(dependency) || dependency.startsWith("@data-slot/")) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(pkg.dir); + } + for (const name of packages.keys()) visit(name); + return ordered; +} + +if (import.meta.main) { + const version = process.argv[2]; + if (!version) throw new Error("Usage: bun run scripts/publish-packages.ts "); + console.log(getPublishPackages(join(import.meta.dir, ".."), version).join("\n")); +}