From 44e01acef55f74b44a271b1177bd2c909d426194 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 5 Jun 2026 08:08:12 +0100 Subject: [PATCH 1/2] ci: host plugin tarballs as release assets, drop marketplace seed Attach each published sandboxed plugin's bundle to the GitHub release changesets creates, giving the decentralized registry (RFC 0001) a stable public URL per version. Remove the legacy marketplace seed workflow, which has been failing since the post-#1057 plugin layout broke its bundler and is superseded by the registry. --- .github/scripts/attach-plugin-tarballs.mjs | 107 +++++++++++++++++++++ .github/workflows/deploy-marketplace.yml | 73 -------------- .github/workflows/release.yml | 11 +++ 3 files changed, 118 insertions(+), 73 deletions(-) create mode 100644 .github/scripts/attach-plugin-tarballs.mjs delete mode 100644 .github/workflows/deploy-marketplace.yml diff --git a/.github/scripts/attach-plugin-tarballs.mjs b/.github/scripts/attach-plugin-tarballs.mjs new file mode 100644 index 0000000000..93da7a0377 --- /dev/null +++ b/.github/scripts/attach-plugin-tarballs.mjs @@ -0,0 +1,107 @@ +// 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"; + +const raw = process.env.PUBLISHED_PACKAGES ?? "[]"; +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; + + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + + // 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/-.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); +} diff --git a/.github/workflows/deploy-marketplace.yml b/.github/workflows/deploy-marketplace.yml deleted file mode 100644 index 4ab1d9a02c..0000000000 --- a/.github/workflows/deploy-marketplace.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Seed Marketplace Plugins - -on: - workflow_dispatch: - push: - branches: [main] - paths: - - "packages/plugins/**" - - ".github/workflows/deploy-marketplace.yml" - -permissions: - contents: read - -concurrency: - group: seed-marketplace - cancel-in-progress: false - -jobs: - seed: - name: Seed Plugins - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - # Build core (produces the CLI at dist/cli/index.mjs) - - run: pnpm run --filter emdash... build - - # Bundle and publish each standard-format plugin. - # Only plugins with a sandbox entry can be marketplace-installed. - # Invokes the built CLI directly via node since pnpm exec can't - # resolve binaries from workspace packages (only from dependencies). - - name: Seed plugins - run: | - CLI="node packages/core/dist/cli/index.mjs" - failures=0 - for dir in packages/plugins/*/; do - [ -f "$dir/package.json" ] || continue - - # Only include plugins that have a sandbox entry - if [ ! -f "$dir/src/sandbox-entry.ts" ] && \ - ! grep -q '"./sandbox"' "$dir/package.json" 2>/dev/null; then - continue - fi - - name=$(basename "$dir") - - # Skip test-only plugins - case "$name" in - marketplace-test|sandboxed-test|api-test) continue ;; - esac - - echo "::group::$name" - $CLI plugin publish --build --dir "$dir" --no-wait --registry "$MARKETPLACE_URL" || { - echo "::warning::Failed to publish $name" - failures=$((failures + 1)) - } - echo "::endgroup::" - done - if [ "$failures" -gt 0 ]; then - echo "::error::$failures plugin(s) failed to publish" - exit 1 - fi - env: - EMDASH_MARKETPLACE_TOKEN: ${{ secrets.MARKETPLACE_SEED_TOKEN }} - MARKETPLACE_URL: https://marketplace.emdashcms.com diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a28a66e9e..d12698d475 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,17 @@ 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. + - 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 From 38f310d3e5846f723cfa90b71678b873b0145f0c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 5 Jun 2026 10:44:31 +0100 Subject: [PATCH 2/2] ci: harden plugin tarball attach script Address review feedback: guard per-directory package.json parsing so one bad manifest doesn't abort the step, treat an empty PUBLISHED_PACKAGES output as no-op, and fail fast when GITHUB_REPOSITORY is missing. Note the publish-only path limitation in the workflow. --- .github/scripts/attach-plugin-tarballs.mjs | 19 +++++++++++++++++-- .github/workflows/release.yml | 3 +++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/scripts/attach-plugin-tarballs.mjs b/.github/scripts/attach-plugin-tarballs.mjs index 93da7a0377..fd44275598 100644 --- a/.github/scripts/attach-plugin-tarballs.mjs +++ b/.github/scripts/attach-plugin-tarballs.mjs @@ -28,7 +28,14 @@ const LEADING_AT_RE = /^@/; const repo = process.env.GITHUB_REPOSITORY; const dryRun = process.env.DRY_RUN === "1"; -const raw = process.env.PUBLISHED_PACKAGES ?? "[]"; +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); @@ -58,7 +65,15 @@ for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) { const pkgPath = join(dir, "package.json"); if (!existsSync(pkgPath)) continue; - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + // 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; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d12698d475..825a8dcb50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,9 @@ jobs: # 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