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
8 changes: 8 additions & 0 deletions .changeset/bright-lions-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/plugin-cli": minor
"@emdash-cms/registry-client": minor
---

Adds repository-level automated plugin releases. `emdash-plugin release setup` writes one shared `.github/workflows/emdash-release.yml` at the Git repository root, including when setup runs from a nested package. The workflow resolves `<slug>@<version>` tags to a unique plugin manifest, rejects version mismatches before attestation, and requests its first repository connection through GitHub OpenID Connect without an Actions secret.

Prepare later packages with `emdash-plugin profile setup --dir <package-directory>`. Their first release reuses approved repository workflow scopes when the signed package profile names the same repository. Tag and manual-run scopes accumulate after publisher confirmation instead of replacing each other. Existing package approvals remain package-scoped until the publisher explicitly confirms a repository connection; existing generated workflows and the legacy optional connection-invitation input remain supported.
7 changes: 7 additions & 0 deletions .changeset/calm-foxes-scaffold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@emdash-cms/plugin-cli": minor
---

Updates `emdash-plugin init` to produce a validated, package-manager-aware plugin project. Interactive setup shows the resolved publisher, author, security contact, repository, target, and package manager before writing. Non-interactive setup requires explicit ownership flags unless `--use-detected` opts into the active publisher session and local Git metadata.

Generated projects pin the plugin CLI version, use bounded EmDash dependencies, include validation and publishing scripts, and add `AGENTS.md` with a local `creating-plugins` skill. `.agents/skills` and `.claude/skills` link to the same canonical skill directory, while `.claude/CLAUDE.md` links to `AGENTS.md`. pnpm projects include the reviewed `esbuild` install policy and use an explicit `SandboxedPlugin` annotation so declaration output remains portable. The scaffolder validates the complete manifest and parent paths before writing and stages new projects atomically.
35 changes: 17 additions & 18 deletions apps/release-action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ 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 workflow uses pinned third-party Actions, builds one bundle, creates GitHub provenance for the exact bundle, and calls this Action. 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, resolves `<slug>@<version>` 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.

Before the first run, create an invitation for the plugin in the publisher dashboard and add its one-time value to the repository as the `EMDASH_CONNECTION_INVITATION` Actions secret. The generated workflow passes that secret as `connection-invitation`.
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 workflow within 30 minutes. The Action consumes the invitation, 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 runs from the approved workflow continue without an invitation.
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.

For tag-triggered releases, choose whether the workflow may publish all 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.
A manual run from a branch requests approval the first time that branch is used. Confirming it adds the branch to the repository connection without removing approved tag or branch scopes. Package-scoped policies created by older workflows remain package-scoped until an unmatched package or ref is approved as a repository connection.

The Action accepts an existing `bundle-file`, or builds `plugin-directory` with the project's installed `emdash-plugin` command. Pass the raw `bundle-path` output from `actions/attest-build-provenance` as `provenance-file`. Before uploading either file, the Action confirms that the workflow is authorised and that the signed package profile links the same canonical repository. The service verifies the checksums and provenance before publishing.

