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
5 changes: 5 additions & 0 deletions .changeset/self-contained-release-tarball.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anarchitecture/ghost": patch
---

Publish GitHub Release archives with runtime dependencies included for package-manager installs.
15 changes: 7 additions & 8 deletions .github/workflows/release-tarball.yml
Original file line number Diff line number Diff line change
@@ -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/<tag>/<file>.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-
Expand Down Expand Up @@ -46,13 +45,13 @@ jobs:
- name: Build
run: pnpm --filter @anarchitecture/ghost build

# `pnpm --filter <pkg> 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions scripts/check-packed-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
107 changes: 107 additions & 0 deletions scripts/check-release-tarball.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
14 changes: 14 additions & 0 deletions scripts/check-release-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 != '' }}";
Expand Down Expand Up @@ -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",
Expand Down
117 changes: 117 additions & 0 deletions scripts/pack-release-tarball.mjs
Original file line number Diff line number Diff line change
@@ -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 <destination-dir>");
}

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 });
}
Loading