Skip to content

Commit da171b3

Browse files
feat: streamline plugin setup and delegated releases (emdash-cms#3081)
* feat: streamline delegated plugin releases * chore: rebuild release action * chore: release repository connection metadata * style: format --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent 45b5ea8 commit da171b3

52 files changed

Lines changed: 2175 additions & 689 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/bright-lions-connect.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@emdash-cms/plugin-cli": minor
3+
"@emdash-cms/registry-client": minor
4+
---
5+
6+
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.
7+
8+
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.

.changeset/calm-foxes-scaffold.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@emdash-cms/plugin-cli": minor
3+
---
4+
5+
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.
6+
7+
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.

apps/release-action/README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ emdash-plugin release setup
1515

1616
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.
1717

18-
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.
18+
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.
1919

20-
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`.
20+
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.
2121

22-
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.
22+
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.
2323

24-
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.
24+
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.
2525

2626
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.
2727

@@ -39,26 +39,25 @@ The following step uses the bundle and provenance outputs created earlier in the
3939
with:
4040
service-url: https://releases.emdashcms.com
4141
publisher-did: did:plc:examplepublisher
42-
connection-invitation: ${{ secrets.EMDASH_CONNECTION_INVITATION }}
4342
bundle-file: ${{ steps.bundle.outputs.path }}
4443
provenance-file: ${{ steps.attest.outputs.bundle-path }}
4544
```
4645
4746
## Inputs
4847
49-
| Input | Required | Default | Purpose |
50-
| ----------------------- | ---------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
51-
| `service-url` | Yes | — | HTTPS origin of the delegated release service. |
52-
| `publisher-did` | Yes | — | DID that owns the package profile and release records. |
53-
| `connection-invitation` | First connection | — | Single-use invitation created by the publisher for this plugin. |
54-
| `bundle-file` | No | — | Existing plugin tarball inside `GITHUB_WORKSPACE`. The Action builds `plugin-directory` when omitted. |
55-
| `plugin-directory` | No | `.` | Plugin source directory inside `GITHUB_WORKSPACE`. |
56-
| `provenance-file` | Conditional | — | Raw Sigstore bundle under `RUNNER_TEMP`; required with a bundle or plugin directory. |
57-
| `release-file` | No | — | Compatibility input for a URL-source release record inside `GITHUB_WORKSPACE`. |
58-
| `idempotency-key` | No | Current run ID | Stable key used to replay the same submission. |
59-
| `poll-interval-seconds` | No | `5` | Delay between intent status requests. |
60-
| `timeout-minutes` | No | `30` | Maximum time to wait for workflow approval, publication, or release approval. |
61-
| `wait-for-approval` | No | `false` | Continue polling when the intent reaches `awaiting_approval`. |
48+
| Input | Required | Default | Purpose |
49+
| ----------------------- | ----------- | -------------- | ----------------------------------------------------------------------------------------------------- |
50+
| `service-url` | Yes | — | HTTPS origin of the delegated release service. |
51+
| `publisher-did` | Yes | — | DID that owns the package profile and release records. |
52+
| `connection-invitation` | No | — | Legacy package-bound invitation accepted from workflows generated before repository connections. |
53+
| `bundle-file` | No | — | Existing plugin tarball inside `GITHUB_WORKSPACE`. The Action builds `plugin-directory` when omitted. |
54+
| `plugin-directory` | No | `.` | Plugin source directory inside `GITHUB_WORKSPACE`. |
55+
| `provenance-file` | Conditional | — | Raw Sigstore bundle under `RUNNER_TEMP`; required with a bundle or plugin directory. |
56+
| `release-file` | No | — | Compatibility input for a URL-source release record inside `GITHUB_WORKSPACE`. |
57+
| `idempotency-key` | No | Current run ID | Stable key used to replay the same submission. |
58+
| `poll-interval-seconds` | No | `5` | Delay between intent status requests. |
59+
| `timeout-minutes` | No | `30` | Maximum time to wait for workflow approval, publication, or release approval. |
60+
| `wait-for-approval` | No | `false` | Continue polling when the intent reaches `awaiting_approval`. |
6261

6362
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.
6463

apps/release-action/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ inputs:
99
description: Publisher DID that owns the package.
1010
required: true
1111
connection-invitation:
12-
description: Single-use invitation created by the publisher for the first workflow connection.
12+
description: Legacy single-use package invitation from workflows created before repository connections.
1313
required: false
1414
release-file:
1515
description: Legacy path to a URL-source release JSON file. Cannot be combined with bundle or provenance inputs.

apps/release-action/dist/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1735,11 +1735,12 @@ function parsePolicy(value) {
17351735
const workflowRef = stringValue(value, "workflowRef");
17361736
const allowedRefs = parseStringArray(value["allowedRefs"]);
17371737
const allowedEnvironments = parseStringArray(value["allowedEnvironments"]);
1738+
const repositoryConnection = value["repositoryConnection"] ?? false;
17381739
const stateVersion = safeInteger(value, "stateVersion");
17391740
const authorizedBy = stringValue(value, "authorizedBy");
17401741
const createdAt = safeInteger(value, "createdAt");
17411742
const updatedAt = safeInteger(value, "updatedAt");
1742-
if (!packageSlug || !repository || !repositoryId || !repositoryOwnerId || !workflowRef || !allowedRefs || !allowedEnvironments || typeof value["active"] !== "boolean" || stateVersion === null || !authorizedBy || createdAt === null || updatedAt === null) throw invalidResponse();
1743+
if (!packageSlug || !repository || !repositoryId || !repositoryOwnerId || !workflowRef || !allowedRefs || !allowedEnvironments || typeof repositoryConnection !== "boolean" || typeof value["active"] !== "boolean" || stateVersion === null || !authorizedBy || createdAt === null || updatedAt === null) throw invalidResponse();
17431744
return {
17441745
packageSlug,
17451746
repository,
@@ -1748,6 +1749,7 @@ function parsePolicy(value) {
17481749
workflowRef,
17491750
allowedRefs,
17501751
allowedEnvironments,
1752+
repositoryConnection,
17511753
active: value["active"],
17521754
stateVersion,
17531755
authorizedBy,
@@ -12946,6 +12948,7 @@ async function runAction(runtime, dependencies = {}) {
1294612948
await setIntentOutputs(runtime, intent);
1294712949
if (intent.state === "awaiting_approval") {
1294812950
runtime.info(`Release intent ${intent.id} requires approval: ${intent.approvalUrl}`);
12951+
await runtime.writeSummary(`## Approve ${intent.packageSlug} ${intent.version}\n\n[Open EmDash to review and approve the release](${intent.approvalUrl})`);
1294912952
return intent;
1295012953
}
1295112954
if (intent.state === "published" && intent.result) {

apps/release-action/src/run.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,9 @@ export async function runAction(
301301
await setIntentOutputs(runtime, intent);
302302
if (intent.state === "awaiting_approval") {
303303
runtime.info(`Release intent ${intent.id} requires approval: ${intent.approvalUrl}`);
304+
await runtime.writeSummary(
305+
`## Approve ${intent.packageSlug} ${intent.version}\n\n[Open EmDash to review and approve the release](${intent.approvalUrl})`,
306+
);
304307
return intent;
305308
}
306309
if (intent.state === "published" && intent.result) {

apps/release-action/test/run.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ describe("delegated release Action", () => {
388388

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

apps/release-service/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ The publisher and approver interfaces use the same Atmosphere account identity a
2121
The service processes an automated release in this order:
2222

2323
1. The publisher authorises the exact create-only release and blob OAuth scope.
24-
2. A GitHub Actions job presents a one-time invitation and a GitHub OIDC token.
25-
3. The publisher checks the repository, workflow file, ref, and environment before confirming the connection.
26-
4. The service verifies the signed package profile and its canonical repository before storing the workflow policy.
27-
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.
24+
2. A GitHub Actions job presents a GitHub OIDC token and the package selected by its `<slug>@<version>` tag.
25+
3. The service verifies the signed package profile and its canonical repository before creating a pending connection request.
26+
4. The publisher checks the repository, workflow file, ref, and environment before confirming the repository connection.
27+
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.
2828
6. The Action uploads the bundle and raw Sigstore provenance to private R2 staging. Profile and workflow checks happen before these uploads.
2929
7. `ReleaseIntentWorkflow` verifies the profile revision, release-key absence, artifact bytes, bundle manifest, declared access, GitHub provenance, and approval policy.
3030
8. A release that expands declared access or uses `confirmation: always` waits for a profile-listed approver's passkey decision.
@@ -33,6 +33,8 @@ The service processes an automated release in this order:
3333

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

36+
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.
37+
3638
## Authority boundaries
3739

3840
The service keeps each identity and credential separate.

apps/release-service/e2e/web-surfaces.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ test("account approves a connection requested by the permanent release workflow"
162162
workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main",
163163
allowedRefs: ["refs/tags/*"],
164164
allowedEnvironments: ["production"],
165+
repositoryConnection: true,
165166
active: true,
166167
stateVersion: 1,
167168
authorizedBy: PUBLISHER_DID,
@@ -179,13 +180,15 @@ test("account approves a connection requested by the permanent release workflow"
179180
await page.goto(`/publisher?connection=${connectionRequest.id}`);
180181
await expect(page.getByText("Signed in as @publisher.example.com")).toBeVisible();
181182
await expect(page.getByText(PUBLISHER_DID)).toHaveCount(0);
182-
await expect(page.getByRole("heading", { name: "2. Prepare your plugin" })).toBeVisible();
183-
await expect(page.getByRole("heading", { name: "Approve workflow for gallery" })).toBeVisible();
183+
await expect(
184+
page.getByRole("heading", { name: "2. Connect your GitHub repository" }),
185+
).toBeVisible();
186+
await expect(page.getByRole("heading", { name: "Connect GitHub repository" })).toBeVisible();
184187
await expect(page.getByText("example/gallery")).toBeVisible();
185188
await expect(page.getByText(".github/workflows/release.yml")).toBeVisible();
186189
await expect(page.getByText("v1.2.3")).toBeVisible();
187-
await expect(page.getByText("All version tags")).toBeVisible();
188-
await page.getByRole("button", { name: "Approve workflow" }).click();
190+
await expect(page.getByText("All package version tags")).toBeVisible();
191+
await page.getByRole("button", { name: "Connect repository" }).click();
189192
await expect(page.getByRole("button", { name: "Check for workflow requests" })).toBeVisible();
190193
expect(confirmed).toBe(true);
191194
});

apps/release-service/src/publisher-do/operations-restore.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,14 @@ export class OperationsRestoreStore {
395395
integerField(value, "createdAt"),
396396
now,
397397
);
398+
if (value["repositoryConnection"] === true) {
399+
this.storage.sql.exec(
400+
`INSERT INTO repository_connection_policies (package_slug, created_at)
401+
VALUES (?, ?) ON CONFLICT(package_slug) DO NOTHING`,
402+
packageSlug,
403+
now,
404+
);
405+
}
398406
}
399407

400408
#restoreIntent(value: unknown, now: number): void {

0 commit comments

Comments
 (0)