|
| 1 | +#!/usr/bin/env bun |
| 2 | +import { $ } from "bun"; |
| 3 | +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { extname, join } from "node:path"; |
| 6 | + |
| 7 | +// Publishes the workspace's public packages to GitHub Packages under a fork |
| 8 | +// scope, WITHOUT changing the @pascal-app/* source. The source tree stays |
| 9 | +// byte-identical to upstream so merges/rebases stay clean; the rename happens |
| 10 | +// only inside the published tarball. |
| 11 | +// |
| 12 | +// Names are rescoped AND re-prefixed: `@pascal-app/<x>` -> `${SCOPE}/pascal-<x>` |
| 13 | +// (e.g. @pascal-app/viewer -> @jrolfs/pascal-viewer). The `pascal-` prefix keeps |
| 14 | +// the published names meaningful outside the upstream scope, where bare names |
| 15 | +// like `core`/`viewer` would be too generic. |
| 16 | +// |
| 17 | +// Per package: `bun pm pack` (which resolves `workspace:` ranges to concrete |
| 18 | +// versions) -> rewrite `@pascal-app/` to `${TARGET_PREFIX}` across every file in |
| 19 | +// the tarball -> `npm publish`. The rewrite must touch the emitted code too, not |
| 20 | +// just package.json: a consumer installs `${SCOPE}/pascal-core`, so the `import` |
| 21 | +// specifiers in dist/** (and editor's shipped src) have to match. |
| 22 | +// |
| 23 | +// To target a different scope (e.g. @meterup) set PUBLISH_SCOPE — that is the |
| 24 | +// only thing that changes. Auth comes from .npmrc (NODE_AUTH_TOKEN). |
| 25 | + |
| 26 | +const SOURCE_PREFIX = "@pascal-app/"; |
| 27 | +const SCOPE = process.env.PUBLISH_SCOPE ?? "@jrolfs"; |
| 28 | +const NAME_PREFIX = "pascal-"; // always prefix published names under the fork scope |
| 29 | +const TARGET_PREFIX = `${SCOPE}/${NAME_PREFIX}`; // e.g. "@jrolfs/pascal-" |
| 30 | +const REGISTRY = "https://npm.pkg.github.com"; |
| 31 | +const DRY_RUN = process.argv.includes("--dry-run"); |
| 32 | +const IN_CI = process.env.GITHUB_ACTIONS === "true"; |
| 33 | + |
| 34 | +// Files whose contents may reference the scope. Note extname("x.d.ts") === ".ts". |
| 35 | +const TEXT_EXT = new Set([ |
| 36 | + ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts", ".tsx", ".jsx", ".json", ".md", ".map", |
| 37 | +]); |
| 38 | + |
| 39 | +/** Rewrite every `@pascal-app/` token to `${TARGET_PREFIX}` across a packed package. */ |
| 40 | +const rewriteScope = (dir: string): void => { |
| 41 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 42 | + const path = join(dir, entry.name); |
| 43 | + if (entry.isDirectory()) { |
| 44 | + rewriteScope(path); |
| 45 | + continue; |
| 46 | + } |
| 47 | + if (!TEXT_EXT.has(extname(entry.name))) continue; |
| 48 | + const before = readFileSync(path, "utf8"); |
| 49 | + if (!before.includes(SOURCE_PREFIX)) continue; |
| 50 | + writeFileSync(path, before.replaceAll(SOURCE_PREFIX, TARGET_PREFIX)); |
| 51 | + } |
| 52 | +}; |
| 53 | + |
| 54 | +const newTags: string[] = []; |
| 55 | + |
| 56 | +for (const dir of readdirSync("packages")) { |
| 57 | + let manifest: { name?: string; version?: string; private?: boolean }; |
| 58 | + try { |
| 59 | + manifest = JSON.parse(readFileSync(join("packages", dir, "package.json"), "utf8")); |
| 60 | + } catch { |
| 61 | + continue; // not a package directory |
| 62 | + } |
| 63 | + if (manifest.private === true || !manifest.name?.startsWith(SOURCE_PREFIX)) continue; |
| 64 | + |
| 65 | + const tag = `${manifest.name.replace(SOURCE_PREFIX, TARGET_PREFIX)}@${manifest.version}`; |
| 66 | + |
| 67 | + const exists = await $`npm view ${tag} version --registry ${REGISTRY}`.quiet().nothrow(); |
| 68 | + if (exists.exitCode === 0) { |
| 69 | + console.log(`→ ${tag} already published, skipping`); |
| 70 | + continue; |
| 71 | + } |
| 72 | + |
| 73 | + // Pack first — bun resolves `workspace:` ranges to concrete versions here. |
| 74 | + const work = mkdtempSync(join(tmpdir(), "publish-fork-")); |
| 75 | + try { |
| 76 | + await $`bun pm pack --destination ${work}`.cwd(join("packages", dir)).quiet(); |
| 77 | + const tarball = readdirSync(work).find((file) => file.endsWith(".tgz")); |
| 78 | + if (!tarball) throw new Error(`pack produced no tarball for ${manifest.name}`); |
| 79 | + await $`tar -xzf ${join(work, tarball)} -C ${work}`.quiet(); |
| 80 | + |
| 81 | + const packed = join(work, "package"); |
| 82 | + rewriteScope(packed); |
| 83 | + |
| 84 | + if (DRY_RUN) { |
| 85 | + console.log(`→ [dry-run] ${tag}`); |
| 86 | + continue; |
| 87 | + } |
| 88 | + |
| 89 | + console.log(`→ Publishing ${tag}`); |
| 90 | + await $`npm publish ${packed} --ignore-scripts --registry ${REGISTRY}`; |
| 91 | + newTags.push(tag); |
| 92 | + console.log(`New tag: ${tag}`); // changesets/action parses this for the GitHub Release |
| 93 | + } finally { |
| 94 | + rmSync(work, { recursive: true, force: true }); |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +// Tag the published versions under the fork scope so GitHub Releases attach to |
| 99 | +// real refs named after what was actually published (not the @pascal-app source). |
| 100 | +if (IN_CI && newTags.length > 0) { |
| 101 | + for (const tag of newTags) await $`git tag ${tag}`.nothrow(); |
| 102 | + await $`git push origin ${newTags}`.nothrow(); |
| 103 | +} |
0 commit comments