diff --git a/.changeset/friendly-dogs-plan.md b/.changeset/friendly-dogs-plan.md new file mode 100644 index 0000000000..e86c6ee290 --- /dev/null +++ b/.changeset/friendly-dogs-plan.md @@ -0,0 +1,7 @@ +--- +"@emdash-cms/plugin-cli": minor +--- + +Adds Changesets-aware automated plugin releases. `emdash-plugin release setup` detects a root `.changeset/config.json` and offers to follow Changesets releases, `@` tags, or manual runs. Use `--trigger auto|changesets|tags|manual` in non-interactive setup. + +The Changesets variant accepts the official Changesets Action published-package JSON through a reusable workflow. It supports mixed monorepos where npm package names differ from EmDash plugin IDs, ignores ordinary npm packages, verifies every reported plugin version, and publishes matching plugins as a matrix. Private EmDash-only packages produce a setup warning unless Changesets versions and tags them. diff --git a/apps/release-action/README.md b/apps/release-action/README.md index 01b173d574..35f986378a 100644 --- a/apps/release-action/README.md +++ b/apps/release-action/README.md @@ -15,9 +15,59 @@ emdash-plugin release setup Before writing `.github/workflows/emdash-release.yml`, the command creates a missing package profile or adds delegated-release settings to an existing valid profile. Profile setup binds the package to the canonical GitHub repository, uses the signed-in [Atmosphere account](https://docs.emdashcms.com/plugins/creating-plugins/publishing/#your-atmosphere-account) as the initial approver, and asks whether approval is required for permission increases or every release. Run `emdash-plugin profile setup` to perform this step without changing the workflow file. -The generated root workflow uses pinned third-party Actions, resolves `@` tags to one plugin package, builds one bundle, creates GitHub provenance for the exact bundle, and calls this Action. Every plugin package in the repository reuses the workflow. It does not push the workflow. The generated workflow currently supports public repositories because the verifier trusts GitHub's public Sigstore root. +The generated root workflow uses pinned third-party Actions, accepts packages released by Changesets, `@` tags, or manual selections, builds one bundle for each release, creates GitHub provenance for the exact bundle, and calls this Action. Every plugin package in the repository reuses the workflow. It does not push the workflow. The generated workflow currently supports public repositories because the verifier trusts GitHub's public Sigstore root. + +## Follow Changesets releases + +Choose **Follow Changesets releases** during `release setup` to generate a reusable EmDash workflow. Add its caller after the existing Changesets publish job. The caller passes Changesets' published-package JSON; the EmDash workflow ignores ordinary packages and publishes matching `emdash-plugin.jsonc` packages at the reported versions. + +For Changesets Action v2 with Changesets CLI v3, expose the kebab-case output: + +```yaml +jobs: + release: + # Keep the existing runner, permissions, and steps. + outputs: + published: ${{ steps.changesets.outputs.published }} + published-packages: ${{ steps.changesets.outputs['published-packages'] }} + + publish-emdash-plugins: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/emdash-release.yml + with: + published-packages: ${{ needs.release.outputs['published-packages'] }} + permissions: + contents: read + id-token: write + attestations: write +``` + +Changesets Action v1 with Changesets CLI v2 uses `steps.changesets.outputs.publishedPackages` instead. Keep the normalized job output and caller unchanged: + +```yaml +jobs: + release: + # Keep the existing runner, permissions, and steps. + outputs: + published: ${{ steps.changesets.outputs.published }} + published-packages: ${{ steps.changesets.outputs.publishedPackages }} + + publish-emdash-plugins: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/emdash-release.yml + with: + published-packages: ${{ needs.release.outputs['published-packages'] }} + permissions: + contents: read + id-token: write + attestations: write +``` + +Private EmDash-only packages require `privatePackages.version: true` and `privatePackages.tag: true` in `.changeset/config.json`. Add unrelated private packages to `ignore`. -Start the workflow by pushing a package tag such as `gallery@1.2.3`. The service checks that the signed package profile names the GitHub repository before creating a connection request. The Action writes an approval link to the job summary and waits. Open the link, sign in to the release service, and check the repository, workflow file, branch or tag, and environment reported by GitHub. After confirmation, the same Action run requests a fresh OIDC token and submits the release. Later packages reuse approved tag and branch scopes when their signed profiles name the same repository. +Start the release using the source selected during setup: let Changesets publish the package, push a package tag such as `gallery@1.2.3`, or run the workflow manually. The service checks that the signed package profile names the GitHub repository before creating a connection request. The Action writes an approval link to the job summary and waits. Open the link, sign in to the release service, and check the repository, workflow file, branch or tag, and environment reported by GitHub. After confirmation, the same Action run requests a fresh OIDC token and submits the release. Later packages reuse approved tag and branch scopes when their signed profiles name the same repository. For tag-triggered releases, choose whether the workflow may publish all package version tags or only the current tag. The approval never grants authority by itself: the publisher's Atmosphere session must confirm the signed GitHub identity before the service creates a publishing policy. diff --git a/apps/release-service/README.md b/apps/release-service/README.md index 90f17346c5..2531e1b5f4 100644 --- a/apps/release-service/README.md +++ b/apps/release-service/README.md @@ -21,7 +21,7 @@ The publisher and approver interfaces use the same Atmosphere account identity a The service processes an automated release in this order: 1. The publisher authorises the exact create-only release and blob OAuth scope. -2. A GitHub Actions job presents a GitHub OIDC token and the package selected by its `@` tag. +2. A GitHub Actions job presents a GitHub OIDC token and the package selected by a Changesets version update, `@` tag, or manual run. 3. The service verifies the signed package profile and its canonical repository before creating a pending connection request. 4. The publisher checks the repository, workflow file, ref, and environment before confirming the repository connection. 5. Every workflow run presents a fresh GitHub OIDC token. The service compares its repository, owner, workflow, ref, environment, commit, run, and runner claims with the stored policy. Packages whose signed profiles name the same repository reuse approved tag and branch scopes. diff --git a/apps/release-service/src/ui/App.test.tsx b/apps/release-service/src/ui/App.test.tsx index 5b2f2cb740..562ff6e214 100644 --- a/apps/release-service/src/ui/App.test.tsx +++ b/apps/release-service/src/ui/App.test.tsx @@ -184,7 +184,7 @@ describe("release-service web surfaces", () => { expect(screen.getByText("pnpm exec emdash-plugin release setup")).toBeTruthy(); expect( screen.getByText( - "Review and commit .github/workflows/emdash-release.yml, then push a package tag such as gallery@1.2.3 or start it from GitHub Actions.", + "Review and commit .github/workflows/emdash-release.yml. EmDash can follow packages released by Changesets, package tags, or manual GitHub Actions runs.", ), ).toBeTruthy(); expect(screen.getAllByText("@publisher.example.com")).toHaveLength(1); diff --git a/apps/release-service/src/ui/PublisherPage.tsx b/apps/release-service/src/ui/PublisherPage.tsx index 4ba0e9d0cb..8c2a857cf7 100644 --- a/apps/release-service/src/ui/PublisherPage.tsx +++ b/apps/release-service/src/ui/PublisherPage.tsx @@ -641,7 +641,7 @@ export function PublisherPage() {

{t( "publisher.workload.setupResult", - "Review and commit .github/workflows/emdash-release.yml, then push a package tag such as gallery@1.2.3 or start it from GitHub Actions.", + "Review and commit .github/workflows/emdash-release.yml. EmDash can follow packages released by Changesets, package tags, or manual GitHub Actions runs.", )}

diff --git a/docs/src/content/docs/plugins/creating-plugins/cli.mdx b/docs/src/content/docs/plugins/creating-plugins/cli.mdx index e69116cdb0..5a6d5ca302 100644 --- a/docs/src/content/docs/plugins/creating-plugins/cli.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/cli.mdx @@ -23,6 +23,7 @@ emdash-plugin publish Build, upload, and publish a releas emdash-plugin update-package [--yes] Preview or apply package-profile changes emdash-plugin profile setup Prepare the signed package profile for delegated releases emdash-plugin release setup Create the delegated-release GitHub Actions workflow +emdash-plugin release plan Plan repository releases for GitHub Actions emdash-plugin release prepare Prepare one repository package for GitHub Actions emdash-plugin login Sign in with your Atmosphere account emdash-plugin logout [--did ] Revoke the active session @@ -178,9 +179,18 @@ It accepts the `profile setup` flags plus the following workflow options: | --- | --- | --- | | `--service-url ` | `https://releases.emdashcms.com` | HTTPS origin used by the generated Action. | | `--action-ref ` | `main` | EmDash repository ref containing the release Action. | +| `--trigger ` | `auto` | Release source: `changesets`, `tags`, or `manual`. `auto` offers Changesets when `.changeset/config.json` exists. | | `--force` | `false` | Replace an existing generated workflow. Without it, setup leaves the existing file unchanged. | -The command never pushes the generated workflow. The first `slug@version` package tag creates a repository connection request using GitHub OpenID Connect; no Actions secret is required. Follow [Automated plugin releases](/plugins/creating-plugins/delegated-releases/) to review the workflow, authorise the release service, connect the repository, and publish the first release. +When setup detects Changesets in an interactive terminal, it asks how EmDash plugins should be released. **Follow Changesets releases** publishes the same versions for packages containing `emdash-plugin.jsonc`. The other choices follow `@` tags or allow manual runs only. In non-interactive use, `auto` selects Changesets when a valid root configuration exists and package tags otherwise. + +The Changesets variant is a reusable workflow. Add one caller job after the existing Changesets publish job and pass its official published-package JSON output. Private EmDash-only packages require `privatePackages.version: true` and `privatePackages.tag: true`; setup warns when either option is missing. + +The command never pushes the generated workflow. The first automated run creates a repository connection request using GitHub OpenID Connect; no Actions secret is required. Follow [Automated plugin releases](/plugins/creating-plugins/delegated-releases/) to review the workflow, authorise the release service, connect the repository, and publish the first release. + +## `release plan` + +`release plan` is used by the generated workflow. With `--published-packages `, it maps the Changesets Action output to packages containing `emdash-plugin.jsonc`, verifies their versions, and writes a JSON selector matrix to `GITHUB_OUTPUT`. With `--package `, it validates one manual selector. The command does not build or publish packages. ## `release prepare` diff --git a/docs/src/content/docs/plugins/creating-plugins/delegated-releases.mdx b/docs/src/content/docs/plugins/creating-plugins/delegated-releases.mdx index 99f465bccb..e0f3a41965 100644 --- a/docs/src/content/docs/plugins/creating-plugins/delegated-releases.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/delegated-releases.mdx @@ -71,7 +71,9 @@ pnpm exec emdash-plugin validate The command creates `.github/workflows/emdash-release.yml`. It does not push the file and does not replace an existing workflow unless you pass `--force`. - The generated workflow runs for package tags matching `@` and through `workflow_dispatch`. It grants `contents: read`, `id-token: write`, and `attestations: write`; pins third-party Actions to full commit identifiers; runs the exact plugin CLI version that generated the file; resolves one package from its manifest; builds one plugin bundle; creates GitHub build provenance for those exact bytes; and passes both files to the EmDash release Action. + If the repository contains `.changeset/config.json`, interactive setup offers **Follow Changesets releases**. When Changesets releases a package containing `emdash-plugin.jsonc`, the reusable EmDash workflow publishes the same version. Connect it to the existing Changesets workflow as described below. Otherwise, the generated workflow runs for package tags matching `@`. Both variants support manual runs and can be selected explicitly with `--trigger changesets|tags|manual`. + + The workflow grants each job only its required `contents`, `id-token`, and `attestations` permissions; pins third-party Actions to full commit identifiers; runs the exact plugin CLI version that generated the file; resolves each package from its manifest; builds one plugin bundle; creates GitHub build provenance for those exact bytes; and passes both files to the EmDash release Action. The workflow lives at the repository root and is shared by every plugin package in that repository. Running `release setup` from a nested package still writes `.github/workflows/emdash-release.yml` at the root. @@ -81,7 +83,9 @@ pnpm exec emdash-plugin validate 5. Start the release workflow. - Update the package version before creating the version tag. The following commands start a `1.2.3` release: + With Changesets, merge the version pull request and let its publish job complete. The Changesets Action passes the packages it released to the reusable EmDash workflow. Ordinary npm packages are ignored; packages containing `emdash-plugin.jsonc` publish the same version to EmDash. + + With the package-tag trigger, update the package version before creating the version tag. The following commands start a `1.2.3` release: ```sh git tag gallery@1.2.3 @@ -106,6 +110,56 @@ pnpm exec emdash-plugin validate +## Connect a Changesets workflow + +The generated `.github/workflows/emdash-release.yml` accepts the Changesets Action published-package JSON through `workflow_call`. Add an output to the existing Changesets job, then call the EmDash workflow from a dependent job. Replace `release` and `changesets` when the existing job or step uses another ID. + +Changesets Action v2 uses the `published-packages` output. Add the following job output and caller to a workflow using Changesets CLI v3: + +```yaml title=".github/workflows/release.yml" +jobs: + release: + # Keep the existing runner, permissions, and steps. + outputs: + published: ${{ steps.changesets.outputs.published }} + published-packages: ${{ steps.changesets.outputs['published-packages'] }} + + publish-emdash-plugins: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/emdash-release.yml + with: + published-packages: ${{ needs.release.outputs['published-packages'] }} + permissions: + contents: read + id-token: write + attestations: write +``` + +Changesets Action v1 uses the camel-case `publishedPackages` step output. Use this expression for a workflow using Changesets CLI v2: + +```yaml title=".github/workflows/release.yml" +jobs: + release: + # Keep the existing runner, permissions, and steps. + outputs: + published: ${{ steps.changesets.outputs.published }} + published-packages: ${{ steps.changesets.outputs.publishedPackages }} + + publish-emdash-plugins: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/emdash-release.yml + with: + published-packages: ${{ needs.release.outputs['published-packages'] }} + permissions: + contents: read + id-token: write + attestations: write +``` + +Keep Changesets responsible for its version pull request and package publication. The EmDash caller runs only when Changesets reports `published: true`. For private EmDash-only packages, set both `privatePackages.version` and `privatePackages.tag` to `true` in `.changeset/config.json`. Add unrelated private applications and test fixtures to `ignore`. + ## Add another package Prepare the package profile from its source directory. The existing root workflow and repository connection are reused: @@ -114,14 +168,14 @@ Prepare the package profile from its source directory. The existing root workflo pnpm exec emdash-plugin profile setup --dir packages/comments ``` -Update the package version, then push its package tag: +With Changesets, add the package to a changeset and merge its version pull request. With the package-tag trigger, update the package version and push its tag: ```sh git tag comments@1.0.0 git push origin comments@1.0.0 ``` -The workflow resolves `comments` to one `emdash-plugin.jsonc`, checks that the manifest version is `1.0.0`, and verifies that the signed profile names the connected repository before accepting artifact uploads. Duplicate package IDs and tag-version mismatches fail before attestation. +The workflow resolves `comments` to one `emdash-plugin.jsonc`, checks the selected version, and verifies that the signed profile names the connected repository before accepting artifact uploads. Duplicate package IDs and version mismatches fail before attestation. ## What the release service verifies diff --git a/docs/technical-specs/delegated-release-service.md b/docs/technical-specs/delegated-release-service.md index a603278f37..befcd21b93 100644 --- a/docs/technical-specs/delegated-release-service.md +++ b/docs/technical-specs/delegated-release-service.md @@ -134,6 +134,8 @@ The service normalizes omitted values to the protocol defaults. It validates the `emdash-plugin release setup` creates a missing package profile or adds the extension to an existing valid profile through the publisher's local CLI session. It asks for confirmation in an interactive terminal and preserves existing package metadata. The service checks the signed extension before creating a workflow connection request and again before accepting that workflow's artifact uploads. A missing profile, missing extension, or repository mismatch returns `PACKAGE_PROFILE_REQUIRED` with the local setup command. +Setup writes one repository workflow. With Changesets, the workflow is called after the existing Changesets publish job and receives its official published-package JSON. It maps package names to plugin manifests, verifies the reported versions, and emits one matrix entry per matching plugin. Without Changesets, `@` tags select a package directly. Manual selection remains available in every generated workflow. Trigger selection does not change the service's workload identity, repository connection, provenance, or package-profile checks. + The delegated path always requires supported provenance, even when `requireProvenance` is absent. The profile field communicates the publisher's requirement to every installer and non-delegated publisher. A supplied unsupported predicate is present-but-unverifiable and fails delegated publication. ### Signed release provenance diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md index ed0a35a888..006f4148a3 100644 --- a/packages/plugin-cli/README.md +++ b/packages/plugin-cli/README.md @@ -40,6 +40,7 @@ emdash-plugin bundle Pack dist/ + assets into a registry emdash-plugin publish Build, upload, and publish a release emdash-plugin profile setup Create or prepare the signed package profile emdash-plugin release setup Create the permanent GitHub release workflow +emdash-plugin release plan Plan repository releases for GitHub Actions emdash-plugin release prepare Prepare one repository package for GitHub Actions emdash-plugin release delegate Print a publisher delegation browser handoff emdash-plugin release revoke Print an authority revocation browser handoff @@ -128,15 +129,21 @@ The command reads the plugin metadata and publisher from `emdash-plugin.jsonc`. Set `repo` in `emdash-plugin.jsonc`, or enter the canonical GitHub repository URL when prompted. The standalone `emdash-plugin profile setup` command prepares only the package profile. -Both setup commands accept `--repository `, `--confirmation escalation-only|always`, and `--yes`. `release setup` also accepts `--service-url`, `--action-ref`, and `--force` for the generated workflow. The default hosted service is `https://releases.emdashcms.com`. +Both setup commands accept `--repository `, `--confirmation escalation-only|always`, and `--yes`. `release setup` also accepts `--service-url`, `--action-ref`, `--trigger auto|changesets|tags|manual`, and `--force` for the generated workflow. The default hosted service is `https://releases.emdashcms.com`. After preparing the profile, `release setup` creates one `.github/workflows/emdash-release.yml` at the Git repository root. Nested plugin packages reuse that workflow. Review and commit the file. The command does not push or replace a different existing workflow; pass `--force` to replace one deliberately. In a non-interactive environment, pass `--yes` to accept the default approval policy. The command fails rather than creating or changing a profile when it cannot prompt and `--yes` is absent. -The generated workflow resolves `@` package tags to a unique plugin manifest, builds that package, creates signed GitHub build provenance, and publishes it. Manual runs accept a plugin ID and use its manifest version. Private and internal GitHub repositories are not supported because their attestations use a private Sigstore trust root that the release verifier does not trust. +With the default `--trigger auto`, setup detects a valid `.changeset/config.json` at the Git repository root. Interactive setup asks whether EmDash should follow Changesets releases, package tags, or manual runs. Non-interactive setup selects Changesets when detected and package tags otherwise. + +The Changesets variant is a reusable workflow. Pass the existing Changesets Action `published-packages` output to it from a dependent job. It maps npm package names to `emdash-plugin.jsonc` slugs, ignores ordinary packages, verifies reported versions, and publishes matching plugins as a matrix. Changesets Action v1 names the step output `publishedPackages`; v2 names it `published-packages`. + +For private EmDash-only packages, set both `privatePackages.version` and `privatePackages.tag` to `true` in `.changeset/config.json`. Setup warns when either option is missing. See [Automated plugin releases](https://docs.emdashcms.com/plugins/creating-plugins/delegated-releases/#connect-a-changesets-workflow) for complete v1 and v2 caller examples. + +The package-tag variant resolves `@` tags to a unique plugin manifest. Every variant builds the selected package, creates signed GitHub build provenance, and publishes it. Manual runs accept a plugin ID and use its manifest version. Private and internal GitHub repositories are not supported because their attestations use a private Sigstore trust root that the release verifier does not trust. Sign in to the release-service dashboard with the Atmosphere account that owns the plugin and authorize EmDash to create plugin releases. -Start the workflow by pushing a package tag such as `gallery@1.2.3`. The service verifies that the signed `gallery` profile names the GitHub repository, then the first run for that tag scope waits and adds a repository-approval link to the GitHub job summary. Open that link, check the repository, workflow file, branch or tag, and environment, then confirm the connection. The same run continues after confirmation. +Start the release using the source selected during setup: let Changesets publish the package, push a package tag such as `gallery@1.2.3`, or run the workflow manually. The service verifies that the signed `gallery` profile names the GitHub repository, then the first run for that ref scope waits and adds a repository-approval link to the GitHub job summary. Open that link, check the repository, workflow file, branch or tag, and environment, then confirm the connection. The same run continues after confirmation. For a release started from a tag, the dashboard can authorize all package version tags or only the current tag. A manual run requests approval the first time its branch is used. Confirming another scope extends the connection instead of replacing existing scopes. Repository and workflow paths remain exact. A later package reuses approved scopes when its signed profile names the same repository. Policies created by older package-scoped workflows are not reused for another package. diff --git a/packages/plugin-cli/src/commands/release.ts b/packages/plugin-cli/src/commands/release.ts index d2245bb71d..3977125c4c 100644 --- a/packages/plugin-cli/src/commands/release.ts +++ b/packages/plugin-cli/src/commands/release.ts @@ -6,7 +6,7 @@ import { defineCommand } from "citty"; import { consola } from "consola"; import pc from "picocolors"; -import { releasePrepareCommand } from "../release-prepare.js"; +import { releasePlanCommand, releasePrepareCommand } from "../release-prepare.js"; import { cancelDelegatedReleaseIntent, dryRunDelegatedRelease, @@ -330,6 +330,7 @@ export const releaseCommand = defineCommand({ "dry-run": releaseDryRunCommand, enrol: releaseEnrolCommand, reject: releaseRejectCommand, + plan: releasePlanCommand, prepare: releasePrepareCommand, revoke: releaseRevokeCommand, setup: releaseSetupCommand, diff --git a/packages/plugin-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts index ac31aef28f..14c7d59a90 100644 --- a/packages/plugin-cli/src/init/templates.ts +++ b/packages/plugin-cli/src/init/templates.ts @@ -441,7 +441,7 @@ Before handing off a change, run validation, typecheck, tests, and build. A rele ## Publishing -Use the local publish script for a release started from this computer. Use the release-setup script for GitHub Actions. The first automated release connects the repository workflow; later packages reuse it only when their signed profiles name the same repository. +Use the local publish script for a release started from this computer. Use the release-setup script for GitHub Actions. Setup detects a root Changesets configuration and offers to follow packages released by Changesets; otherwise it uses package tags. Connect the generated reusable workflow to the existing Changesets publish job by passing its published-package output. Changesets Action v1 names the step output \`publishedPackages\`; v2 names it \`published-packages\`. Expose it as a \`published-packages\` job output and pass it to the generated workflow from a dependent job when Changesets reports \`published == 'true'\`. The first automated release connects the repository workflow; later packages reuse it only when their signed profiles name the same repository. For complete EmDash patterns and API details, use https://docs.emdashcms.com/plugins/creating-plugins/. `; diff --git a/packages/plugin-cli/src/release-prepare.ts b/packages/plugin-cli/src/release-prepare.ts index 1eb7db6477..9120e8d309 100644 --- a/packages/plugin-cli/src/release-prepare.ts +++ b/packages/plugin-cli/src/release-prepare.ts @@ -16,10 +16,12 @@ import { resolveHandleToDid } from "./manifest/publisher.js"; const SKIPPED_DIRECTORIES = new Set([".astro", ".emdash-release", ".git", "dist", "node_modules"]); const MAX_DISCOVERED_DIRECTORIES = 10_000; const MAX_DISCOVERED_PLUGINS = 256; +const MAX_PUBLISHED_PACKAGES_BYTES = 256 * 1024; export type ReleasePrepareErrorCode = | "PACKAGE_AMBIGUOUS" | "PACKAGE_NOT_FOUND" + | "PUBLISHED_PACKAGES_INVALID" | "PUBLISHER_UNRESOLVED" | "RELEASE_SELECTOR_INVALID" | "VERSION_MISMATCH"; @@ -43,6 +45,13 @@ export interface PreparedRepositoryRelease { bundleFile: string; } +export interface PlannedRepositoryRelease { + packageName: string; + packageSlug: string; + pluginDirectory: string; + version: string; +} + interface ReleaseSelector { packageSlug: string; version: string | null; @@ -108,6 +117,136 @@ async function discoverPluginDirectories(repositoryRoot: string): Promise MAX_PUBLISHED_PACKAGES_BYTES) { + throw new ReleasePrepareError( + "PUBLISHED_PACKAGES_INVALID", + "Changesets published-packages output is invalid.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new ReleasePrepareError( + "PUBLISHED_PACKAGES_INVALID", + "Changesets published-packages output is not valid JSON.", + ); + } + if (!Array.isArray(parsed) || parsed.length > MAX_DISCOVERED_PLUGINS) { + throw new ReleasePrepareError( + "PUBLISHED_PACKAGES_INVALID", + "Changesets published-packages output must be a bounded array.", + ); + } + const packages: PublishedPackage[] = []; + const names = new Set(); + for (const item of parsed) { + if ( + item === null || + typeof item !== "object" || + Array.isArray(item) || + Object.keys(item).toSorted().join(",") !== "name,version" + ) { + throw new ReleasePrepareError( + "PUBLISHED_PACKAGES_INVALID", + "Each Changesets published package must contain only name and version.", + ); + } + const name = Reflect.get(item, "name"); + const version = Reflect.get(item, "version"); + if ( + typeof name !== "string" || + name.length === 0 || + name.length > 214 || + typeof version !== "string" || + version.length === 0 || + version.length > 255 || + names.has(name) + ) { + throw new ReleasePrepareError( + "PUBLISHED_PACKAGES_INVALID", + "Changesets published package names and versions must be unique bounded strings.", + ); + } + names.add(name); + packages.push({ name, version }); + } + return packages; +} + +async function packageName(directory: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(join(directory, "package.json"), "utf8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const name = Reflect.get(parsed, "name"); + return typeof name === "string" ? name : null; + } catch { + return null; + } +} + +function repositoryPath(repositoryRoot: string, path: string): string { + return relative(repositoryRoot, path).split(sep).join("/"); +} + +export async function planPublishedRepositoryReleases(options: { + repositoryRoot: string; + publishedPackages: string; +}): Promise { + const repositoryRoot = resolve(options.repositoryRoot); + const published = parsePublishedPackages(options.publishedPackages); + const publishedNames = new Set(published.map((item) => item.name)); + const directoriesByName = new Map(); + for (const directory of await discoverPluginDirectories(repositoryRoot)) { + const name = await packageName(directory); + if (!name || !publishedNames.has(name)) continue; + const directories = directoriesByName.get(name) ?? []; + directories.push(directory); + directoriesByName.set(name, directories); + } + const planned: PlannedRepositoryRelease[] = []; + const slugs = new Set(); + for (const item of published) { + const directories = directoriesByName.get(item.name) ?? []; + if (directories.length === 0) continue; + if (directories.length > 1) { + throw new ReleasePrepareError( + "PACKAGE_AMBIGUOUS", + `More than one EmDash plugin package uses the package name ${item.name}.`, + ); + } + const directory = directories[0]!; + const sources = await resolveSources(directory); + if (!sources.hasPackageJson || !sources.packageName) continue; + if (sources.manifest.version !== item.version) { + throw new ReleasePrepareError( + "VERSION_MISMATCH", + `Changesets reported ${item.name}@${item.version}, but ${sources.manifest.slug} is ${sources.manifest.version}.`, + ); + } + if (slugs.has(sources.manifest.slug)) { + throw new ReleasePrepareError( + "PACKAGE_AMBIGUOUS", + `More than one ${sources.manifest.slug} plugin package was found in ${repositoryRoot}.`, + ); + } + slugs.add(sources.manifest.slug); + planned.push({ + packageName: sources.packageName, + packageSlug: sources.manifest.slug, + pluginDirectory: repositoryPath(repositoryRoot, sources.pluginDir) || ".", + version: item.version, + }); + } + return planned.toSorted((left, right) => left.packageSlug.localeCompare(right.packageSlug)); +} + async function manifestSlug(directory: string): Promise { const errors: ParseError[] = []; const parsed: unknown = parse( @@ -236,6 +375,65 @@ async function writeGitHubOutputs(path: string, release: PreparedRepositoryRelea ); } +async function writeReleasePlan(path: string, selectors: readonly string[]): Promise { + await appendFile(path, `selectors=${JSON.stringify(selectors)}\n`, "utf8"); +} + +export const releasePlanCommand = defineCommand({ + meta: { name: "plan", description: "Plan repository plugin releases for GitHub Actions" }, + args: { + dir: { + type: "string", + description: "Repository root (default: current repository)", + default: process.cwd(), + }, + "published-packages": { + type: "string", + description: "Changesets Action published-packages JSON output", + }, + package: { + type: "string", + description: "Plugin ID or @ for a manual release", + }, + }, + async run({ args }) { + try { + if ((args["published-packages"] ? 1 : 0) + (args.package ? 1 : 0) !== 1) { + throw new ReleasePrepareError( + "RELEASE_SELECTOR_INVALID", + "Pass exactly one of --published-packages or --package.", + ); + } + const repositoryRoot = await findRepositoryRoot(args.dir); + let selectors: string[]; + if (args.package) { + const selector = parseReleaseSelector(args.package); + selectors = [`${selector.packageSlug}${selector.version ? `@${selector.version}` : ""}`]; + } else { + selectors = ( + await planPublishedRepositoryReleases({ + repositoryRoot, + publishedPackages: args["published-packages"]!, + }) + ).map((release) => `${release.packageSlug}@${release.version}`); + } + const output = process.env["GITHUB_OUTPUT"]; + if (output) await writeReleasePlan(output, selectors); + consola.info( + selectors.length === 0 + ? "No published EmDash plugin packages found." + : JSON.stringify(selectors), + ); + } catch (error) { + if (error instanceof ReleasePrepareError) { + consola.error(error.message); + process.exit(1); + } + throw error; + } + }, +}); + export const releasePrepareCommand = defineCommand({ meta: { name: "prepare", description: "Prepare one repository package for GitHub Actions" }, args: { diff --git a/packages/plugin-cli/src/release-setup.ts b/packages/plugin-cli/src/release-setup.ts index 0007f1e3ff..7e4a3923d3 100644 --- a/packages/plugin-cli/src/release-setup.ts +++ b/packages/plugin-cli/src/release-setup.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { isDid, isHandle, type Handle } from "@atcute/lexicons/syntax"; +import * as clack from "@clack/prompts"; import { defineCommand } from "citty"; import consola from "consola"; import pc from "picocolors"; @@ -18,12 +19,25 @@ export const DEFAULT_RELEASE_ACTION_REF = "main"; export const RELEASE_WORKFLOW_PATH = ".github/workflows/emdash-release.yml"; const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +const CHANGESETS_CONFIG_PATH = ".changeset/config.json"; + +export type ReleaseTrigger = "changesets" | "manual" | "tags"; +export type ReleaseTriggerOption = "auto" | ReleaseTrigger; + +export interface ChangesetsConfiguration { + baseBranch: string; + privatePackagesTag: boolean; + privatePackagesVersion: boolean; +} export type ReleaseSetupErrorCode = | "PUBLISHER_REQUIRED" | "PUBLISHER_UNRESOLVED" | "INVALID_SERVICE_URL" | "INVALID_ACTION_REF" + | "INVALID_TRIGGER" + | "CHANGESETS_CONFIG_INVALID" + | "CHANGESETS_NOT_FOUND" | "WORKFLOW_EXISTS"; export class ReleaseSetupError extends Error { @@ -42,6 +56,7 @@ export interface SetupReleaseWorkflowOptions { force?: boolean; serviceUrl?: string; actionRef?: string; + trigger?: ReleaseTriggerOption; resolvePublisherDid?: (handle: Handle) => Promise; beforeWrite?: (context: { publisherDid: string; pluginDir: string }) => Promise; } @@ -50,6 +65,69 @@ export interface SetupReleaseWorkflowResult { path: string; publisherDid: string; status: "created" | "replaced" | "reused"; + trigger: ReleaseTrigger; + warnings: readonly string[]; +} + +export function resolveReleaseTrigger(value: string, changesetsDetected: boolean): ReleaseTrigger { + if (value === "auto") return changesetsDetected ? "changesets" : "tags"; + if (value === "changesets" || value === "manual" || value === "tags") return value; + throw new ReleaseSetupError( + "INVALID_TRIGGER", + "--trigger must be auto, changesets, tags, or manual.", + ); +} + +export async function detectChangesets( + repositoryRoot: string, +): Promise { + let raw: string; + try { + raw = await readFile(join(repositoryRoot, CHANGESETS_CONFIG_PATH), "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ReleaseSetupError( + "CHANGESETS_CONFIG_INVALID", + `${join(repositoryRoot, CHANGESETS_CONFIG_PATH)} is not valid JSON.`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ReleaseSetupError( + "CHANGESETS_CONFIG_INVALID", + `${join(repositoryRoot, CHANGESETS_CONFIG_PATH)} must contain a Changesets configuration object.`, + ); + } + const baseBranch = Reflect.get(parsed, "baseBranch") ?? "main"; + const privatePackages = Reflect.get(parsed, "privatePackages"); + if ( + typeof baseBranch !== "string" || + !ACTION_REF_PATTERN.test(baseBranch) || + baseBranch.includes("..") || + baseBranch.includes("//") || + baseBranch.endsWith("/") || + (privatePackages !== undefined && + (privatePackages === null || + typeof privatePackages !== "object" || + Array.isArray(privatePackages))) + ) { + throw new ReleaseSetupError( + "CHANGESETS_CONFIG_INVALID", + `${join(repositoryRoot, CHANGESETS_CONFIG_PATH)} has invalid release settings.`, + ); + } + return { + baseBranch, + privatePackagesTag: + privatePackages !== undefined && Reflect.get(privatePackages, "tag") === true, + privatePackagesVersion: + privatePackages !== undefined && Reflect.get(privatePackages, "version") === true, + }; } export async function setupReleaseWorkflow( @@ -72,8 +150,36 @@ export async function setupReleaseWorkflow( const actionRef = validateActionRef(options.actionRef ?? DEFAULT_RELEASE_ACTION_REF); const cliVersion = await installedCliVersion(); const repositoryRoot = await findRepositoryRoot(sources.pluginDir); + const triggerOption = options.trigger ?? "tags"; + const changesets = + triggerOption === "auto" || triggerOption === "changesets" + ? await detectChangesets(repositoryRoot) + : null; + const trigger = resolveReleaseTrigger(triggerOption, changesets !== null); + if (trigger === "changesets" && changesets === null) { + throw new ReleaseSetupError( + "CHANGESETS_NOT_FOUND", + `--trigger changesets requires ${join(repositoryRoot, CHANGESETS_CONFIG_PATH)}.`, + ); + } + const warnings: string[] = []; + if ( + trigger === "changesets" && + changesets !== null && + (!changesets.privatePackagesVersion || !changesets.privatePackagesTag) && + (await pluginPackageIsPrivate(sources.pluginDir)) + ) { + warnings.push( + "Changesets must version and tag private plugin packages. Set privatePackages.version and privatePackages.tag to true in .changeset/config.json before relying on Changesets releases.", + ); + } const workflowPath = join(repositoryRoot, RELEASE_WORKFLOW_PATH); - const workflow = renderReleaseWorkflow({ serviceUrl, actionRef, cliVersion }); + const workflow = renderReleaseWorkflow({ + serviceUrl, + actionRef, + cliVersion, + trigger, + }); let existing: string | null = null; try { existing = await readFile(workflowPath, "utf8"); @@ -88,7 +194,7 @@ export async function setupReleaseWorkflow( } await options.beforeWrite?.({ publisherDid, pluginDir: sources.pluginDir }); if (existing === workflow && !options.force) { - return { path: workflowPath, publisherDid, status: "reused" }; + return { path: workflowPath, publisherDid, status: "reused", trigger, warnings }; } await mkdir(join(repositoryRoot, ".github", "workflows"), { recursive: true }); @@ -116,9 +222,25 @@ export async function setupReleaseWorkflow( path: workflowPath, publisherDid, status: existing === null ? "created" : "replaced", + trigger, + warnings, }; } +async function pluginPackageIsPrivate(pluginDir: string): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(join(pluginDir, "package.json"), "utf8")); + return ( + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) && + Reflect.get(parsed, "private") === true + ); + } catch { + return false; + } +} + async function resolvePublisherDid( publisher: string, resolveHandle: (handle: Handle) => Promise, @@ -195,14 +317,20 @@ function renderReleaseWorkflow(input: { serviceUrl: string; actionRef: string; cliVersion: string; + trigger: ReleaseTrigger; }): string { + if (input.trigger === "changesets") return renderChangesetsReleaseWorkflow(input); + const trigger = + input.trigger === "tags" + ? ` push: + tags: + - "*@*" + workflow_dispatch:` + : " workflow_dispatch:"; return `name: "Publish EmDash plugins" on: - push: - tags: - - "*@*" - workflow_dispatch: +${trigger} inputs: package: description: "Plugin ID to publish" @@ -211,8 +339,12 @@ on: permissions: contents: read - id-token: write - attestations: write + id-token: write # Required for the audience-bound GitHub OIDC token. + attestations: write # Required to sign build provenance. + +concurrency: + group: emdash-release-\${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: false jobs: publish: @@ -227,6 +359,8 @@ jobs: - name: "Check out repository" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: "Set up pnpm" uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 @@ -258,7 +392,143 @@ jobs: subject-path: \${{ steps.prepare.outputs.bundle-file }} - name: "Publish plugin" - uses: emdash-cms/emdash/apps/release-action@${input.actionRef} + uses: emdash-cms/emdash/apps/release-action@${input.actionRef} # zizmor: ignore[unpinned-uses] -- configured by --action-ref + with: + service-url: ${input.serviceUrl} + publisher-did: \${{ steps.prepare.outputs.publisher-did }} + bundle-file: \${{ steps.prepare.outputs.bundle-file }} + provenance-file: \${{ steps.attest.outputs.bundle-path }} +`; +} + +function renderChangesetsReleaseWorkflow(input: { + serviceUrl: string; + actionRef: string; + cliVersion: string; +}): string { + return `name: "Publish EmDash plugins" + +on: + workflow_call: + inputs: + published-packages: + description: "Changesets Action published-packages JSON output" + required: true + type: string + workflow_dispatch: + inputs: + package: + description: "Plugin ID to publish" + required: true + type: string + +permissions: {} + +concurrency: + group: emdash-release-\${{ github.workflow }}-\${{ github.ref }} + cancel-in-progress: false + +jobs: + plan: + name: "Plan plugin releases" + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + selectors: \${{ steps.changesets.outputs.selectors || steps.manual.outputs.selectors }} + steps: + - name: "Check out repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: "Set up pnpm" + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11 + + - name: "Set up Node.js" + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: "Plan Changesets plugin releases" + id: changesets + if: \${{ github.event_name == 'workflow_call' }} + shell: bash + env: + EMDASH_PUBLISHED_PACKAGES: \${{ inputs.published-packages }} + run: | + set -euo pipefail + pnpm dlx @emdash-cms/plugin-cli@${input.cliVersion} release plan --published-packages "\${EMDASH_PUBLISHED_PACKAGES}" --dir . + + - name: "Plan manual plugin release" + id: manual + if: \${{ github.event_name == 'workflow_dispatch' }} + shell: bash + env: + EMDASH_RELEASE_SELECTOR: \${{ inputs.package }} + run: | + set -euo pipefail + pnpm dlx @emdash-cms/plugin-cli@${input.cliVersion} release plan --package "\${EMDASH_RELEASE_SELECTOR}" --dir . + + publish: + name: "Build and publish \${{ matrix.selector }}" + needs: plan + if: \${{ needs.plan.outputs.selectors != '[]' }} + strategy: + fail-fast: false + matrix: + selector: \${{ fromJSON(needs.plan.outputs.selectors) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Required for the audience-bound GitHub OIDC token. + attestations: write # Required to sign build provenance. + steps: + - name: "Check repository visibility" + if: \${{ github.event.repository.visibility != 'public' }} + run: | + echo "::error title=Public repository required::EmDash releases currently require a public GitHub repository because private and internal repository attestations cannot yet be verified." + exit 1 + + - name: "Check out repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: "Set up pnpm" + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11 + + - name: "Set up Node.js" + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: pnpm + + - name: "Install dependencies" + run: pnpm install --frozen-lockfile + + - name: "Prepare plugin release" + id: prepare + shell: bash + env: + EMDASH_RELEASE_SELECTOR: "\${{ matrix.selector }}" + run: | + set -euo pipefail + pnpm dlx @emdash-cms/plugin-cli@${input.cliVersion} release prepare "\${EMDASH_RELEASE_SELECTOR}" --dir . --out-dir .emdash-release + + - name: "Create build provenance" + id: attest + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 + with: + subject-path: \${{ steps.prepare.outputs.bundle-file }} + + - name: "Publish plugin" + uses: emdash-cms/emdash/apps/release-action@${input.actionRef} # zizmor: ignore[unpinned-uses] -- configured by --action-ref with: service-url: ${input.serviceUrl} publisher-did: \${{ steps.prepare.outputs.publisher-did }} @@ -288,6 +558,11 @@ export const releaseSetupCommand = defineCommand({ description: "EmDash repository ref containing the release Action", default: DEFAULT_RELEASE_ACTION_REF, }, + trigger: { + type: "string", + description: "Release trigger: auto, changesets, tags, or manual", + default: "auto", + }, force: { type: "boolean", description: `Replace an existing ${RELEASE_WORKFLOW_PATH}`, @@ -310,11 +585,38 @@ export const releaseSetupCommand = defineCommand({ }, async run({ args }) { try { + const repositoryRoot = await findRepositoryRoot(args.dir); + const changesets = + args.trigger === "auto" || args.trigger === "changesets" + ? await detectChangesets(repositoryRoot) + : null; + let trigger = resolveReleaseTrigger(args.trigger, changesets !== null); + if (args.trigger === "auto" && changesets && !args.yes && process.stdin.isTTY === true) { + const selected = await clack.select({ + message: "How should EmDash plugins be released?", + options: [ + { + value: "changesets", + label: "Follow Changesets releases", + hint: "publish matching EmDash plugins at the same versions", + }, + { value: "tags", label: "Follow package tags", hint: "@" }, + { value: "manual", label: "Manual only", hint: "run from GitHub Actions" }, + ], + initialValue: "changesets", + }); + if (clack.isCancel(selected)) { + clack.cancel("Cancelled."); + process.exit(0); + } + trigger = resolveReleaseTrigger(selected, true); + } const result = await setupReleaseWorkflow({ dir: args.dir, serviceUrl: args["service-url"], actionRef: args["action-ref"], force: args.force, + trigger, beforeWrite: async () => runProfileSetup({ dir: args.dir, @@ -323,6 +625,7 @@ export const releaseSetupCommand = defineCommand({ yes: args.yes, }), }); + for (const warning of result.warnings) consola.warn(warning); consola.success( result.status === "reused" ? `Using shared workflow ${pc.cyan(result.path)}` @@ -330,7 +633,11 @@ export const releaseSetupCommand = defineCommand({ ); consola.info("Review and commit the workflow when you are ready. Nothing was pushed."); consola.info( - "Publish by pushing a package tag such as gallery@1.2.3, or run the workflow from GitHub Actions.", + result.trigger === "changesets" + ? "Pass your Changesets Action published-packages output to this reusable workflow. See the delegated release guide for the caller job." + : result.trigger === "tags" + ? "Publish by pushing a package tag such as gallery@1.2.3, or run the workflow from GitHub Actions." + : "Start a release from the workflow's Run workflow control.", ); consola.info("The first run links this repository workflow in the release dashboard."); } catch (error) { diff --git a/packages/plugin-cli/tests/release-prepare.test.ts b/packages/plugin-cli/tests/release-prepare.test.ts index 424c2a681f..c1de502c4b 100644 --- a/packages/plugin-cli/tests/release-prepare.test.ts +++ b/packages/plugin-cli/tests/release-prepare.test.ts @@ -5,7 +5,10 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { prepareRepositoryRelease } from "../src/release-prepare.js"; +import { + planPublishedRepositoryReleases, + prepareRepositoryRelease, +} from "../src/release-prepare.js"; const FIXTURE = fileURLToPath(new URL("./fixtures/minimal-plugin", import.meta.url)); const PUBLISHER_DID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz"; @@ -86,4 +89,45 @@ describe("prepareRepositoryRelease", () => { }); expect(release.packageSlug).toBe("fixture-minimal"); }); + + it("maps Changesets published package names to plugin slugs", async () => { + await expect( + planPublishedRepositoryReleases({ + repositoryRoot, + publishedPackages: JSON.stringify([ + { name: "ordinary-library", version: "4.0.0" }, + { name: "fixture-minimal-plugin", version: "1.2.3" }, + ]), + }), + ).resolves.toEqual([ + { + packageName: "fixture-minimal-plugin", + packageSlug: "fixture-minimal", + pluginDirectory: "packages/fixture-minimal", + version: "1.2.3", + }, + ]); + }); + + it("rejects a published plugin version that differs from the package", async () => { + await expect( + planPublishedRepositoryReleases({ + repositoryRoot, + publishedPackages: '[{"name":"fixture-minimal-plugin","version":"2.0.0"}]', + }), + ).rejects.toMatchObject({ code: "VERSION_MISMATCH" }); + }); + + it("rejects malformed or duplicate Changesets publication output", async () => { + await expect( + planPublishedRepositoryReleases({ repositoryRoot, publishedPackages: "not json" }), + ).rejects.toMatchObject({ code: "PUBLISHED_PACKAGES_INVALID" }); + await expect( + planPublishedRepositoryReleases({ + repositoryRoot, + publishedPackages: + '[{"name":"fixture-minimal-plugin","version":"1.2.3"},{"name":"fixture-minimal-plugin","version":"1.2.3"}]', + }), + ).rejects.toMatchObject({ code: "PUBLISHED_PACKAGES_INVALID" }); + }); }); diff --git a/packages/plugin-cli/tests/release-setup.test.ts b/packages/plugin-cli/tests/release-setup.test.ts index 009925b7d0..e1c6b7d1a1 100644 --- a/packages/plugin-cli/tests/release-setup.test.ts +++ b/packages/plugin-cli/tests/release-setup.test.ts @@ -1,4 +1,4 @@ -import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,7 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_RELEASE_ACTION_REF, DEFAULT_RELEASE_SERVICE_URL, + detectChangesets, RELEASE_WORKFLOW_PATH, + resolveReleaseTrigger, setupReleaseWorkflow, } from "../src/release-setup.js"; @@ -41,6 +43,8 @@ describe("setupReleaseWorkflow", () => { expect(workflow).toContain('description: "Plugin ID to publish"'); expect(workflow).toContain("id-token: write"); expect(workflow).toContain("attestations: write"); + expect(workflow).toContain("group: emdash-release-${{ github.workflow }}-${{ github.ref }}"); + expect(workflow).toContain("persist-credentials: false"); expect(workflow).toContain("if: ${{ github.event.repository.visibility != 'public' }}"); expect(workflow).toContain( "EmDash releases currently require a public GitHub repository because private and internal repository attestations cannot yet be verified.", @@ -97,6 +101,84 @@ describe("setupReleaseWorkflow", () => { } }); + it("detects Changesets at the repository root and resolves the automatic trigger", async () => { + await mkdir(join(dir, ".changeset"), { recursive: true }); + await writeFile( + join(dir, ".changeset", "config.json"), + JSON.stringify({ baseBranch: "develop", privatePackages: { version: true, tag: true } }), + "utf8", + ); + + await expect(detectChangesets(dir)).resolves.toEqual({ + baseBranch: "develop", + privatePackagesTag: true, + privatePackagesVersion: true, + }); + expect(resolveReleaseTrigger("auto", true)).toBe("changesets"); + expect(resolveReleaseTrigger("auto", false)).toBe("tags"); + expect(() => resolveReleaseTrigger("invalid", true)).toThrow( + "--trigger must be auto, changesets, tags, or manual", + ); + }); + + it("creates one Changesets caller and manual workflow without inferring branch releases", async () => { + await mkdir(join(dir, ".changeset"), { recursive: true }); + await writeFile( + join(dir, ".changeset", "config.json"), + JSON.stringify({ baseBranch: "main" }), + "utf8", + ); + + const result = await setupReleaseWorkflow({ + dir, + trigger: "changesets", + resolvePublisherDid: async () => PUBLISHER_DID, + }); + const workflow = await readFile(result.path, "utf8"); + + expect(result.trigger).toBe("changesets"); + expect(result.warnings).toContain( + "Changesets must version and tag private plugin packages. Set privatePackages.version and privatePackages.tag to true in .changeset/config.json before relying on Changesets releases.", + ); + expect(workflow).toContain("workflow_call:"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("published-packages:"); + expect(workflow).not.toContain("tags:"); + expect(workflow).not.toContain("push:"); + expect(workflow).toContain('release plan --published-packages "${EMDASH_PUBLISHED_PACKAGES}"'); + expect(workflow).toContain('release plan --package "${EMDASH_RELEASE_SELECTOR}"'); + expect(workflow).toContain("selector: ${{ fromJSON(needs.plan.outputs.selectors) }}"); + expect(workflow).toContain('EMDASH_RELEASE_SELECTOR: "${{ matrix.selector }}"'); + expect(workflow.match(/persist-credentials: false/g)).toHaveLength(2); + }); + + it("creates a manual-only workflow when requested", async () => { + const result = await setupReleaseWorkflow({ + dir, + trigger: "manual", + resolvePublisherDid: async () => PUBLISHER_DID, + }); + const workflow = await readFile(result.path, "utf8"); + + expect(result.trigger).toBe("manual"); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).not.toContain("push:"); + }); + + it("allows an explicit tag trigger when an unrelated Changesets config is invalid", async () => { + await mkdir(join(dir, ".changeset"), { recursive: true }); + await writeFile(join(dir, ".changeset", "config.json"), "not json\n", "utf8"); + + const result = await setupReleaseWorkflow({ + dir, + trigger: "tags", + resolvePublisherDid: async () => PUBLISHER_DID, + }); + + expect(result.trigger).toBe("tags"); + await expect(readFile(result.path, "utf8")).resolves.toContain('tags:\n - "*@*"'); + }); + it("pins a manifest DID without doing a handle lookup", async () => { await writeFile( join(dir, "emdash-plugin.jsonc"), diff --git a/skills/creating-plugins/SKILL.md b/skills/creating-plugins/SKILL.md index 6342fe9388..fe31661c1d 100644 --- a/skills/creating-plugins/SKILL.md +++ b/skills/creating-plugins/SKILL.md @@ -260,7 +260,11 @@ pnpm exec emdash-plugin login pnpm exec emdash-plugin publish ``` -For GitHub Actions, run `emdash-plugin release setup` from one plugin package. It prepares that package profile and creates one shared `.github/workflows/emdash-release.yml` at the Git repository root. The first `@` tag requests approval for the repository workflow through GitHub OpenID Connect. A manual run requests approval the first time its branch is used; confirmation adds that scope without replacing approved tags. Later packages reuse approved scopes when their signed profiles name the same repository. Prepare each package with `emdash-plugin profile setup --dir `. +For GitHub Actions, run `emdash-plugin release setup` from one plugin package. It prepares that package profile and creates one shared `.github/workflows/emdash-release.yml` at the Git repository root. When `.changeset/config.json` exists, setup offers **Follow Changesets releases**. The generated reusable workflow accepts the Changesets Action published-package JSON, maps package names to plugin slugs, and publishes matching plugins at the same versions. Otherwise, package tags use `@`. Select explicitly with `--trigger changesets|tags|manual`. + +To connect Changesets manually, expose its `published` and published-package step outputs from the existing release job, then call `./.github/workflows/emdash-release.yml` from a dependent job when `published == 'true'`. Changesets Action v1 uses `publishedPackages`; v2 uses `published-packages`. Private EmDash-only packages require `privatePackages.version: true` and `privatePackages.tag: true`. Read [Publishing](./references/publishing.md) for the complete caller blocks. + +The first automated release requests approval for the repository workflow through GitHub OpenID Connect. A manual run requests approval the first time its branch is used; confirmation adds that scope without replacing approved tags. Later packages reuse approved scopes when their signed profiles name the same repository. Prepare each package with `emdash-plugin profile setup --dir `. Read [Publishing](./references/publishing.md) before configuring local or delegated releases. It defines the manifest, profile, tag, provenance, and approval requirements. diff --git a/skills/creating-plugins/references/publishing.md b/skills/creating-plugins/references/publishing.md index 153fb628f7..204839fb23 100644 --- a/skills/creating-plugins/references/publishing.md +++ b/skills/creating-plugins/references/publishing.md @@ -40,7 +40,33 @@ pnpm exec emdash-plugin release setup The command prepares the current signed package profile and writes `.github/workflows/emdash-release.yml` at the Git repository root. It does not push the file. The workflow is shared by all plugin packages in that repository and requires no Actions secret. -The generated workflow publishes tags in `@` form: +When `.changeset/config.json` exists at the repository root, interactive setup offers **Follow Changesets releases**. The generated workflow accepts the Changesets Action published-package JSON and publishes packages that also contain `emdash-plugin.jsonc`. + +Connect Changesets Action v2 by exposing its outputs from the existing release job and calling the generated workflow: + +```yaml +jobs: + release: + # Keep the existing runner, permissions, and steps. + outputs: + published: ${{ steps.changesets.outputs.published }} + published-packages: ${{ steps.changesets.outputs['published-packages'] }} + + publish-emdash-plugins: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/emdash-release.yml + with: + published-packages: ${{ needs.release.outputs['published-packages'] }} + permissions: + contents: read + id-token: write + attestations: write +``` + +For Changesets Action v1, set the normalized `published-packages` job output from `${{ steps.changesets.outputs.publishedPackages }}` instead. Private EmDash-only packages require both `privatePackages.version: true` and `privatePackages.tag: true`; add unrelated private packages to `ignore`. + +Without Changesets, the generated workflow publishes tags in `@` form: ```sh git tag gallery@1.2.3 @@ -51,7 +77,7 @@ The workflow runs `release prepare` through the exact plugin CLI version that ge The first run uses GitHub OpenID Connect to request a repository connection. The service checks that the initiating package's signed profile names the same repository before creating the request. The publisher approves the repository, workflow file, ref scope, and environment in the release dashboard. A manual run requests approval the first time its branch is used; confirmation adds that scope without removing approved tags or branches. -Prepare another package without changing the workflow: +Prepare another package without changing the workflow. Changesets users add it to a changeset; package-tag users push its tag: ```sh pnpm exec emdash-plugin profile setup --dir packages/comments