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
122 changes: 122 additions & 0 deletions .github/scripts/attach-plugin-tarballs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Attach sandboxed-plugin tarballs to the GitHub releases that
// `changesets/action` creates during a release run.
//
// The decentralized plugin registry (RFC 0001) stores only a *link* to the
// plugin bytes, not the bytes themselves. By bundling each published
// sandboxed plugin and uploading the tarball as a release asset, every
// version automatically gets a stable public URL that an `emdash-plugin
// publish --url ...` step (or a human) can point a registry record at.
//
// Input: the `publishedPackages` output from changesets/action, passed via
// the PUBLISHED_PACKAGES env var as a JSON array of `{ name, version }`.
// Only packages under `packages/plugins/*` that expose a `./sandbox` export
// and are not private are processed; everything else (native plugins, test
// fixtures) is skipped.
//
// Set DRY_RUN=1 to bundle and resolve tarballs without calling `gh`.

import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

const PLUGINS_DIR = "packages/plugins";
const BUNDLER = "packages/plugin-cli/dist/index.mjs";

const SLASH_RE = /\//g;
const LEADING_AT_RE = /^@/;

const repo = process.env.GITHUB_REPOSITORY;
const dryRun = process.env.DRY_RUN === "1";
Comment on lines +28 to +29

if (!dryRun && !repo) {
console.error("GITHUB_REPOSITORY is not set; cannot target `gh release upload`.");
process.exit(1);
}

// `?? "[]"` only catches undefined/null. An unset Actions output arrives as
// an empty string, which would make JSON.parse throw, so coalesce that too.
const raw = process.env.PUBLISHED_PACKAGES?.trim() || "[]";
let published;
try {
published = JSON.parse(raw);
} catch (error) {
console.error(`Could not parse PUBLISHED_PACKAGES as JSON: ${error.message}`);
process.exit(1);
}

if (!Array.isArray(published) || published.length === 0) {
console.log("No published packages to process.");
process.exit(0);
}

// name -> version for the packages that were just published.
const publishedVersions = new Map(published.map((p) => [p.name, p.version]));

/** Slugify a manifest id the same way the bundler names its tarball. */
const slugify = (id) => id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "");

const failures = [];
let attached = 0;

for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;

const dir = join(PLUGINS_DIR, entry.name);
const pkgPath = join(dir, "package.json");
if (!existsSync(pkgPath)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The JSON.parse() here is unprotected. The script iterates all subdirectories of packages/plugins/ (not just published ones), so a malformed, empty, or merge-conflicted package.json in any plugin directory — even a non-published test fixture — will throw an uncaught exception and abort tarball attachment for every published plugin.

Wrap the read/parse in a try/catch so a bad file is gracefully skipped instead of killing the release step:

Suggested change
let pkg;
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
} catch {
console.warn(`Skipping ${entry.name}: could not read/parse package.json`);
continue;
}

// We read every plugin dir, so a single malformed package.json must not
// abort the whole step. Treat an unreadable manifest as a skip.
let pkg;
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
} catch {
console.warn(`Skipping ${entry.name}: could not read/parse package.json`);
continue;
}

// Only published, public, sandboxed plugins.
if (!publishedVersions.has(pkg.name)) continue;
if (pkg.private === true) continue;
if (!pkg.exports?.["./sandbox"]) {
console.log(`Skipping ${pkg.name}: no ./sandbox export (not a sandboxed plugin).`);
continue;
}

const version = publishedVersions.get(pkg.name);
const tag = `${pkg.name}@${version}`;
console.log(`\n=== ${pkg.name}@${version} ===`);

try {
// Bundle. This rebuilds from source and writes dist/<slug>-<version>.tar.gz
// plus dist/manifest.json, so we can read the exact id/version back out
// rather than guessing the filename (stale tarballs may linger in dist/).
execFileSync("node", [BUNDLER, "bundle", "--dir", dir], { stdio: "inherit" });

const manifest = JSON.parse(readFileSync(join(dir, "dist", "manifest.json"), "utf-8"));
const tarball = join(dir, "dist", `${slugify(manifest.id)}-${manifest.version}.tar.gz`);
if (!existsSync(tarball)) {
throw new Error(`Expected tarball not found: ${tarball}`);
}

if (dryRun) {
console.log(`[dry-run] would upload ${tarball} to release ${tag}`);
} else {
// --clobber so re-runs replace the asset instead of failing.
execFileSync("gh", ["release", "upload", tag, tarball, "--clobber", "--repo", repo], {
stdio: "inherit",
});
console.log(`Attached ${tarball} to ${tag}`);
}
attached++;
} catch (error) {
console.error(`Failed to attach tarball for ${pkg.name}: ${error.message}`);
failures.push(pkg.name);
}
}

console.log(`\nAttached ${attached} plugin tarball(s).`);
if (failures.length > 0) {
console.error(`Failed: ${failures.join(", ")}`);
process.exit(1);
}
73 changes: 0 additions & 73 deletions .github/workflows/deploy-marketplace.yml

This file was deleted.

14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

# Attach sandboxed-plugin tarballs to the releases changesets just
# created, so the decentralized registry has a stable public URL to
# point each release record at. See the script header for details.
# Only the changesets path is covered: the publish-only recovery path
# below neither runs changesets/action nor creates GitHub releases, so
# assets for that path must be backfilled manually.
- name: Attach plugin tarballs to releases
if: ${{ steps.changesets.outputs.published == 'true' }}
run: node .github/scripts/attach-plugin-tarballs.mjs
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}

- name: Publish (manual)
if: ${{ inputs.publish-only }}
run: node .github/scripts/release.mjs publish
Comment on lines +87 to 97
Expand Down
Loading