Skip to content

Commit 5b3774e

Browse files
jrolfsclaude
andcommitted
chore(fork): publish to GitHub Packages via publish-time rescope
Keeps the @pascal-app/* source byte-identical to upstream so merges/rebases stay clean — instead of renaming the source tree, the rename happens only in the published artifact. GitHub Packages requires the npm scope to match the owning account/org, so this lets the fork ship under its own scope without a 644-file rename. scripts/publish-fork.ts: for each public package, `bun pm pack` (resolves workspace: ranges to concrete versions), rewrite @pascal-app/<x> -> ${SCOPE}/pascal-<x> across the whole tarball (package.json AND emitted dist import specifiers, since consumers install ${SCOPE}/pascal-core), then `npm publish` to GitHub Packages. Published names are re-prefixed with `pascal-` so they stay meaningful outside the upstream scope (bare core/viewer are too generic). It emits "New tag: ${SCOPE}/pascal-pkg@version" lines and tags under the published name so changesets/action cuts releases named after the fork scope, not @pascal-app. Scoped to @jrolfs (personal) for testing; set PUBLISH_SCOPE to retarget (e.g. @meterup) — that env var is the only thing that changes. - ci:publish -> scripts/publish-fork.ts (overrides the upstream npm publish). - .npmrc: GitHub Packages auth (NODE_AUTH_TOKEN), registry-agnostic. - release.yml: add packages:write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c6c390e commit 5b3774e

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
99
permissions:
1010
contents: write
1111
pull-requests: write
12+
packages: write
1213

1314
jobs:
1415
release:

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"restart": "bun kill && bun clean:cache && bun dev",
1717
"changeset": "changeset",
1818
"ci:version": "changeset version && bun install",
19-
"ci:publish": "bun run scripts/publish.ts"
19+
"ci:publish": "bun run scripts/publish-fork.ts"
2020
},
2121
"devDependencies": {
2222
"@biomejs/biome": "^2.4.6",

scripts/publish-fork.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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

Comments
 (0)