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
101 changes: 101 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Release

# Release PR -> merge -> publish.
#
# release-please keeps a version-bump + changelog PR open against master. Merging
# it tags the release and triggers the publish job below. Nothing publishes until
# that PR is merged, so this workflow is inert on ordinary pushes.
#
# Publishing uses npm Trusted Publishing (OIDC): there is no NPM_TOKEN anywhere.
# It requires a one-time setup on npmjs.com —
# @pathscale/ui -> Settings -> Trusted Publisher -> GitHub Actions
# repository: pathscale/ui, workflow: release.yml
# Until that is configured the publish step fails closed; it cannot publish
# unsigned or unauthenticated.

on:
push:
branches: [master]

permissions:
contents: read

jobs:
release-please:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
# Version baseline comes from .release-please-manifest.json, not from git
# tags: the newest tag here is v1.1.51 while npm is on 1.2.11, so tag-derived
# versioning would propose a version below what is already published.
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for OIDC trusted publishing + provenance
steps:
- uses: actions/checkout@v4

# Mirrors ci.yml — the build resolves this plugin from a sibling directory.
- name: Checkout rsbuild-plugin-ui-css-purge
uses: actions/checkout@v4
with:
repository: pathscale/rsbuild-plugin-ui-css-purge
path: rsbuild-plugin-ui-css-purge

- name: Symlink plugin to expected path
run: ln -s $GITHUB_WORKSPACE/rsbuild-plugin-ui-css-purge ../rsbuild-plugin-ui-css-purge

- uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org

# Trusted publishing needs npm >= 11.5.1.
- name: Ensure npm supports trusted publishing
run: |
npm install -g npm@latest
npm --version

- name: Install dependencies
run: bun install

- name: Contract check
run: bun run check

- name: Type check
run: npx tsc --noEmit

- name: Build
run: bun run build

# ---- pre-publish gates. A version can never be reused; fail before shipping.

- name: Package check (exports and README resolve against the tarball)
run: bun run check:package

- name: publint (publish config, exports, file paths)
run: npx --yes publint --strict

- name: Are the types wrong? (consumer type resolution)
run: npx --yes @arethetypeswrong/cli --pack --ignore-rules cjs-resolves-to-esm

- name: Publish to npm
run: npm publish --provenance --access public
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.2.11"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"format": "bun biome format --write",
"lint": "bun biome lint --write",
"check": "bun run scripts/check-contracts.ts",
"check:package": "bun run scripts/check-package.ts",
"playground:dev": "cd playground && bun run dev",
"playground:build": "cd playground && bun run build",
"playground:preview": "cd playground && bun run preview"
Expand Down
11 changes: 11 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"package-name": "@pathscale/ui",
"changelog-path": "CHANGELOG.md",
"include-component-in-tag": false
}
}
}
129 changes: 129 additions & 0 deletions scripts/check-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Pre-publish gate: verify the package we are about to ship is internally consistent.
*
* Packs the tarball and checks it against the two things that have actually broken here:
*
* 1. Every subpath in `exports` resolves to a file that is really in the tarball.
* (`./stores` was declared for months while `dist/stores/` was never shipped.)
* 2. Every `@pathscale/ui/...` import in the README resolves.
* (The README told people to import a compat stylesheet that does not exist.)
*
* Run: bun run check:package
*/
import { execSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";

type Failure = { rule: string; detail: string };
const failures: Failure[] = [];

// ---------------------------------------------------------------- pack

const packLine = execSync("npm pack --json --silent", { encoding: "utf8" });
const tarball: string = JSON.parse(packLine)[0].filename;

// Paths inside the tarball are prefixed with `package/`.
const entries = new Set(
execSync(`tar tzf ${tarball}`, { encoding: "utf8" })
.split("\n")
.filter(Boolean)
.map((l) => l.replace(/^package\//, "").replace(/\/$/, "")),
);

const shipped = (rel: string) => entries.has(rel.replace(/^\.\//, ""));

/**
* A wildcard target is satisfied if anything in the tarball matches its shape.
* Note `*` in an `exports` target matches across path segments — it is not a
* single-segment glob — so it maps to `.+`, not `[^/]+`.
*/
const shippedGlob = (pattern: string) => {
const rel = pattern.replace(/^\.\//, "");
const rx = new RegExp(
`^${rel.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".+")}$`,
);
for (const e of entries) if (rx.test(e)) return true;
return false;
};

// ------------------------------------------------------- exports resolve

const pkg = JSON.parse(readFileSync("package.json", "utf8"));

const targetsOf = (value: unknown): string[] => {
if (typeof value === "string") return [value];
if (value && typeof value === "object")
return Object.values(value as Record<string, unknown>).flatMap(targetsOf);
return [];
};

for (const [subpath, value] of Object.entries(pkg.exports ?? {})) {
const missing = targetsOf(value).filter(
(target) => !(target.includes("*") ? shippedGlob(target) : shipped(target)),
);
if (missing.length > 0) {
failures.push({
rule: "exports entry does not resolve",
detail: `"${subpath}" is declared in package.json but ${missing.join(", ")} ${
missing.length > 1 ? "are" : "is"
} not in the tarball`,
});
}
}

// -------------------------------------------------- README imports resolve

if (existsSync("README.md")) {
const readme = readFileSync("README.md", "utf8");
const specifiers = new Set(
[...readme.matchAll(/["'`](@pathscale\/ui(?:\/[^"'`\s]+)?)["'`]/g)].map((m) => m[1]),
);

for (const spec of specifiers) {
const subpath = spec === pkg.name ? "." : `./${spec.slice(pkg.name.length + 1)}`;

// Find the exports key that would match, honouring a single wildcard segment.
const key = Object.keys(pkg.exports ?? {}).find((k) => {
if (k === subpath) return true;
if (!k.includes("*")) return false;
const [head, tail] = k.split("*");
return subpath.startsWith(head) && subpath.endsWith(tail);
});

if (!key) {
failures.push({
rule: "README imports an unexported subpath",
detail: `${spec} is not covered by any "exports" key`,
});
continue;
}

// Resolve the concrete target and confirm the file ships.
const wildcard = key.includes("*")
? subpath.slice(key.split("*")[0].length, subpath.length - key.split("*")[1].length)
: null;

const resolved = targetsOf(pkg.exports[key]).map((t) =>
wildcard ? t.replace("*", wildcard) : t,
);

if (resolved.length && !resolved.some(shipped)) {
failures.push({
rule: "README imports a file that is not shipped",
detail: `${spec} -> ${resolved.join(", ")} missing from the tarball`,
});
}
}
}

// ---------------------------------------------------------------- report

rmSync(tarball, { force: true });

if (failures.length > 0) {
console.error(`\n✖ package check failed — ${failures.length} problem(s)\n`);
for (const f of failures) console.error(` ${f.rule}\n ${f.detail}\n`);
console.error("These would ship to npm and cannot be unpublished. Fix before releasing.\n");
process.exit(1);
}

console.log(`✔ package check passed — ${entries.size} files, all exports and README imports resolve`);
Loading