Expand All @@ -39,26 +39,25 @@ The following step uses the bundle and provenance outputs created earlier in the
with:
service-url: https://releases.emdashcms.com
publisher-did: did:plc:examplepublisher
connection-invitation: ${{ secrets.EMDASH_CONNECTION_INVITATION }}
bundle-file: ${{ steps.bundle.outputs.path }}
provenance-file: ${{ steps.attest.outputs.bundle-path }}
```

## Inputs

| Input | Required | Default | Purpose |
| ----------------------- | ---------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `service-url` | Yes | — | HTTPS origin of the delegated release service. |
| `publisher-did` | Yes | — | DID that owns the package profile and release records. |
| `connection-invitation` | First connection | — | Single-use invitation created by the publisher for this plugin. |
| `bundle-file` | No | — | Existing plugin tarball inside `GITHUB_WORKSPACE`. The Action builds `plugin-directory` when omitted. |
| `plugin-directory` | No | `.` | Plugin source directory inside `GITHUB_WORKSPACE`. |
| `provenance-file` | Conditional | — | Raw Sigstore bundle under `RUNNER_TEMP`; required with a bundle or plugin directory. |
| `release-file` | No | — | Compatibility input for a URL-source release record inside `GITHUB_WORKSPACE`. |
| `idempotency-key` | No | Current run ID | Stable key used to replay the same submission. |
| `poll-interval-seconds` | No | `5` | Delay between intent status requests. |
| `timeout-minutes` | No | `30` | Maximum time to wait for workflow approval, publication, or release approval. |
| `wait-for-approval` | No | `false` | Continue polling when the intent reaches `awaiting_approval`. |
| Input | Required | Default | Purpose |
| ----------------------- | ----------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `service-url` | Yes | — | HTTPS origin of the delegated release service. |
| `publisher-did` | Yes | — | DID that owns the package profile and release records. |
| `connection-invitation` | No | — | Legacy package-bound invitation accepted from workflows generated before repository connections. |
| `bundle-file` | No | — | Existing plugin tarball inside `GITHUB_WORKSPACE`. The Action builds `plugin-directory` when omitted. |
| `plugin-directory` | No | `.` | Plugin source directory inside `GITHUB_WORKSPACE`. |
| `provenance-file` | Conditional | — | Raw Sigstore bundle under `RUNNER_TEMP`; required with a bundle or plugin directory. |
| `release-file` | No | — | Compatibility input for a URL-source release record inside `GITHUB_WORKSPACE`. |
| `idempotency-key` | No | Current run ID | Stable key used to replay the same submission. |
| `poll-interval-seconds` | No | `5` | Delay between intent status requests. |
| `timeout-minutes` | No | `30` | Maximum time to wait for workflow approval, publication, or release approval. |
| `wait-for-approval` | No | `false` | Continue polling when the intent reaches `awaiting_approval`. |

The default idempotency key is stable across attempts of one GitHub run. Set `idempotency-key` when separate runs or jobs must replay the same submission identity.

Expand Down
2 changes: 1 addition & 1 deletion apps/release-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ inputs:
description: Publisher DID that owns the package.
required: true
connection-invitation:
description: Single-use invitation created by the publisher for the first workflow connection.
description: Legacy single-use package invitation from workflows created before repository connections.
required: false
release-file:
description: Legacy path to a URL-source release JSON file. Cannot be combined with bundle or provenance inputs.
Expand Down
5 changes: 4 additions & 1 deletion apps/release-action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1735,11 +1735,12 @@ function parsePolicy(value) {
const workflowRef = stringValue(value, "workflowRef");
const allowedRefs = parseStringArray(value["allowedRefs"]);
const allowedEnvironments = parseStringArray(value["allowedEnvironments"]);
const repositoryConnection = value["repositoryConnection"] ?? false;
const stateVersion = safeInteger(value, "stateVersion");
const authorizedBy = stringValue(value, "authorizedBy");
const createdAt = safeInteger(value, "createdAt");
const updatedAt = safeInteger(value, "updatedAt");
if (!packageSlug || !repository || !repositoryId || !repositoryOwnerId || !workflowRef || !allowedRefs || !allowedEnvironments || typeof value["active"] !== "boolean" || stateVersion === null || !authorizedBy || createdAt === null || updatedAt === null) throw invalidResponse();
if (!packageSlug || !repository || !repositoryId || !repositoryOwnerId || !workflowRef || !allowedRefs || !allowedEnvironments || typeof repositoryConnection !== "boolean" || typeof value["active"] !== "boolean" || stateVersion === null || !authorizedBy || createdAt === null || updatedAt === null) throw invalidResponse();
return {
packageSlug,
repository,
Expand All @@ -1748,6 +1749,7 @@ function parsePolicy(value) {
workflowRef,
allowedRefs,
allowedEnvironments,
repositoryConnection,
active: value["active"],
stateVersion,
authorizedBy,
Expand Down Expand Up @@ -12946,6 +12948,7 @@ async function runAction(runtime, dependencies = {}) {
await setIntentOutputs(runtime, intent);
if (intent.state === "awaiting_approval") {
runtime.info(`Release intent ${intent.id} requires approval: ${intent.approvalUrl}`);
await runtime.writeSummary(`## Approve ${intent.packageSlug} ${intent.version}\n\n[Open EmDash to review and approve the release](${intent.approvalUrl})`);
return intent;
}
if (intent.state === "published" && intent.result) {
Expand Down
3 changes: 3 additions & 0 deletions apps/release-action/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ export async function runAction(
await setIntentOutputs(runtime, intent);
if (intent.state === "awaiting_approval") {
runtime.info(`Release intent ${intent.id} requires approval: ${intent.approvalUrl}`);
await runtime.writeSummary(
`## Approve ${intent.packageSlug} ${intent.version}\n\n[Open EmDash to review and approve the release](${intent.approvalUrl})`,
);
return intent;
}
if (intent.state === "published" && intent.result) {
Expand Down
3 changes: 3 additions & 0 deletions apps/release-action/test/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ describe("delegated release Action", () => {

expect(result.state).toBe("awaiting_approval");
expect(runtime.outputs.get("approval-url")).toBe(approvalUrl);
expect(runtime.summaries).toContain(
`## Approve gallery 1.2.3\n\n[Open EmDash to review and approve the release](${approvalUrl})`,
);
expect(runtime.failures).toEqual([]);
});

Expand Down
10 changes: 6 additions & 4 deletions apps/release-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ 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 one-time invitation and a GitHub OIDC token.
3. The publisher checks the repository, workflow file, ref, and environment before confirming the connection.
4. The service verifies the signed package profile and its canonical repository before storing the workflow policy.
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.
2. A GitHub Actions job presents a GitHub OIDC token and the package selected by its `<slug>@<version>` tag.
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.
6. The Action uploads the bundle and raw Sigstore provenance to private R2 staging. Profile and workflow checks happen before these uploads.
7. `ReleaseIntentWorkflow` verifies the profile revision, release-key absence, artifact bytes, bundle manifest, declared access, GitHub provenance, and approval policy.
8. A release that expands declared access or uses `confirmation: always` waits for a profile-listed approver's passkey decision.
Expand All @@ -33,6 +33,8 @@ The service processes an automated release in this order:

An ambiguous PDS create enters reconciliation. The Workflow reads the deterministic release key and accepts only the exact expected record as published.

Confirming another tag or branch scope extends a repository connection without replacing existing scopes. Package policies created before repository connections remain package-scoped and cannot authorize another package.

## Authority boundaries

The service keeps each identity and credential separate.
Expand Down
11 changes: 7 additions & 4 deletions apps/release-service/e2e/web-surfaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ test("account approves a connection requested by the permanent release workflow"
workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main",
allowedRefs: ["refs/tags/*"],
allowedEnvironments: ["production"],
repositoryConnection: true,
active: true,
stateVersion: 1,
authorizedBy: PUBLISHER_DID,
Expand All @@ -179,13 +180,15 @@ test("account approves a connection requested by the permanent release workflow"
await page.goto(`/publisher?connection=${connectionRequest.id}`);
await expect(page.getByText("Signed in as @publisher.example.com")).toBeVisible();
await expect(page.getByText(PUBLISHER_DID)).toHaveCount(0);
await expect(page.getByRole("heading", { name: "2. Prepare your plugin" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Approve workflow for gallery" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "2. Connect your GitHub repository" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "Connect GitHub repository" })).toBeVisible();
await expect(page.getByText("example/gallery")).toBeVisible();
await expect(page.getByText(".github/workflows/release.yml")).toBeVisible();
await expect(page.getByText("v1.2.3")).toBeVisible();
await expect(page.getByText("All version tags")).toBeVisible();
await page.getByRole("button", { name: "Approve workflow" }).click();
await expect(page.getByText("All package version tags")).toBeVisible();
await page.getByRole("button", { name: "Connect repository" }).click();
await expect(page.getByRole("button", { name: "Check for workflow requests" })).toBeVisible();
expect(confirmed).toBe(true);
});
Expand Down
8 changes: 8 additions & 0 deletions apps/release-service/src/publisher-do/operations-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,14 @@ export class OperationsRestoreStore {
integerField(value, "createdAt"),
now,
);
if (value["repositoryConnection"] === true) {
this.storage.sql.exec(
`INSERT INTO repository_connection_policies (package_slug, created_at)
VALUES (?, ?) ON CONFLICT(package_slug) DO NOTHING`,
packageSlug,
now,
);
}
}

#restoreIntent(value: unknown, now: number): void {
Expand Down
Loading
Loading