diff --git a/.changeset/self-contained-release-tarball.md b/.changeset/self-contained-release-tarball.md new file mode 100644 index 00000000..c8ba4e2d --- /dev/null +++ b/.changeset/self-contained-release-tarball.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": patch +--- + +Publish GitHub Release archives with runtime dependencies included for package-manager installs. diff --git a/.github/workflows/release-tarball.yml b/.github/workflows/release-tarball.yml index 30db4b17..dcc6806e 100644 --- a/.github/workflows/release-tarball.yml +++ b/.github/workflows/release-tarball.yml @@ -1,9 +1,8 @@ name: Publish tarball to GitHub Release -# Manual fallback for the tarball distribution channel. Packs -# @anarchitecture/ghost and attaches the .tgz to a GitHub Release, so consumers can: -# -# npm install https://github.com/block/ghost/releases/download//.tgz +# Manual fallback for the tarball distribution channel. Builds a +# package-manager-friendly @anarchitecture/ghost archive with runtime +# dependencies included and attaches the .tgz to a GitHub Release. # # The normal path is release.yml, which attaches the tarball automatically on # every Changesets publish. This workflow is dispatch-only so a Changesets- @@ -46,13 +45,13 @@ jobs: - name: Build run: pnpm --filter @anarchitecture/ghost build - # `pnpm --filter pack` writes the tarball to the workspace root, - # not the package dir, in pnpm 10. Force it into a known staging dir so - # the release step can glob a single location deterministically. + # Keep this distribution archive separate from npm publishing: npm gets + # the normal package, while GitHub Releases get a self-contained archive + # for package-manager installs that should not run a registry install. - name: Pack run: | mkdir -p dist-tarball - pnpm --filter @anarchitecture/ghost pack --pack-destination "$GITHUB_WORKSPACE/dist-tarball" + node scripts/pack-release-tarball.mjs "$GITHUB_WORKSPACE/dist-tarball" ls -la dist-tarball # Inputs from workflow_dispatch are attacker-controlled (anyone with diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5acb04c0..92b3535e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,7 +70,7 @@ jobs: | python3 -c "import json,sys; pkgs=json.load(sys.stdin); print(next(p['version'] for p in pkgs if p['name']=='@anarchitecture/ghost'))")" TAG="anarchitecture-ghost@${VERSION}" mkdir -p dist-tarball - pnpm --filter @anarchitecture/ghost pack --pack-destination "$GITHUB_WORKSPACE/dist-tarball" + node scripts/pack-release-tarball.mjs "$GITHUB_WORKSPACE/dist-tarball" ls -la dist-tarball if ! gh release view "$TAG" >/dev/null 2>&1; then gh release create "$TAG" --title "$TAG" --generate-notes diff --git a/package.json b/package.json index 2754d345..b620e649 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ "test:watch": "vitest", "typecheck": "tsc --build", "build:ui": "pnpm --filter ghost-ui build", - "check": "biome check . && pnpm typecheck && pnpm check:package-bin && pnpm check:packed-package && pnpm check:file-sizes && pnpm check:terminology && pnpm check:docs && pnpm check:install-bundle && pnpm check:release-workflows && pnpm check:cli-manifest", + "check": "biome check . && pnpm typecheck && pnpm check:package-bin && pnpm check:packed-package && pnpm check:file-sizes && pnpm check:terminology && pnpm check:docs && pnpm check:install-bundle && pnpm check:release-tarball && pnpm check:release-workflows && pnpm check:cli-manifest", "check:file-sizes": "node scripts/check-file-sizes.mjs", "check:packed-package": "node scripts/check-packed-package.mjs", "check:package-bin": "chmod +x packages/ghost/dist/bin.js && node scripts/link-package-bin.mjs && node scripts/check-package-bin.mjs", "check:terminology": "node scripts/check-terminology.mjs", "check:docs": "node scripts/check-docs-frontmatter.mjs", "check:install-bundle": "node scripts/check-install-bundle.mjs", + "check:release-tarball": "node scripts/check-release-tarball.mjs", "check:release-workflows": "node scripts/check-release-workflows.mjs", "check:cli-manifest": "node scripts/dump-cli-help.mjs --check", "dump:cli-help": "node scripts/dump-cli-help.mjs", diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index e8937e80..f272013e 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -87,6 +87,12 @@ try { fail(`expected exactly one packed tarball, found ${tarballs.length}`); } const tarballPath = resolve(packDir, tarballs[0]); + const packedEntries = run("tar", ["-tzf", tarballPath]).split("\n"); + if ( + packedEntries.some((entry) => entry.startsWith("package/node_modules/")) + ) { + fail("npm package tarball must not include node_modules"); + } writeFileSync( join(consumerDir, "package.json"), diff --git a/scripts/check-release-tarball.mjs b/scripts/check-release-tarball.mjs new file mode 100644 index 00000000..672e34d1 --- /dev/null +++ b/scripts/check-release-tarball.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const ROOT = process.cwd(); +const PACKAGE_JSON = JSON.parse( + readFileSync(join(ROOT, "packages", "ghost", "package.json"), "utf8"), +); + +function fail(message) { + console.error(`check-release-tarball failed: ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + encoding: "utf8", + env: { ...process.env, ...(options.env ?? {}) }, + }); + if (result.error) { + fail( + `${command} ${args.join(" ")} failed to start: ${result.error.message}`, + ); + } + if (result.status !== 0) { + fail( + `${command} ${args.join(" ")} exited with ${result.status}\n${ + result.stderr || result.stdout + }`, + ); + } + return result.stdout.trim(); +} + +const tmpRoot = mkdtempSync(join(tmpdir(), "ghost-release-tarball-check-")); +const packDir = join(tmpRoot, "pack"); +const extractDir = join(tmpRoot, "extract"); + +try { + mkdirSync(packDir, { recursive: true }); + mkdirSync(extractDir, { recursive: true }); + + run("node", ["scripts/pack-release-tarball.mjs", packDir]); + + const expectedName = `anarchitecture-ghost-${PACKAGE_JSON.version}.tgz`; + const tarballPath = resolve(packDir, expectedName); + if (!existsSync(tarballPath)) { + fail(`expected release tarball at ${tarballPath}`); + } + + run("tar", ["-xzf", tarballPath, "-C", extractDir]); + + const packageDir = join(extractDir, "package"); + const requiredPaths = [ + "package.json", + "dist/bin.js", + "dist/cli.js", + "node_modules/cac", + "node_modules/jiti", + "node_modules/yaml", + "node_modules/zod", + ]; + for (const relativePath of requiredPaths) { + const fullPath = join(packageDir, relativePath); + if (!existsSync(fullPath)) { + fail(`release tarball is missing ${relativePath}`); + } + } + + const help = run("node", [join(packageDir, "dist", "bin.js"), "--help"], { + cwd: tmpRoot, + }); + if (!help.includes("Core workflow")) { + fail("release tarball ghost --help output did not include Core workflow"); + } + + const init = run( + "node", + [join(packageDir, "dist", "bin.js"), "init", "--format", "json"], + { cwd: tmpRoot }, + ); + const initOutput = JSON.parse(init); + if (!initOutput.manifest?.endsWith(".ghost/fingerprint/manifest.yml")) { + fail("release tarball ghost init did not emit the expected manifest path"); + } + + const topLevelEntries = readdirSync(extractDir); + if (topLevelEntries.length !== 1 || topLevelEntries[0] !== "package") { + fail("release tarball must extract to a single package/ directory"); + } + + console.log( + `check-release-tarball: ${expectedName} runs without installing dependencies`, + ); +} finally { + rmSync(tmpRoot, { recursive: true, force: true }); +} diff --git a/scripts/check-release-workflows.mjs b/scripts/check-release-workflows.mjs index c815d7b2..ece5c99d 100644 --- a/scripts/check-release-workflows.mjs +++ b/scripts/check-release-workflows.mjs @@ -16,6 +16,8 @@ function readWorkflow(name) { const releaseWorkflow = readWorkflow("release.yml"); const tarballWorkflow = readWorkflow("release-tarball.yml"); const friendlyTagAssignment = 'TAG="anarchitecture-ghost@$' + '{VERSION}"'; +const releaseTarballPackCommand = + 'node scripts/pack-release-tarball.mjs "$GITHUB_WORKSPACE/dist-tarball"'; const tapAppSecretGate = "HAS_TAP_APP: $" + "{{ secrets.BLOCK_HOMEBREW_TAP_APP_ID != '' && secrets.BLOCK_HOMEBREW_TAP_PRIVATE_KEY != '' }}"; @@ -58,6 +60,18 @@ if ( fail("release.yml must upload the packed .tgz asset to the GitHub Release"); } +if (!releaseWorkflow.includes(releaseTarballPackCommand)) { + fail( + "release.yml must publish the self-contained release tarball instead of the npm package tarball", + ); +} + +if (!tarballWorkflow.includes(releaseTarballPackCommand)) { + fail( + "release-tarball.yml must publish the self-contained release tarball instead of the npm package tarball", + ); +} + if (!releaseWorkflow.includes(tapAppSecretGate)) { fail( "release.yml must gate the Homebrew tap bump on both GitHub App secrets", diff --git a/scripts/pack-release-tarball.mjs b/scripts/pack-release-tarball.mjs new file mode 100644 index 00000000..482cf554 --- /dev/null +++ b/scripts/pack-release-tarball.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const ROOT = process.cwd(); +const PACKAGE_DIR = join(ROOT, "packages", "ghost"); +const PACKAGE_JSON_PATH = join(PACKAGE_DIR, "package.json"); +const PACKAGE_JSON = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8")); +const DESTINATION = process.argv[2]; + +function fail(message) { + console.error(`pack-release-tarball failed: ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + encoding: "utf8", + }); + if (result.error) { + fail( + `${command} ${args.join(" ")} failed to start: ${result.error.message}`, + ); + } + if (result.status !== 0) { + fail( + `${command} ${args.join(" ")} exited with ${result.status}\n${ + result.stderr || result.stdout + }`, + ); + } +} + +function copyIfPresent(from, to) { + if (existsSync(from)) { + cpSync(from, to, { recursive: true, dereference: true }); + } +} + +function copyDependency(name, nodeModulesDir) { + const dependencyPath = join(PACKAGE_DIR, "node_modules", ...name.split("/")); + if (!existsSync(dependencyPath)) { + fail( + `missing installed dependency ${name}; run pnpm install --frozen-lockfile first`, + ); + } + + const destinationPath = join(nodeModulesDir, ...name.split("/")); + mkdirSync(dirname(destinationPath), { recursive: true }); + cpSync(realpathSync(dependencyPath), destinationPath, { + recursive: true, + dereference: true, + }); +} + +if (!DESTINATION) { + fail("usage: node scripts/pack-release-tarball.mjs "); +} + +const requiredDistFiles = ["bin.js", "cli.js", "index.js"].map((file) => + join(PACKAGE_DIR, "dist", file), +); +for (const distFile of requiredDistFiles) { + if (!existsSync(distFile)) { + fail( + `missing built output at ${distFile}; run pnpm --filter @anarchitecture/ghost build first`, + ); + } +} + +const destinationDir = resolve(DESTINATION); +mkdirSync(destinationDir, { recursive: true }); + +const tmpRoot = mkdtempSync(join(tmpdir(), "ghost-release-tarball-")); +const stagingPackage = join(tmpRoot, "package"); + +try { + mkdirSync(stagingPackage, { recursive: true }); + + cpSync(PACKAGE_JSON_PATH, join(stagingPackage, "package.json")); + copyIfPresent( + join(PACKAGE_DIR, "README.md"), + join(stagingPackage, "README.md"), + ); + copyIfPresent(join(ROOT, "LICENSE"), join(stagingPackage, "LICENSE")); + cpSync(join(PACKAGE_DIR, "dist"), join(stagingPackage, "dist"), { + recursive: true, + dereference: true, + }); + chmodSync(join(stagingPackage, "dist", "bin.js"), 0o755); + + const nodeModulesDir = join(stagingPackage, "node_modules"); + mkdirSync(nodeModulesDir, { recursive: true }); + for (const dependencyName of Object.keys(PACKAGE_JSON.dependencies ?? {})) { + copyDependency(dependencyName, nodeModulesDir); + } + + const archiveName = `anarchitecture-ghost-${PACKAGE_JSON.version}.tgz`; + const archivePath = join(destinationDir, archiveName); + rmSync(archivePath, { force: true }); + run("tar", ["-czf", archivePath, "-C", tmpRoot, "package"]); + console.log(`Packed ${archivePath}`); +} finally { + rmSync(tmpRoot, { recursive: true, force: true }); +}