Skip to content
Merged
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
73 changes: 40 additions & 33 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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")"

Expand All @@ -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/') }}
Expand Down
134 changes: 134 additions & 0 deletions scripts/publish-packages.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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");
});
75 changes: 75 additions & 0 deletions scripts/publish-packages.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

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<string>();
for (const workspace of workspaces) {
for (const path of new Bun.Glob(`${workspace}/package.json`).scanSync(root)) {
paths.add(path);
}
}

const packages = new Map<string, { dir: string; manifest: Manifest }>();
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<string>();
const visited = new Set<string>();
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 <version>");
console.log(getPublishPackages(join(import.meta.dir, ".."), version).join("\n"));
}
Loading