diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6903b830 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..83cd26e2 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.2.11" +} diff --git a/package.json b/package.json index b46eef3d..0d5dcd2d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..4f035caa --- /dev/null +++ b/release-please-config.json @@ -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 + } + } +} diff --git a/scripts/check-package.ts b/scripts/check-package.ts new file mode 100644 index 00000000..d9704b8c --- /dev/null +++ b/scripts/check-package.ts @@ -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).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`);