From 6286f000e873a7a72a6c737f365d92ff2dcebcb7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 14:57:09 -0500 Subject: [PATCH 01/18] Add CLI realm publish/unpublish commands --- .../boxel-cli/src/commands/realm/index.ts | 4 + .../boxel-cli/src/commands/realm/publish.ts | 280 ++++++++++++++++++ .../boxel-cli/src/commands/realm/unpublish.ts | 148 +++++++++ .../tests/integration/realm-publish.test.ts | 154 ++++++++++ 4 files changed, 586 insertions(+) create mode 100644 packages/boxel-cli/src/commands/realm/publish.ts create mode 100644 packages/boxel-cli/src/commands/realm/unpublish.ts create mode 100644 packages/boxel-cli/tests/integration/realm-publish.test.ts diff --git a/packages/boxel-cli/src/commands/realm/index.ts b/packages/boxel-cli/src/commands/realm/index.ts index 236ee710d7e..fc6f6077e4d 100644 --- a/packages/boxel-cli/src/commands/realm/index.ts +++ b/packages/boxel-cli/src/commands/realm/index.ts @@ -4,11 +4,13 @@ import { registerCreateCommand } from './create'; import { registerHistoryCommand } from './history'; import { registerListCommand } from './list'; import { registerMilestoneCommand } from './milestone'; +import { registerPublishCommand } from './publish'; import { registerPullCommand } from './pull'; import { registerPushCommand } from './push'; import { registerRemoveCommand } from './remove'; import { registerStatusCommand } from './status'; import { registerSyncCommand } from './sync'; +import { registerUnpublishCommand } from './unpublish'; import { registerWaitForReadyCommand } from './wait-for-ready'; import { registerWatchCommand } from './watch'; @@ -22,11 +24,13 @@ export function registerRealmCommand(program: Command): void { registerHistoryCommand(realm); registerListCommand(realm); registerMilestoneCommand(realm); + registerPublishCommand(realm); registerPullCommand(realm); registerPushCommand(realm); registerRemoveCommand(realm); const sync = registerSyncCommand(realm); registerStatusCommand(sync); + registerUnpublishCommand(realm); registerWaitForReadyCommand(realm); registerWatchCommand(realm); } diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts new file mode 100644 index 00000000000..e3df5cc9916 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -0,0 +1,280 @@ +import type { Command } from 'commander'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../../lib/profile-manager'; +import { unpublishRealm } from './unpublish'; +import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors'; + +const DEFAULT_TIMEOUT_MS = 300_000; +const READINESS_POLL_INTERVAL_MS = 1000; + +export interface PublishOptions { + /** Wait for the published realm to pass readiness check (default: true). */ + waitForReady?: boolean; + /** Readiness-poll timeout in milliseconds (default: 300_000). */ + timeoutMs?: number; + /** + * When the server returns 400/409 (e.g. an existing publication conflicts), + * unpublish the target URL first and retry once. Default: true. + */ + republish?: boolean; + profileManager?: ProfileManager; +} + +export interface PublishRealmResult { + publishedRealmURL: string; + publishedRealmId: string; + lastPublishedAt: string; + status: string; +} + +/** + * Publish a source realm to a published-realm URL. + * + * Speaks the contract documented at + * `packages/realm-server/handlers/handle-publish-realm.ts`: the server + * accepts the publish, returns `202 Accepted` with `status: "pending"`, + * and the client polls `//_readiness-check` until + * the realm is mounted and indexed. 200/201 are accepted too so this + * function survives any future move back to a synchronous handler. + */ +export async function publishRealm( + sourceRealmURL: string, + publishedRealmURL: string, + options: PublishOptions = {}, +): Promise { + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + throw new Error(NO_ACTIVE_PROFILE_ERROR); + } + + let normalizedSource = ensureTrailingSlash(sourceRealmURL); + let normalizedPublished = ensureTrailingSlash(publishedRealmURL); + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + + let response = await postPublish( + pm, + realmServerUrl, + normalizedSource, + normalizedPublished, + ); + + if ( + (response.status === 400 || response.status === 409) && + options.republish !== false + ) { + let conflictBody = await safeReadResponseText(response); + console.log( + `Publish returned ${response.status} (${conflictBody.slice(0, 200)}). Unpublishing and retrying.`, + ); + let unpublishResult = await unpublishRealm(normalizedPublished, { + profileManager: pm, + tolerateMissing: true, + }); + if (!unpublishResult.unpublished && !unpublishResult.notFound) { + throw new Error( + `Conflict on publish; unpublish-then-retry also failed: ${ + unpublishResult.error ?? 'unknown' + }`, + ); + } + response = await postPublish( + pm, + realmServerUrl, + normalizedSource, + normalizedPublished, + ); + } + + if ( + response.status !== 200 && + response.status !== 201 && + response.status !== 202 + ) { + let body = await safeReadResponseText(response); + throw new Error( + `Publish failed: HTTP ${response.status}: ${body.slice(0, 1000)}`, + ); + } + + let body = (await response.json()) as PublishResponseBody; + let attrs = body?.data?.attributes; + if (!attrs?.publishedRealmURL) { + throw new Error( + `Publish response missing data.attributes.publishedRealmURL: ${JSON.stringify( + body, + ).slice(0, 500)}`, + ); + } + + let result: PublishRealmResult = { + publishedRealmURL: ensureTrailingSlash(attrs.publishedRealmURL), + publishedRealmId: body.data.id, + lastPublishedAt: attrs.lastPublishedAt, + status: attrs.status, + }; + + if (options.waitForReady !== false) { + let timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let realmToken: string | undefined; + try { + let serverToken = await pm.getOrRefreshServerToken(); + realmToken = await pm.fetchAndStoreRealmToken( + result.publishedRealmURL, + serverToken, + ); + } catch { + // The published realm is permission-public-read; fall through to + // poll without an Authorization header. + } + await waitForPublishedRealmReady( + result.publishedRealmURL, + realmToken, + timeoutMs, + ); + } + + return result; +} + +interface PublishResponseBody { + data: { + type: 'published_realm'; + id: string; + attributes: { + sourceRealmURL: string; + publishedRealmURL: string; + lastPublishedAt: string; + status: string; + }; + }; +} + +async function postPublish( + pm: ProfileManager, + realmServerUrl: string, + sourceRealmURL: string, + publishedRealmURL: string, +): Promise { + return pm.authedRealmServerFetch(`${realmServerUrl}/_publish-realm`, { + method: 'POST', + headers: { + Accept: 'application/vnd.api+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sourceRealmURL, publishedRealmURL }), + }); +} + +async function waitForPublishedRealmReady( + publishedRealmURL: string, + realmToken: string | undefined, + timeoutMs: number, +): Promise { + let readinessUrl = new URL('_readiness-check', publishedRealmURL).href; + let startedAt = Date.now(); + let lastError: string | undefined; + + while (Date.now() - startedAt < timeoutMs) { + try { + let headers: Record = { + Accept: 'application/vnd.api+json', + }; + if (realmToken) { + headers.Authorization = realmToken; + } + let response = await fetch(readinessUrl, { headers }); + if (response.ok) { + return; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + let remaining = timeoutMs - (Date.now() - startedAt); + if (remaining <= 0) break; + await new Promise((r) => + setTimeout(r, Math.min(READINESS_POLL_INTERVAL_MS, remaining)), + ); + } + + throw new Error( + `Timed out after ${timeoutMs}ms waiting for ${publishedRealmURL} to pass readiness check${ + lastError ? `: ${lastError}` : '' + }`, + ); +} + +async function safeReadResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +interface PublishCliOptions { + noWait?: boolean; + timeout?: number; + noRepublish?: boolean; +} + +export function registerPublishCommand(realm: Command): void { + realm + .command('publish') + .description( + 'Publish a source realm to a published-realm URL, polling readiness until ready', + ) + .argument('', 'URL of the source realm to publish') + .argument( + '', + 'Public-facing URL the published copy will serve at', + ) + .option('--no-wait', 'Return as soon as the server accepts the publish') + .option( + '--timeout ', + `Readiness-poll timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})`, + parseTimeoutOption, + ) + .option( + '--no-republish', + 'Do not auto-unpublish + retry when the server returns 400/409', + ) + .action( + async ( + sourceRealmURL: string, + publishedRealmURL: string, + opts: PublishCliOptions, + ) => { + try { + let result = await publishRealm(sourceRealmURL, publishedRealmURL, { + waitForReady: opts.noWait !== true, + timeoutMs: opts.timeout, + republish: opts.noRepublish !== true, + }); + console.log( + `${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + } catch (err) { + console.error( + `${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, + ); +} + +function parseTimeoutOption(value: string): number { + let n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) { + throw new Error( + '--timeout must be a non-negative integer (milliseconds).', + ); + } + return n; +} diff --git a/packages/boxel-cli/src/commands/realm/unpublish.ts b/packages/boxel-cli/src/commands/realm/unpublish.ts new file mode 100644 index 00000000000..a6344df4df2 --- /dev/null +++ b/packages/boxel-cli/src/commands/realm/unpublish.ts @@ -0,0 +1,148 @@ +import type { Command } from 'commander'; +import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; +import { + getProfileManager, + NO_ACTIVE_PROFILE_ERROR, + type ProfileManager, +} from '../../lib/profile-manager'; +import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors'; + +export interface UnpublishOptions { + /** + * When true, do not fail if the server reports the realm was already + * unpublished. Useful for cleanup paths that must be idempotent (e.g. + * a PR-close hook that runs even if a previous close already unpublished). + * Default: false. + */ + tolerateMissing?: boolean; + profileManager?: ProfileManager; +} + +export interface UnpublishRealmResult { + publishedRealmURL: string; + unpublished: boolean; + notFound?: boolean; + error?: string; +} + +/** + * Unpublish a published realm. Mirrors `boxel realm publish`'s contract + * with `/_unpublish-realm`. + * + * The realm-server returns 200 on success and 422 with a "not found" body + * when the URL isn't currently published. We special-case the latter (and + * 404, defensively) so cleanup callers can run unconditionally. + */ +export async function unpublishRealm( + publishedRealmURL: string, + options: UnpublishOptions = {}, +): Promise { + let normalized = ensureTrailingSlash(publishedRealmURL); + let pm = options.profileManager ?? getProfileManager(); + let active = pm.getActiveProfile(); + if (!active) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: NO_ACTIVE_PROFILE_ERROR, + }; + } + + let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); + + let response: Response; + try { + response = await pm.authedRealmServerFetch( + `${realmServerUrl}/_unpublish-realm`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.api+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ publishedRealmURL: normalized }), + }, + ); + } catch (err) { + return { + publishedRealmURL: normalized, + unpublished: false, + error: `Failed to reach realm server: ${ + err instanceof Error ? err.message : String(err) + }`, + }; + } + + if (response.ok) { + return { publishedRealmURL: normalized, unpublished: true }; + } + + let body = await safeReadResponseText(response); + let looksLikeNotFound = + response.status === 404 || + (response.status === 422 && /not found/i.test(body)); + + if (looksLikeNotFound) { + if (options.tolerateMissing) { + return { publishedRealmURL: normalized, unpublished: false, notFound: true }; + } + return { + publishedRealmURL: normalized, + unpublished: false, + notFound: true, + error: `Published realm ${normalized} is not currently published`, + }; + } + + return { + publishedRealmURL: normalized, + unpublished: false, + error: `Realm server returned ${response.status}: ${body.slice(0, 500)}`, + }; +} + +async function safeReadResponseText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +interface UnpublishCliOptions { + tolerateMissing?: boolean; +} + +export function registerUnpublishCommand(realm: Command): void { + realm + .command('unpublish') + .description('Unpublish a published realm by its public-facing URL') + .argument('', 'URL of the published realm to remove') + .option( + '--tolerate-missing', + 'Exit successfully when the realm is already unpublished', + ) + .action( + async (publishedRealmURL: string, opts: UnpublishCliOptions) => { + let result = await unpublishRealm(publishedRealmURL, { + tolerateMissing: opts.tolerateMissing === true, + }); + + if (result.error) { + console.error(`${FG_RED}Error:${RESET} ${result.error}`); + process.exit(1); + } + + if (result.notFound) { + console.log( + `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + return; + } + + console.log( + `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + }, + ); +} diff --git a/packages/boxel-cli/tests/integration/realm-publish.test.ts b/packages/boxel-cli/tests/integration/realm-publish.test.ts new file mode 100644 index 00000000000..6554a9bd20f --- /dev/null +++ b/packages/boxel-cli/tests/integration/realm-publish.test.ts @@ -0,0 +1,154 @@ +import '../helpers/setup-realm-server'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRealm } from '../../src/commands/realm/create'; +import { publishRealm } from '../../src/commands/realm/publish'; +import { unpublishRealm } from '../../src/commands/realm/unpublish'; +import { + startTestRealmServer, + stopTestRealmServer, + createTestProfileDir, + setupTestProfile, + uniqueRealmName, + TEST_REALM_SERVER_URL, +} from '../helpers/integration'; +import type { ProfileManager } from '../../src/lib/profile-manager'; + +let profileManager: ProfileManager; +let cleanup: () => void; + +beforeAll(async () => { + await startTestRealmServer(); + let testProfile = createTestProfileDir(); + profileManager = testProfile.profileManager; + cleanup = testProfile.cleanup; + await setupTestProfile(profileManager); +}); + +afterAll(async () => { + cleanup?.(); + await stopTestRealmServer(); +}); + +async function createPublishableSource(): Promise { + let name = uniqueRealmName(); + let result = await createRealm(name, `Source ${name}`, { profileManager }); + return result.realmUrl; +} + +function uniquePublishedUrl(): string { + // Realm server enforces `domainsForPublishedRealms` (typically + // `['localhost']` in tests) — use a *.localhost subdomain so the URL + // passes the publish-handler's allow-list. The hostname resolves to + // 127.0.0.1 via RFC 6761, and the realm-server listens on the same + // port for any host, so fetch() reaches it. + let port = new URL(TEST_REALM_SERVER_URL).port; + return `http://published-${uniqueRealmName()}.localhost:${port}/`; +} + +describe('realm publish (integration)', () => { + it('accepts the 202 + status:pending response and polls readiness', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + let result = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + timeoutMs: 60_000, + }); + + expect(result.publishedRealmURL).toBe(publishedUrl); + expect(result.publishedRealmId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(result.lastPublishedAt).toBeTruthy(); + // The server signals async indexing with status:'pending'. publishRealm() + // must surface that value rather than failing the call — earlier + // boxel-home CI broke when callers required 200/201 and got a 202. + expect(result.status).toBe('pending'); + }, 90_000); + + it('returns without waiting when waitForReady is false', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + let result = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + expect(result.publishedRealmURL).toBe(publishedUrl); + expect(result.status).toBe('pending'); + }, 60_000); + + it('republishes by unpublishing first when the target URL already exists', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + // Republishing the same URL must succeed via the action's auto-recovery + // path (unpublish-then-retry on 400/409). This mirrors what + // boxel-home's PR preview flow needs across successive PR pushes. + let republished = await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + expect(republished.publishedRealmURL).toBe(publishedUrl); + }, 90_000); + + it('throws a useful error when the source realm does not exist', async () => { + let bogusSource = `${TEST_REALM_SERVER_URL}/does-not-exist-${uniqueRealmName()}/`; + let publishedUrl = uniquePublishedUrl(); + + await expect( + publishRealm(bogusSource, publishedUrl, { + profileManager, + waitForReady: false, + republish: false, + }), + ).rejects.toThrow(/Publish failed: HTTP/); + }, 30_000); +}); + +describe('realm unpublish (integration)', () => { + it('unpublishes a previously published realm', async () => { + let sourceUrl = await createPublishableSource(); + let publishedUrl = uniquePublishedUrl(); + + await publishRealm(sourceUrl, publishedUrl, { + profileManager, + waitForReady: false, + }); + + let result = await unpublishRealm(publishedUrl, { profileManager }); + + expect(result.unpublished).toBe(true); + expect(result.error).toBeUndefined(); + }, 60_000); + + it('treats a missing realm as success when tolerateMissing is set', async () => { + let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`; + + let result = await unpublishRealm(bogusUrl, { + profileManager, + tolerateMissing: true, + }); + + expect(result.unpublished).toBe(false); + expect(result.notFound).toBe(true); + expect(result.error).toBeUndefined(); + }, 30_000); + + it('reports an error for a missing realm when tolerateMissing is unset', async () => { + let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`; + + let result = await unpublishRealm(bogusUrl, { profileManager }); + + expect(result.unpublished).toBe(false); + expect(result.notFound).toBe(true); + expect(result.error).toMatch(/not currently published/); + }, 30_000); +}); From 0860a68fb17e543a63c128a6fa4765e1c318ebed Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 14:59:01 -0500 Subject: [PATCH 02/18] Add shared action to set up Boxel CLI --- .github/actions/_setup-boxel-cli/action.yml | 99 +++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/actions/_setup-boxel-cli/action.yml diff --git a/.github/actions/_setup-boxel-cli/action.yml b/.github/actions/_setup-boxel-cli/action.yml new file mode 100644 index 00000000000..2d082154938 --- /dev/null +++ b/.github/actions/_setup-boxel-cli/action.yml @@ -0,0 +1,99 @@ +name: Setup boxel-cli (internal) +description: | + Internal helper for the workspace-sync, publish-preview-realm, and + unpublish-preview-realm composite actions. Checks out the boxel monorepo + at the ref the parent action was invoked with, builds packages/boxel-cli + from source, puts `boxel` on $PATH, and runs `boxel profile add` so + subsequent `boxel realm …` calls authenticate as the supplied Matrix + user. Not intended to be referenced by consumer workflows directly. + +inputs: + matrix-url: + description: Matrix server URL (used to infer the Matrix domain). + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Realm-server URL. Optional — when the Matrix ID's domain is a known + one (stack.cards / boxel.ai / localhost), boxel profile add fills + this in from built-in defaults. Pass explicitly when those defaults + do not apply. + required: false + default: "" + +runs: + using: composite + steps: + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + # Clone outside the consumer's $GITHUB_WORKSPACE so the source tree + # never appears under any `boxel realm push .` input path. + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 + with: + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Put boxel on PATH + shell: bash + run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" From c11d17158890a44592c481c35a40f34b382799fa Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 14:59:31 -0500 Subject: [PATCH 03/18] Add preview actions --- .../actions/publish-preview-realm/action.yml | 117 ++++++++++++++++++ .../unpublish-preview-realm/action.yml | 41 ++++++ 2 files changed, 158 insertions(+) create mode 100644 .github/actions/publish-preview-realm/action.yml create mode 100644 .github/actions/unpublish-preview-realm/action.yml diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml new file mode 100644 index 00000000000..94b90813002 --- /dev/null +++ b/.github/actions/publish-preview-realm/action.yml @@ -0,0 +1,117 @@ +name: Publish preview realm +description: | + End-to-end preview-realm deploy for a PR: idempotently create a source + realm, push local content into it, and publish it at a public URL. The + publish step accepts the realm-server's 202 + status:pending contract + and polls /_readiness-check until the realm is mounted and indexed. + Replaces the matrix→openid→server-session curl block + manual 200/201 + check that boxel-home maintained in preview-realm.yml. + +inputs: + realm-name: + description: | + Endpoint slug for the source realm. Lowercase letters, digits, and + hyphens only. The source realm URL will be + `${realm-server-url}/${matrix-username}/${realm-name}/`. + required: true + display-name: + description: Human-readable display name for the source realm. + required: true + published-realm-url: + description: | + Public URL the published realm will serve at, e.g. + `https://${user}.staging.boxel.dev/boxel-home-pr-57/`. Must satisfy + the realm-server's `domainsForPublishedRealms` allow-list. + required: true + source-path: + description: Local directory whose contents are pushed to the source realm. + required: false + default: "." + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Realm-server URL. Defaults inferred from matrix-url for + stack.cards / boxel.ai / localhost domains; supply this for any + other environment. + required: false + default: "" + readiness-timeout-ms: + description: | + Maximum time to wait for the published realm to pass its readiness + check before failing the action. Default: 300000 (5 minutes). + required: false + default: "300000" + +outputs: + source-realm-url: + description: URL of the source realm that was created / synced. + value: ${{ steps.urls.outputs.source-realm-url }} + published-realm-url: + description: URL the realm is published at. + value: ${{ inputs.published-realm-url }} + +runs: + using: composite + steps: + - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + with: + matrix-url: ${{ inputs.matrix-url }} + matrix-username: ${{ inputs.matrix-username }} + matrix-password: ${{ inputs.matrix-password }} + realm-server-url: ${{ inputs.realm-server-url }} + + - id: urls + shell: bash + env: + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_NAME: ${{ inputs.realm-name }} + run: | + # Mirror boxel profile add's environment-default logic so the + # computed source URL matches the profile's realmServerUrl. + if [ -z "$REALM_SERVER_URL" ]; then + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + REALM_SERVER_URL="https://app.boxel.ai/" + elif echo "$MATRIX_URL" | grep -q "matrix-staging.stack.cards"; then + REALM_SERVER_URL="https://realms-staging.stack.cards/" + else + echo "::error::realm-server-url must be supplied for matrix-url=$MATRIX_URL" + exit 1 + fi + fi + BASE="${REALM_SERVER_URL%/}" + SOURCE_URL="${BASE}/${MATRIX_USERNAME}/${REALM_NAME}/" + echo "source-realm-url=${SOURCE_URL}" >> "$GITHUB_OUTPUT" + echo "Source realm URL: ${SOURCE_URL}" + + - name: Create source realm if absent + shell: bash + env: + REALM_NAME: ${{ inputs.realm-name }} + DISPLAY_NAME: ${{ inputs.display-name }} + run: boxel realm create "$REALM_NAME" "$DISPLAY_NAME" + + - name: Push content to source realm + shell: bash + env: + SOURCE_PATH: ${{ inputs.source-path }} + SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} + run: boxel realm push "$SOURCE_PATH" "$SOURCE_URL" --delete + + - name: Publish + wait for readiness + shell: bash + env: + SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} + PUBLISHED_URL: ${{ inputs.published-realm-url }} + TIMEOUT_MS: ${{ inputs.readiness-timeout-ms }} + run: | + boxel realm publish "$SOURCE_URL" "$PUBLISHED_URL" --timeout "$TIMEOUT_MS" diff --git a/.github/actions/unpublish-preview-realm/action.yml b/.github/actions/unpublish-preview-realm/action.yml new file mode 100644 index 00000000000..64f8ea3973b --- /dev/null +++ b/.github/actions/unpublish-preview-realm/action.yml @@ -0,0 +1,41 @@ +name: Unpublish preview realm +description: | + Unpublish a preview realm via `boxel realm unpublish`. Tolerates the + "not currently published" case so PR-close cleanup can run + unconditionally without failing when an earlier run already removed it. + +inputs: + published-realm-url: + description: Public-facing URL of the published realm to remove. + required: true + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Realm-server URL. Defaults inferred from matrix-url for + stack.cards / boxel.ai / localhost domains; supply this for any + other environment. + required: false + default: "" + +runs: + using: composite + steps: + - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + with: + matrix-url: ${{ inputs.matrix-url }} + matrix-username: ${{ inputs.matrix-username }} + matrix-password: ${{ inputs.matrix-password }} + realm-server-url: ${{ inputs.realm-server-url }} + + - shell: bash + env: + PUBLISHED_URL: ${{ inputs.published-realm-url }} + run: boxel realm unpublish "$PUBLISHED_URL" --tolerate-missing From 71dac1854e4f3b6f7ddd0b68ee837f67d3ebb55f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 14:59:38 -0500 Subject: [PATCH 04/18] Add workspace sync action --- .github/actions/workspace-sync/action.yml | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/actions/workspace-sync/action.yml diff --git a/.github/actions/workspace-sync/action.yml b/.github/actions/workspace-sync/action.yml new file mode 100644 index 00000000000..6d59afd09f3 --- /dev/null +++ b/.github/actions/workspace-sync/action.yml @@ -0,0 +1,81 @@ +name: Sync to Boxel workspace +description: | + Sync a local directory to a Boxel workspace using `boxel realm push` + from the in-tree @cardstack/boxel-cli. Replaces hand-rolled curl / + boxel-cli-clone steps that boxel-home, boxel-catalog, and boxel-skills + each used to maintain independently. + +inputs: + workspace-url: + description: Target workspace URL. + required: true + matrix-url: + description: Matrix server URL. + required: true + matrix-username: + description: Matrix username without the leading @ or :domain suffix. + required: true + matrix-password: + description: Matrix password. + required: true + realm-server-url: + description: | + Optional realm-server URL. Defaults are inferred from matrix-url for + stack.cards / boxel.ai / localhost domains; supply this for any other + environment. + required: false + default: "" + source-path: + description: Local directory to push to the workspace. + required: false + default: "." + delete: + description: When 'true', delete remote files that do not exist locally. + required: false + default: "true" + dry-run: + description: When 'true', show what would change without writing. + required: false + default: "false" + +outputs: + sync-output: + description: Captured stdout/stderr from `boxel realm push`. + value: ${{ steps.sync.outputs.sync-output }} + +runs: + using: composite + steps: + - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + with: + matrix-url: ${{ inputs.matrix-url }} + matrix-username: ${{ inputs.matrix-username }} + matrix-password: ${{ inputs.matrix-password }} + realm-server-url: ${{ inputs.realm-server-url }} + + - id: sync + shell: bash + env: + SOURCE_PATH: ${{ inputs.source-path }} + WORKSPACE_URL: ${{ inputs.workspace-url }} + DELETE: ${{ inputs.delete }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + URL="${WORKSPACE_URL%/}/" + ARGS=(realm push "$SOURCE_PATH" "$URL") + if [ "$DELETE" = "true" ]; then ARGS+=(--delete); fi + if [ "$DRY_RUN" = "true" ]; then ARGS+=(--dry-run); fi + + set +e + OUTPUT=$(boxel "${ARGS[@]}" 2>&1) + STATUS=$? + set -e + echo "$OUTPUT" + + { + echo "sync-output<> "$GITHUB_OUTPUT" + + exit $STATUS From 7a15ea55f300c8e3d0cb3647cc20c7c93fba9672 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 15:10:27 -0500 Subject: [PATCH 05/18] Run boxel-cli tests on realm-server changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxel-cli integration test suite covers the publish/unpublish and readiness-check HTTP contract the realm-server exposes. CS-11161 was the result of a realm-server-only PR changing /_publish-realm from a synchronous 200 to an async 202 + poll model without anything tripping in that PR's CI — boxel-cli-test only ran when packages/boxel-cli/** files changed. Widening the trigger to also include realm-server changes makes that class of drift fail pre-merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81d0e82d5fc..2a2309639e7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -947,7 +947,11 @@ jobs: boxel-cli-test: name: Boxel CLI Tests needs: [change-check, test-web-assets] - if: needs.change-check.outputs.boxel-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + # Also run on realm-server changes: boxel-cli's integration tests cover + # the publish/unpublish/readiness-check HTTP contract the realm-server + # exposes, so a realm-server-only PR that drifts that contract (as in + # CS-11161) needs to fail here pre-merge rather than on main post-merge. + if: needs.change-check.outputs.boxel-cli == 'true' || needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: group: boxel-cli-test-${{ github.head_ref || github.run_id }} From 43a358d0ae68e0daecc21c961329ef49fabe4ec9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 15:42:57 -0500 Subject: [PATCH 06/18] Add formatting autofixes --- .../boxel-cli/src/commands/realm/publish.ts | 4 +- .../boxel-cli/src/commands/realm/unpublish.ts | 42 ++++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index e3df5cc9916..3b93fc39930 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -272,9 +272,7 @@ export function registerPublishCommand(realm: Command): void { function parseTimeoutOption(value: string): number { let n = Number.parseInt(value, 10); if (!Number.isFinite(n) || n < 0 || String(n) !== value.trim()) { - throw new Error( - '--timeout must be a non-negative integer (milliseconds).', - ); + throw new Error('--timeout must be a non-negative integer (milliseconds).'); } return n; } diff --git a/packages/boxel-cli/src/commands/realm/unpublish.ts b/packages/boxel-cli/src/commands/realm/unpublish.ts index a6344df4df2..7f16d6dae69 100644 --- a/packages/boxel-cli/src/commands/realm/unpublish.ts +++ b/packages/boxel-cli/src/commands/realm/unpublish.ts @@ -84,7 +84,11 @@ export async function unpublishRealm( if (looksLikeNotFound) { if (options.tolerateMissing) { - return { publishedRealmURL: normalized, unpublished: false, notFound: true }; + return { + publishedRealmURL: normalized, + unpublished: false, + notFound: true, + }; } return { publishedRealmURL: normalized, @@ -122,27 +126,25 @@ export function registerUnpublishCommand(realm: Command): void { '--tolerate-missing', 'Exit successfully when the realm is already unpublished', ) - .action( - async (publishedRealmURL: string, opts: UnpublishCliOptions) => { - let result = await unpublishRealm(publishedRealmURL, { - tolerateMissing: opts.tolerateMissing === true, - }); + .action(async (publishedRealmURL: string, opts: UnpublishCliOptions) => { + let result = await unpublishRealm(publishedRealmURL, { + tolerateMissing: opts.tolerateMissing === true, + }); - if (result.error) { - console.error(`${FG_RED}Error:${RESET} ${result.error}`); - process.exit(1); - } - - if (result.notFound) { - console.log( - `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`, - ); - return; - } + if (result.error) { + console.error(`${FG_RED}Error:${RESET} ${result.error}`); + process.exit(1); + } + if (result.notFound) { console.log( - `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + `Already unpublished: ${FG_CYAN}${result.publishedRealmURL}${RESET}`, ); - }, - ); + return; + } + + console.log( + `${FG_GREEN}Unpublished:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, + ); + }); } From 9a09fa0fc500d2609c0de7269ca3db70bb9329ad Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 18:04:34 -0500 Subject: [PATCH 07/18] Read --no-wait/--no-republish from Commander's positive keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander exposes negated options (`--no-foo`) on the positive key (`foo`) defaulting to `true`. The CLI shim for `boxel realm publish` was reading `opts.noWait` / `opts.noRepublish`, which Commander never sets — so `--no-wait` and `--no-republish` were silently ignored. The programmatic `publishRealm(...)` API was unaffected, which is why the integration tests still passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/boxel-cli/src/commands/realm/publish.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index 3b93fc39930..b35542ff509 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -218,9 +218,12 @@ async function safeReadResponseText(response: Response): Promise { } interface PublishCliOptions { - noWait?: boolean; + // Commander exposes `--no-wait` / `--no-republish` on the positive + // keys (`wait` / `republish`), defaulting to `true` and flipping to + // `false` when the negated flag is passed. + wait?: boolean; timeout?: number; - noRepublish?: boolean; + republish?: boolean; } export function registerPublishCommand(realm: Command): void { @@ -252,9 +255,9 @@ export function registerPublishCommand(realm: Command): void { ) => { try { let result = await publishRealm(sourceRealmURL, publishedRealmURL, { - waitForReady: opts.noWait !== true, + waitForReady: opts.wait !== false, timeoutMs: opts.timeout, - republish: opts.noRepublish !== true, + republish: opts.republish !== false, }); console.log( `${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, From fa4d6ac8727aa13bb03f07602656af9bf9cc0672 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 18:07:53 -0500 Subject: [PATCH 08/18] Add CLI flag tests for boxel realm publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts `publishCliOptsToOptions` so the Commander-flag → PublishOptions translation is testable without a realm-server. The new tests would have caught the CS-11161 bug class: they assert (a) Commander populates the positive `wait` / `republish` keys for negated flags (not `noWait` / `noRepublish`), and (b) the translation lowers `--no-wait` to `waitForReady: false` and `--no-republish` to `republish: false`. The integration tests exercise the programmatic publishRealm() API only and missed both halves. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../boxel-cli/src/commands/realm/publish.ts | 22 +++- .../tests/commands/realm-publish-cli.test.ts | 104 ++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 packages/boxel-cli/tests/commands/realm-publish-cli.test.ts diff --git a/packages/boxel-cli/src/commands/realm/publish.ts b/packages/boxel-cli/src/commands/realm/publish.ts index b35542ff509..82bd2fdd090 100644 --- a/packages/boxel-cli/src/commands/realm/publish.ts +++ b/packages/boxel-cli/src/commands/realm/publish.ts @@ -217,7 +217,7 @@ async function safeReadResponseText(response: Response): Promise { } } -interface PublishCliOptions { +export interface PublishCliOptions { // Commander exposes `--no-wait` / `--no-republish` on the positive // keys (`wait` / `republish`), defaulting to `true` and flipping to // `false` when the negated flag is passed. @@ -226,6 +226,16 @@ interface PublishCliOptions { republish?: boolean; } +export function publishCliOptsToOptions( + opts: PublishCliOptions, +): PublishOptions { + return { + waitForReady: opts.wait !== false, + timeoutMs: opts.timeout, + republish: opts.republish !== false, + }; +} + export function registerPublishCommand(realm: Command): void { realm .command('publish') @@ -254,11 +264,11 @@ export function registerPublishCommand(realm: Command): void { opts: PublishCliOptions, ) => { try { - let result = await publishRealm(sourceRealmURL, publishedRealmURL, { - waitForReady: opts.wait !== false, - timeoutMs: opts.timeout, - republish: opts.republish !== false, - }); + let result = await publishRealm( + sourceRealmURL, + publishedRealmURL, + publishCliOptsToOptions(opts), + ); console.log( `${FG_GREEN}Published:${RESET} ${FG_CYAN}${result.publishedRealmURL}${RESET}`, ); diff --git a/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts b/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts new file mode 100644 index 00000000000..4c92a339042 --- /dev/null +++ b/packages/boxel-cli/tests/commands/realm-publish-cli.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { + publishCliOptsToOptions, + registerPublishCommand, +} from '../../src/commands/realm/publish'; + +// Regression test for the negated-flag bug fixed in CS-11161. Commander +// exposes `--no-foo` options on the positive key (`foo`) defaulting to +// `true`, so the CLI shim must read `opts.wait` / `opts.republish` — not +// `opts.noWait` / `opts.noRepublish`. The integration tests exercise the +// programmatic `publishRealm(...)` API only and missed this entirely. + +function parsePublishFlags(extra: string[]): { + capturedOpts: Record | null; + capturedArgs: [string, string] | null; +} { + let capturedOpts: Record | null = null; + let capturedArgs: [string, string] | null = null; + + const program = new Command().exitOverride(); + const realm = program.command('realm'); + registerPublishCommand(realm); + const publishCmd = realm.commands.find((c) => c.name() === 'publish'); + if (!publishCmd) { + throw new Error('publish subcommand not registered'); + } + + // Replace the action so we capture the parsed opts without executing + // publishRealm() (which would need a real realm-server). + publishCmd.action((sourceUrl: string, publishedUrl: string, opts: object) => { + capturedOpts = { ...opts } as Record; + capturedArgs = [sourceUrl, publishedUrl]; + }); + + program.parse( + [ + 'realm', + 'publish', + 'http://src.localhost/', + 'http://pub.localhost/', + ...extra, + ], + { from: 'user' }, + ); + + return { capturedOpts, capturedArgs }; +} + +describe('boxel realm publish CLI flags', () => { + it('with no flags, opts.wait and opts.republish default to true', () => { + const { capturedOpts, capturedArgs } = parsePublishFlags([]); + expect(capturedOpts).not.toBeNull(); + expect(capturedOpts!.wait).toBe(true); + expect(capturedOpts!.republish).toBe(true); + expect(capturedArgs).toEqual([ + 'http://src.localhost/', + 'http://pub.localhost/', + ]); + }); + + it('--no-wait flips opts.wait to false (not opts.noWait)', () => { + const { capturedOpts } = parsePublishFlags(['--no-wait']); + expect(capturedOpts!.wait).toBe(false); + expect(capturedOpts!.republish).toBe(true); + // Commander does not synthesize a noWait key — guarding against + // a future regression where someone reintroduces opts.noWait. + expect('noWait' in capturedOpts!).toBe(false); + }); + + it('--no-republish flips opts.republish to false (not opts.noRepublish)', () => { + const { capturedOpts } = parsePublishFlags(['--no-republish']); + expect(capturedOpts!.republish).toBe(false); + expect(capturedOpts!.wait).toBe(true); + expect('noRepublish' in capturedOpts!).toBe(false); + }); + + it('--timeout parses to opts.timeout as a number', () => { + const { capturedOpts } = parsePublishFlags(['--timeout', '60000']); + expect(capturedOpts!.timeout).toBe(60000); + }); +}); + +describe('publishCliOptsToOptions translation', () => { + it('translates empty opts to defaults that preserve current behavior', () => { + expect(publishCliOptsToOptions({})).toEqual({ + waitForReady: true, + timeoutMs: undefined, + republish: true, + }); + }); + + it('translates --no-wait to waitForReady: false', () => { + expect(publishCliOptsToOptions({ wait: false }).waitForReady).toBe(false); + }); + + it('translates --no-republish to republish: false', () => { + expect(publishCliOptsToOptions({ republish: false }).republish).toBe(false); + }); + + it('propagates --timeout into timeoutMs', () => { + expect(publishCliOptsToOptions({ timeout: 12345 }).timeoutMs).toBe(12345); + }); +}); From 02e17b4b34918e6c30fcec6e48f54da0dec593b0 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 18:15:46 -0500 Subject: [PATCH 09/18] Tighten publish-preview-realm doc to match URL inference The realm-server-url input description claimed defaults were inferred for stack.cards / boxel.ai / localhost, but this action's own inference block only knows the two CI environments and hard-fails otherwise. Localhost works fine in workspace-sync and unpublish-preview-realm because they forward to _setup-boxel-cli without inferring a URL themselves, but here the action needs the URL to compose the source- realm URL. Documenting the actual contract rather than expanding the inference, since no CI workflow invokes this against a localhost matrix. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/publish-preview-realm/action.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml index 94b90813002..59701a3b875 100644 --- a/.github/actions/publish-preview-realm/action.yml +++ b/.github/actions/publish-preview-realm/action.yml @@ -38,9 +38,10 @@ inputs: required: true realm-server-url: description: | - Realm-server URL. Defaults inferred from matrix-url for - stack.cards / boxel.ai / localhost domains; supply this for any - other environment. + Realm-server URL. When empty, the action infers it from matrix-url + for the boxel.ai (production) and matrix-staging.stack.cards + (staging) cases only. Supply this explicitly for any other + environment, including local dev (e.g. http://localhost:4201/). required: false default: "" readiness-timeout-ms: From 80a87a4405421ed51cce8c2143c1db021dc4a410 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 15 May 2026 18:24:46 -0500 Subject: [PATCH 10/18] Add TEMPORARY workflow exercising the new actions end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the boxel-cli-test stack setup (build host/icons/ui via the reusable test-web-assets workflow, start matrix, register realm users, boot the dev services) and then invokes publish-preview-realm, workspace-sync, and unpublish-preview-realm against the local stack — referencing each action at @\${{ github.sha }} the same way a real consumer would. Delete this workflow once a successful run is captured. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .github/workflows/cs-11161-action-demo.yml diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml new file mode 100644 index 00000000000..a8303c4d761 --- /dev/null +++ b/.github/workflows/cs-11161-action-demo.yml @@ -0,0 +1,103 @@ +name: CS-11161 action demo (TEMPORARY) + +# TEMPORARY: exercises the three new composite actions +# (workspace-sync, publish-preview-realm, unpublish-preview-realm) +# end-to-end against the same localhost matrix + realm-server stack +# that boxel-cli-test boots, so the PR shows them running in CI. +# Delete this file (and the test-web-assets call below) after the +# demo run is captured. + +on: + workflow_dispatch: + push: + branches: [cs-11161-extract-workspace-sync-action] + +concurrency: + group: cs-11161-action-demo-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test-web-assets: + name: Build test web assets + uses: ./.github/workflows/test-web-assets.yaml + with: + caller: cs-11161-action-demo + + action-demo: + name: Run new actions against local stack + needs: [test-web-assets] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/init + + - name: Download test web assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.test-web-assets.outputs.artifact_name }} + path: .test-web-assets-artifact + - name: Restore test web assets into workspace + shell: bash + run: | + shopt -s dotglob + cp -a .test-web-assets-artifact/. ./ + + - name: Start Matrix + run: pnpm start:matrix + working-directory: packages/realm-server + - name: Register realm users + run: pnpm register-realm-users + working-directory: packages/matrix + - name: Start dev stack (base realm + prerenderer) + run: | + mise run test-services:matrix | tee -a /tmp/server.log & + timeout 600 bash -c 'until curl -sf http://localhost:4200 > /dev/null && curl -sf -H "Accept: application/vnd.api+json" http://localhost:4201/base/_readiness-check > /dev/null; do sleep 2; done' + + - name: Build a small fixture to push + shell: bash + run: | + mkdir -p /tmp/cs-11161-fixture + cat > /tmp/cs-11161-fixture/README.md <<'EOF' + # CS-11161 action demo + Pushed by the temporary demo workflow to prove the new + workspace-sync / publish-preview-realm / unpublish-preview-realm + actions work end-to-end on a fresh runner. + EOF + + - name: Demo publish-preview-realm + id: publish_demo + uses: cardstack/boxel/.github/actions/publish-preview-realm@${{ github.sha }} + with: + realm-name: cs-11161-demo-${{ github.run_id }} + display-name: "CS-11161 demo realm" + published-realm-url: http://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + source-path: /tmp/cs-11161-fixture + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: http://localhost:4201/ + readiness-timeout-ms: "120000" + + - name: Demo workspace-sync (re-push to the just-created source realm) + uses: cardstack/boxel/.github/actions/workspace-sync@${{ github.sha }} + with: + workspace-url: ${{ steps.publish_demo.outputs.source-realm-url }} + source-path: /tmp/cs-11161-fixture + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: http://localhost:4201/ + + - name: Demo unpublish-preview-realm + if: always() && steps.publish_demo.outcome == 'success' + uses: cardstack/boxel/.github/actions/unpublish-preview-realm@${{ github.sha }} + with: + published-realm-url: http://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + matrix-url: http://localhost:8008 + matrix-username: user + matrix-password: password + realm-server-url: http://localhost:4201/ + + - name: Print server logs + if: ${{ !cancelled() }} + run: cat /tmp/server.log From a2fc68536dd88b5d4c2a8e896c302f02972feb7b Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 07:22:14 -0500 Subject: [PATCH 11/18] Pin demo workflow's action refs to branch name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions doesn't allow expressions in a workflow step's `uses:` ref — only literal strings. The `github.action_ref` expression that works in the actions' own internal `uses:` clauses is evaluated by a different parser context (composite-action steps) and isn't usable here. The demo workflow only runs on this branch anyway, so pinning to the branch name resolves to the latest pushed tip at run time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml index a8303c4d761..80df7825f7b 100644 --- a/.github/workflows/cs-11161-action-demo.yml +++ b/.github/workflows/cs-11161-action-demo.yml @@ -66,7 +66,7 @@ jobs: - name: Demo publish-preview-realm id: publish_demo - uses: cardstack/boxel/.github/actions/publish-preview-realm@${{ github.sha }} + uses: cardstack/boxel/.github/actions/publish-preview-realm@cs-11161-extract-workspace-sync-action with: realm-name: cs-11161-demo-${{ github.run_id }} display-name: "CS-11161 demo realm" @@ -79,7 +79,7 @@ jobs: readiness-timeout-ms: "120000" - name: Demo workspace-sync (re-push to the just-created source realm) - uses: cardstack/boxel/.github/actions/workspace-sync@${{ github.sha }} + uses: cardstack/boxel/.github/actions/workspace-sync@cs-11161-extract-workspace-sync-action with: workspace-url: ${{ steps.publish_demo.outputs.source-realm-url }} source-path: /tmp/cs-11161-fixture @@ -90,7 +90,7 @@ jobs: - name: Demo unpublish-preview-realm if: always() && steps.publish_demo.outcome == 'success' - uses: cardstack/boxel/.github/actions/unpublish-preview-realm@${{ github.sha }} + uses: cardstack/boxel/.github/actions/unpublish-preview-realm@cs-11161-extract-workspace-sync-action with: published-realm-url: http://cs-11161-demo-${{ github.run_id }}.localhost:4201/ matrix-url: http://localhost:8008 From 38a744637f06a056fa562d0bda8979c3e7ba7577 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 07:33:08 -0500 Subject: [PATCH 12/18] Inline boxel-cli setup into each composite action GitHub Actions' workflow parser rejects expressions in a composite action's `uses:` ref (the same restriction that hit the demo workflow, just at a different parser layer). That meant the three top-level actions could not call `_setup-boxel-cli@\${{ github.action_ref }}` and have it run at the ref the consumer pinned. Inlining the setup scaffolding (clone boxel, install pnpm, build the CLI, configure the profile) into each action removes the cross-action `uses:` and makes them parseable. The cost is ~50 lines of duplication across three files; the comment block at the top of `runs:` calls that out so future edits stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/_setup-boxel-cli/action.yml | 99 ------------------- .../actions/publish-preview-realm/action.yml | 75 +++++++++++++- .../unpublish-preview-realm/action.yml | 75 +++++++++++++- .github/actions/workspace-sync/action.yml | 75 +++++++++++++- 4 files changed, 210 insertions(+), 114 deletions(-) delete mode 100644 .github/actions/_setup-boxel-cli/action.yml diff --git a/.github/actions/_setup-boxel-cli/action.yml b/.github/actions/_setup-boxel-cli/action.yml deleted file mode 100644 index 2d082154938..00000000000 --- a/.github/actions/_setup-boxel-cli/action.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Setup boxel-cli (internal) -description: | - Internal helper for the workspace-sync, publish-preview-realm, and - unpublish-preview-realm composite actions. Checks out the boxel monorepo - at the ref the parent action was invoked with, builds packages/boxel-cli - from source, puts `boxel` on $PATH, and runs `boxel profile add` so - subsequent `boxel realm …` calls authenticate as the supplied Matrix - user. Not intended to be referenced by consumer workflows directly. - -inputs: - matrix-url: - description: Matrix server URL (used to infer the Matrix domain). - required: true - matrix-username: - description: Matrix username without the leading @ or :domain suffix. - required: true - matrix-password: - description: Matrix password. - required: true - realm-server-url: - description: | - Realm-server URL. Optional — when the Matrix ID's domain is a known - one (stack.cards / boxel.ai / localhost), boxel profile add fills - this in from built-in defaults. Pass explicitly when those defaults - do not apply. - required: false - default: "" - -runs: - using: composite - steps: - - name: Checkout boxel monorepo for boxel-cli source - shell: bash - env: - BOXEL_REPO: ${{ github.action_repository }} - BOXEL_REF: ${{ github.action_ref }} - run: | - # Clone outside the consumer's $GITHUB_WORKSPACE so the source tree - # never appears under any `boxel realm push .` input path. - SRC="${RUNNER_TEMP}/boxel-src" - rm -rf "$SRC" - git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" - git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" - git -C "$SRC" checkout FETCH_HEAD - echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" - - - name: Install mise (node + pnpm) - uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 - with: - install: true - working_directory: ${{ env.BOXEL_SRC }} - - - name: Cache pnpm store - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ~/.local/share/pnpm/store - key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} - restore-keys: | - ${{ runner.os }}-boxel-pnpm- - - - name: Install workspace dependencies - shell: bash - working-directory: ${{ env.BOXEL_SRC }} - run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... - - - name: Build boxel-cli - shell: bash - working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli - run: pnpm build - - - name: Put boxel on PATH - shell: bash - run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" - - - name: Configure boxel profile - shell: bash - env: - BOXEL_PASSWORD: ${{ inputs.matrix-password }} - MATRIX_URL: ${{ inputs.matrix-url }} - MATRIX_USERNAME: ${{ inputs.matrix-username }} - REALM_SERVER_URL: ${{ inputs.realm-server-url }} - run: | - if echo "$MATRIX_URL" | grep -q "boxel.ai"; then - DOMAIN="boxel.ai" - elif echo "$MATRIX_URL" | grep -q "stack.cards"; then - DOMAIN="stack.cards" - else - DOMAIN="localhost" - fi - ARGS=( - profile add - --user "@${MATRIX_USERNAME}:${DOMAIN}" - --password "$BOXEL_PASSWORD" - --matrix-url "$MATRIX_URL" - ) - if [ -n "$REALM_SERVER_URL" ]; then - ARGS+=(--realm-server-url "$REALM_SERVER_URL") - fi - boxel "${ARGS[@]}" diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml index 59701a3b875..dee29898796 100644 --- a/.github/actions/publish-preview-realm/action.yml +++ b/.github/actions/publish-preview-realm/action.yml @@ -59,15 +59,80 @@ outputs: description: URL the realm is published at. value: ${{ inputs.published-realm-url }} +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# workspace-sync and unpublish-preview-realm: a composite action's +# `uses:` ref must be a literal string at parse time (no expressions), so +# we cannot delegate to a sibling action and have it run at the same ref +# the consumer pinned. Keep them in sync when changing any one. runs: using: composite steps: - - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 + with: + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: - matrix-url: ${{ inputs.matrix-url }} - matrix-username: ${{ inputs.matrix-username }} - matrix-password: ${{ inputs.matrix-password }} - realm-server-url: ${{ inputs.realm-server-url }} + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Put boxel on PATH + shell: bash + run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" - id: urls shell: bash diff --git a/.github/actions/unpublish-preview-realm/action.yml b/.github/actions/unpublish-preview-realm/action.yml index 64f8ea3973b..8abd2a9442c 100644 --- a/.github/actions/unpublish-preview-realm/action.yml +++ b/.github/actions/unpublish-preview-realm/action.yml @@ -25,15 +25,80 @@ inputs: required: false default: "" +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# workspace-sync and publish-preview-realm: a composite action's `uses:` +# ref must be a literal string at parse time (no expressions), so we +# cannot delegate to a sibling action and have it run at the same ref the +# consumer pinned. Keep them in sync when changing any one. runs: using: composite steps: - - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: - matrix-url: ${{ inputs.matrix-url }} - matrix-username: ${{ inputs.matrix-username }} - matrix-password: ${{ inputs.matrix-password }} - realm-server-url: ${{ inputs.realm-server-url }} + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Put boxel on PATH + shell: bash + run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" - shell: bash env: diff --git a/.github/actions/workspace-sync/action.yml b/.github/actions/workspace-sync/action.yml index 6d59afd09f3..af34a9f7e57 100644 --- a/.github/actions/workspace-sync/action.yml +++ b/.github/actions/workspace-sync/action.yml @@ -43,15 +43,80 @@ outputs: description: Captured stdout/stderr from `boxel realm push`. value: ${{ steps.sync.outputs.sync-output }} +# The first seven steps duplicate the same setup-boxel-cli scaffolding in +# publish-preview-realm and unpublish-preview-realm: a composite action's +# `uses:` ref must be a literal string at parse time (no expressions), so +# we cannot delegate to a sibling action and have it run at the same ref +# the consumer pinned. Keep them in sync when changing any one. runs: using: composite steps: - - uses: cardstack/boxel/.github/actions/_setup-boxel-cli@${{ github.action_ref }} + - name: Checkout boxel monorepo for boxel-cli source + shell: bash + env: + BOXEL_REPO: ${{ github.action_repository }} + BOXEL_REF: ${{ github.action_ref }} + run: | + SRC="${RUNNER_TEMP}/boxel-src" + rm -rf "$SRC" + git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" + git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" + git -C "$SRC" checkout FETCH_HEAD + echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" + + - name: Install mise (node + pnpm) + uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 with: - matrix-url: ${{ inputs.matrix-url }} - matrix-username: ${{ inputs.matrix-username }} - matrix-password: ${{ inputs.matrix-password }} - realm-server-url: ${{ inputs.realm-server-url }} + install: true + working_directory: ${{ env.BOXEL_SRC }} + + - name: Cache pnpm store + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.local/share/pnpm/store + key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} + restore-keys: | + ${{ runner.os }}-boxel-pnpm- + + - name: Install workspace dependencies + shell: bash + working-directory: ${{ env.BOXEL_SRC }} + run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... + + - name: Build boxel-cli + shell: bash + working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli + run: pnpm build + + - name: Put boxel on PATH + shell: bash + run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + + - name: Configure boxel profile + shell: bash + env: + BOXEL_PASSWORD: ${{ inputs.matrix-password }} + MATRIX_URL: ${{ inputs.matrix-url }} + MATRIX_USERNAME: ${{ inputs.matrix-username }} + REALM_SERVER_URL: ${{ inputs.realm-server-url }} + run: | + if echo "$MATRIX_URL" | grep -q "boxel.ai"; then + DOMAIN="boxel.ai" + elif echo "$MATRIX_URL" | grep -q "stack.cards"; then + DOMAIN="stack.cards" + else + DOMAIN="localhost" + fi + ARGS=( + profile add + --user "@${MATRIX_USERNAME}:${DOMAIN}" + --password "$BOXEL_PASSWORD" + --matrix-url "$MATRIX_URL" + ) + if [ -n "$REALM_SERVER_URL" ]; then + ARGS+=(--realm-server-url "$REALM_SERVER_URL") + fi + boxel "${ARGS[@]}" - id: sync shell: bash From 496ccc32aa67ca24a34963ddbbd36ff4669076f9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 08:01:05 -0500 Subject: [PATCH 13/18] Use HTTPS URLs in demo workflow to match dev stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The realm-server and vite host both speak HTTPS+HTTP/2 only (the prerenderer needs HTTP/2 multiplexing, see infra:ensure-dev-cert). The previous demo workflow's http:// probe and http:// action inputs would never match the running services — the readiness loop spun for 10 minutes and timed out. Update the probe to mirror boxel-cli-test (curl -sk + %{http_code} check) and pass https://localhost:4201/ to the action inputs. Matrix stays on http://localhost:8008 — synapse doesn't terminate TLS locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml index 80df7825f7b..eded3e2c380 100644 --- a/.github/workflows/cs-11161-action-demo.yml +++ b/.github/workflows/cs-11161-action-demo.yml @@ -51,7 +51,11 @@ jobs: - name: Start dev stack (base realm + prerenderer) run: | mise run test-services:matrix | tee -a /tmp/server.log & - timeout 600 bash -c 'until curl -sf http://localhost:4200 > /dev/null && curl -sf -H "Accept: application/vnd.api+json" http://localhost:4201/base/_readiness-check > /dev/null; do sleep 2; done' + # Realm-server and vite host both speak HTTPS+HTTP/2 only; `-k` + # tolerates the mkcert leaf cert from infra:ensure-dev-cert. The + # %{http_code} check guards against the dispatcher's 3xx ahead + # of the base realm finishing its initial index. + timeout 600 bash -c 'until curl -sk -o /dev/null -w "%{http_code}" https://localhost:4200/ | grep -qx 200 && curl -sk -o /dev/null -w "%{http_code}" -H "Accept: application/vnd.api+json" https://localhost:4201/base/_readiness-check | grep -qx 200; do sleep 2; done' - name: Build a small fixture to push shell: bash @@ -70,12 +74,12 @@ jobs: with: realm-name: cs-11161-demo-${{ github.run_id }} display-name: "CS-11161 demo realm" - published-realm-url: http://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ source-path: /tmp/cs-11161-fixture matrix-url: http://localhost:8008 matrix-username: user matrix-password: password - realm-server-url: http://localhost:4201/ + realm-server-url: https://localhost:4201/ readiness-timeout-ms: "120000" - name: Demo workspace-sync (re-push to the just-created source realm) @@ -86,17 +90,17 @@ jobs: matrix-url: http://localhost:8008 matrix-username: user matrix-password: password - realm-server-url: http://localhost:4201/ + realm-server-url: https://localhost:4201/ - name: Demo unpublish-preview-realm if: always() && steps.publish_demo.outcome == 'success' uses: cardstack/boxel/.github/actions/unpublish-preview-realm@cs-11161-extract-workspace-sync-action with: - published-realm-url: http://cs-11161-demo-${{ github.run_id }}.localhost:4201/ + published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ matrix-url: http://localhost:8008 matrix-username: user matrix-password: password - realm-server-url: http://localhost:4201/ + realm-server-url: https://localhost:4201/ - name: Print server logs if: ${{ !cancelled() }} From 2424d894d23e0822378f621df7d2dd5268917786 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 13:52:59 -0500 Subject: [PATCH 14/18] Symlink boxel into /usr/local/bin instead of $GITHUB_PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous setup wrote BOXEL_SRC/packages/boxel-cli/bin to $GITHUB_PATH, but that directory holds the script named `boxel.js` (the file package.json's `bin: { boxel }` maps to). Even when $GITHUB_PATH did propagate, the next step would fail to find a `boxel` binary. Symlinking the entry point into /usr/local/bin — which is already on the default PATH — gives subsequent steps the same `boxel` command that npm/pnpm-installed callers see. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/publish-preview-realm/action.yml | 9 +++++++-- .github/actions/unpublish-preview-realm/action.yml | 9 +++++++-- .github/actions/workspace-sync/action.yml | 9 +++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml index dee29898796..6db4e6b0496 100644 --- a/.github/actions/publish-preview-realm/action.yml +++ b/.github/actions/publish-preview-realm/action.yml @@ -104,9 +104,14 @@ runs: working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli run: pnpm build - - name: Put boxel on PATH + - name: Make `boxel` available on PATH shell: bash - run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - name: Configure boxel profile shell: bash diff --git a/.github/actions/unpublish-preview-realm/action.yml b/.github/actions/unpublish-preview-realm/action.yml index 8abd2a9442c..a8efce2cf4a 100644 --- a/.github/actions/unpublish-preview-realm/action.yml +++ b/.github/actions/unpublish-preview-realm/action.yml @@ -70,9 +70,14 @@ runs: working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli run: pnpm build - - name: Put boxel on PATH + - name: Make `boxel` available on PATH shell: bash - run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - name: Configure boxel profile shell: bash diff --git a/.github/actions/workspace-sync/action.yml b/.github/actions/workspace-sync/action.yml index af34a9f7e57..f7022c4b969 100644 --- a/.github/actions/workspace-sync/action.yml +++ b/.github/actions/workspace-sync/action.yml @@ -88,9 +88,14 @@ runs: working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli run: pnpm build - - name: Put boxel on PATH + - name: Make `boxel` available on PATH shell: bash - run: echo "${BOXEL_SRC}/packages/boxel-cli/bin" >> "$GITHUB_PATH" + # The bin directory contains `boxel.js` (the file package.json's + # `bin` field maps to `boxel`). Just prepending the directory to + # PATH would expose `boxel.js`, not `boxel`. Symlink the entry + # point under /usr/local/bin so consumers can call `boxel …` the + # way npm/pnpm-installed callers do. + run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - name: Configure boxel profile shell: bash From ac7ae89b70ce215ec92d15e65d575dbfa7033664 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 17:15:50 -0500 Subject: [PATCH 15/18] Register all matrix users in demo workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm register-realm-users runs in realms-only mode and only registers the REALM_USERS list — it skips EXTRA_USERS, which is where the user/password account this demo authenticates as lives. Switch to register-all so synapse has that account when boxel realm create attempts a Matrix login (boxel-cli #4851 run failed with 403 because the account never existed in synapse). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml index eded3e2c380..418f61ddd97 100644 --- a/.github/workflows/cs-11161-action-demo.yml +++ b/.github/workflows/cs-11161-action-demo.yml @@ -45,8 +45,11 @@ jobs: - name: Start Matrix run: pnpm start:matrix working-directory: packages/realm-server - - name: Register realm users - run: pnpm register-realm-users + - name: Register matrix users (all) + # `register-realm-users` runs in realms-only mode and skips the + # `user`/`password` account this demo authenticates as. Use the + # `register-all` variant so EXTRA_USERS get created too. + run: pnpm register-all working-directory: packages/matrix - name: Start dev stack (base realm + prerenderer) run: | From c85dc82d9eed0269b3c26c37b6b464a18fc8d06f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 17:33:25 -0500 Subject: [PATCH 16/18] Start postgres before register-all in demo workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register-all writes EXTRA_USERS into the boxel users table via ensureUserRecord, so it requires postgres up — register-realm-users (realms-only) doesn't. Add an explicit start:pg + wait-for-pg step pair before the matrix-user registration, mirroring the order the ci.yaml matrix-test job uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml index 418f61ddd97..ed614186f0b 100644 --- a/.github/workflows/cs-11161-action-demo.yml +++ b/.github/workflows/cs-11161-action-demo.yml @@ -42,9 +42,19 @@ jobs: shopt -s dotglob cp -a .test-web-assets-artifact/. ./ + - name: Start PostgreSQL + # register-all (below) writes EXTRA_USERS to the boxel users + # table, so postgres must be up before the matrix-side + # registration runs. boxel-cli-test gets away without this step + # because it uses register-realm-users (realms-only), which + # never touches the DB. + run: pnpm start:pg + working-directory: packages/realm-server - name: Start Matrix run: pnpm start:matrix working-directory: packages/realm-server + - name: Wait for PostgreSQL to accept connections + run: timeout 60 bash -c 'until (echo > /dev/tcp/127.0.0.1/5435) >/dev/null 2>&1; do sleep 1; done' - name: Register matrix users (all) # `register-realm-users` runs in realms-only mode and skips the # `user`/`password` account this demo authenticates as. Use the From b544ca652e1643352601752e8855765d881bb36f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 17:44:29 -0500 Subject: [PATCH 17/18] Run DB migrations before register-all register-all's ensureUserRecord step writes to the boxel users table, but the boxel database is only created by the realm-server's migrations on first boot. Without those migrations, register-all hits 'database "boxel" does not exist'. Run `pnpm migrate` (realm-server's PgAdapter-driven migrator) between the wait-for-pg step and the matrix-user registration to make the target schema exist. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cs-11161-action-demo.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml index ed614186f0b..0f33e8fbc7b 100644 --- a/.github/workflows/cs-11161-action-demo.yml +++ b/.github/workflows/cs-11161-action-demo.yml @@ -55,6 +55,14 @@ jobs: working-directory: packages/realm-server - name: Wait for PostgreSQL to accept connections run: timeout 60 bash -c 'until (echo > /dev/tcp/127.0.0.1/5435) >/dev/null 2>&1; do sleep 1; done' + - name: Run database migrations + # register-all calls ensureUserRecord which writes to the boxel + # users table. The boxel database (and that table) are created + # by the realm-server's migrations on first boot. Run them up + # front so the registration step has a target schema to write + # against. + run: pnpm migrate + working-directory: packages/realm-server - name: Register matrix users (all) # `register-realm-users` runs in realms-only mode and skips the # `user`/`password` account this demo authenticates as. Use the From 62b11f467ec6870cc5b584a0cf99621796587af9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 19 May 2026 18:57:37 -0500 Subject: [PATCH 18/18] Remove shared GitHub Actions; tracked separately in CS-11180 The publish-preview-realm / unpublish-preview-realm / workspace-sync composite actions and their TEMPORARY demo workflow have been split off into their own PR + Linear ticket (CS-11180) so this PR can land on the small CLI half without waiting on the ~30-min action demo each iteration. The action files are preserved on the new branch cs-11180-extract-shared-preview-realm-github-actions-to-monorepo, which keeps the CLI commits in its ancestry so the demo can still exercise the CLI from this PR while it's in review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../actions/publish-preview-realm/action.yml | 188 ------------------ .../unpublish-preview-realm/action.yml | 111 ----------- .github/actions/workspace-sync/action.yml | 151 -------------- .github/workflows/cs-11161-action-demo.yml | 128 ------------ 4 files changed, 578 deletions(-) delete mode 100644 .github/actions/publish-preview-realm/action.yml delete mode 100644 .github/actions/unpublish-preview-realm/action.yml delete mode 100644 .github/actions/workspace-sync/action.yml delete mode 100644 .github/workflows/cs-11161-action-demo.yml diff --git a/.github/actions/publish-preview-realm/action.yml b/.github/actions/publish-preview-realm/action.yml deleted file mode 100644 index 6db4e6b0496..00000000000 --- a/.github/actions/publish-preview-realm/action.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: Publish preview realm -description: | - End-to-end preview-realm deploy for a PR: idempotently create a source - realm, push local content into it, and publish it at a public URL. The - publish step accepts the realm-server's 202 + status:pending contract - and polls /_readiness-check until the realm is mounted and indexed. - Replaces the matrix→openid→server-session curl block + manual 200/201 - check that boxel-home maintained in preview-realm.yml. - -inputs: - realm-name: - description: | - Endpoint slug for the source realm. Lowercase letters, digits, and - hyphens only. The source realm URL will be - `${realm-server-url}/${matrix-username}/${realm-name}/`. - required: true - display-name: - description: Human-readable display name for the source realm. - required: true - published-realm-url: - description: | - Public URL the published realm will serve at, e.g. - `https://${user}.staging.boxel.dev/boxel-home-pr-57/`. Must satisfy - the realm-server's `domainsForPublishedRealms` allow-list. - required: true - source-path: - description: Local directory whose contents are pushed to the source realm. - required: false - default: "." - matrix-url: - description: Matrix server URL. - required: true - matrix-username: - description: Matrix username without the leading @ or :domain suffix. - required: true - matrix-password: - description: Matrix password. - required: true - realm-server-url: - description: | - Realm-server URL. When empty, the action infers it from matrix-url - for the boxel.ai (production) and matrix-staging.stack.cards - (staging) cases only. Supply this explicitly for any other - environment, including local dev (e.g. http://localhost:4201/). - required: false - default: "" - readiness-timeout-ms: - description: | - Maximum time to wait for the published realm to pass its readiness - check before failing the action. Default: 300000 (5 minutes). - required: false - default: "300000" - -outputs: - source-realm-url: - description: URL of the source realm that was created / synced. - value: ${{ steps.urls.outputs.source-realm-url }} - published-realm-url: - description: URL the realm is published at. - value: ${{ inputs.published-realm-url }} - -# The first seven steps duplicate the same setup-boxel-cli scaffolding in -# workspace-sync and unpublish-preview-realm: a composite action's -# `uses:` ref must be a literal string at parse time (no expressions), so -# we cannot delegate to a sibling action and have it run at the same ref -# the consumer pinned. Keep them in sync when changing any one. -runs: - using: composite - steps: - - name: Checkout boxel monorepo for boxel-cli source - shell: bash - env: - BOXEL_REPO: ${{ github.action_repository }} - BOXEL_REF: ${{ github.action_ref }} - run: | - SRC="${RUNNER_TEMP}/boxel-src" - rm -rf "$SRC" - git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" - git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" - git -C "$SRC" checkout FETCH_HEAD - echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" - - - name: Install mise (node + pnpm) - uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 - with: - install: true - working_directory: ${{ env.BOXEL_SRC }} - - - name: Cache pnpm store - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ~/.local/share/pnpm/store - key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} - restore-keys: | - ${{ runner.os }}-boxel-pnpm- - - - name: Install workspace dependencies - shell: bash - working-directory: ${{ env.BOXEL_SRC }} - run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... - - - name: Build boxel-cli - shell: bash - working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli - run: pnpm build - - - name: Make `boxel` available on PATH - shell: bash - # The bin directory contains `boxel.js` (the file package.json's - # `bin` field maps to `boxel`). Just prepending the directory to - # PATH would expose `boxel.js`, not `boxel`. Symlink the entry - # point under /usr/local/bin so consumers can call `boxel …` the - # way npm/pnpm-installed callers do. - run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - - - name: Configure boxel profile - shell: bash - env: - BOXEL_PASSWORD: ${{ inputs.matrix-password }} - MATRIX_URL: ${{ inputs.matrix-url }} - MATRIX_USERNAME: ${{ inputs.matrix-username }} - REALM_SERVER_URL: ${{ inputs.realm-server-url }} - run: | - if echo "$MATRIX_URL" | grep -q "boxel.ai"; then - DOMAIN="boxel.ai" - elif echo "$MATRIX_URL" | grep -q "stack.cards"; then - DOMAIN="stack.cards" - else - DOMAIN="localhost" - fi - ARGS=( - profile add - --user "@${MATRIX_USERNAME}:${DOMAIN}" - --password "$BOXEL_PASSWORD" - --matrix-url "$MATRIX_URL" - ) - if [ -n "$REALM_SERVER_URL" ]; then - ARGS+=(--realm-server-url "$REALM_SERVER_URL") - fi - boxel "${ARGS[@]}" - - - id: urls - shell: bash - env: - REALM_SERVER_URL: ${{ inputs.realm-server-url }} - MATRIX_URL: ${{ inputs.matrix-url }} - MATRIX_USERNAME: ${{ inputs.matrix-username }} - REALM_NAME: ${{ inputs.realm-name }} - run: | - # Mirror boxel profile add's environment-default logic so the - # computed source URL matches the profile's realmServerUrl. - if [ -z "$REALM_SERVER_URL" ]; then - if echo "$MATRIX_URL" | grep -q "boxel.ai"; then - REALM_SERVER_URL="https://app.boxel.ai/" - elif echo "$MATRIX_URL" | grep -q "matrix-staging.stack.cards"; then - REALM_SERVER_URL="https://realms-staging.stack.cards/" - else - echo "::error::realm-server-url must be supplied for matrix-url=$MATRIX_URL" - exit 1 - fi - fi - BASE="${REALM_SERVER_URL%/}" - SOURCE_URL="${BASE}/${MATRIX_USERNAME}/${REALM_NAME}/" - echo "source-realm-url=${SOURCE_URL}" >> "$GITHUB_OUTPUT" - echo "Source realm URL: ${SOURCE_URL}" - - - name: Create source realm if absent - shell: bash - env: - REALM_NAME: ${{ inputs.realm-name }} - DISPLAY_NAME: ${{ inputs.display-name }} - run: boxel realm create "$REALM_NAME" "$DISPLAY_NAME" - - - name: Push content to source realm - shell: bash - env: - SOURCE_PATH: ${{ inputs.source-path }} - SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} - run: boxel realm push "$SOURCE_PATH" "$SOURCE_URL" --delete - - - name: Publish + wait for readiness - shell: bash - env: - SOURCE_URL: ${{ steps.urls.outputs.source-realm-url }} - PUBLISHED_URL: ${{ inputs.published-realm-url }} - TIMEOUT_MS: ${{ inputs.readiness-timeout-ms }} - run: | - boxel realm publish "$SOURCE_URL" "$PUBLISHED_URL" --timeout "$TIMEOUT_MS" diff --git a/.github/actions/unpublish-preview-realm/action.yml b/.github/actions/unpublish-preview-realm/action.yml deleted file mode 100644 index a8efce2cf4a..00000000000 --- a/.github/actions/unpublish-preview-realm/action.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Unpublish preview realm -description: | - Unpublish a preview realm via `boxel realm unpublish`. Tolerates the - "not currently published" case so PR-close cleanup can run - unconditionally without failing when an earlier run already removed it. - -inputs: - published-realm-url: - description: Public-facing URL of the published realm to remove. - required: true - matrix-url: - description: Matrix server URL. - required: true - matrix-username: - description: Matrix username without the leading @ or :domain suffix. - required: true - matrix-password: - description: Matrix password. - required: true - realm-server-url: - description: | - Realm-server URL. Defaults inferred from matrix-url for - stack.cards / boxel.ai / localhost domains; supply this for any - other environment. - required: false - default: "" - -# The first seven steps duplicate the same setup-boxel-cli scaffolding in -# workspace-sync and publish-preview-realm: a composite action's `uses:` -# ref must be a literal string at parse time (no expressions), so we -# cannot delegate to a sibling action and have it run at the same ref the -# consumer pinned. Keep them in sync when changing any one. -runs: - using: composite - steps: - - name: Checkout boxel monorepo for boxel-cli source - shell: bash - env: - BOXEL_REPO: ${{ github.action_repository }} - BOXEL_REF: ${{ github.action_ref }} - run: | - SRC="${RUNNER_TEMP}/boxel-src" - rm -rf "$SRC" - git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" - git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" - git -C "$SRC" checkout FETCH_HEAD - echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" - - - name: Install mise (node + pnpm) - uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 - with: - install: true - working_directory: ${{ env.BOXEL_SRC }} - - - name: Cache pnpm store - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ~/.local/share/pnpm/store - key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} - restore-keys: | - ${{ runner.os }}-boxel-pnpm- - - - name: Install workspace dependencies - shell: bash - working-directory: ${{ env.BOXEL_SRC }} - run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... - - - name: Build boxel-cli - shell: bash - working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli - run: pnpm build - - - name: Make `boxel` available on PATH - shell: bash - # The bin directory contains `boxel.js` (the file package.json's - # `bin` field maps to `boxel`). Just prepending the directory to - # PATH would expose `boxel.js`, not `boxel`. Symlink the entry - # point under /usr/local/bin so consumers can call `boxel …` the - # way npm/pnpm-installed callers do. - run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - - - name: Configure boxel profile - shell: bash - env: - BOXEL_PASSWORD: ${{ inputs.matrix-password }} - MATRIX_URL: ${{ inputs.matrix-url }} - MATRIX_USERNAME: ${{ inputs.matrix-username }} - REALM_SERVER_URL: ${{ inputs.realm-server-url }} - run: | - if echo "$MATRIX_URL" | grep -q "boxel.ai"; then - DOMAIN="boxel.ai" - elif echo "$MATRIX_URL" | grep -q "stack.cards"; then - DOMAIN="stack.cards" - else - DOMAIN="localhost" - fi - ARGS=( - profile add - --user "@${MATRIX_USERNAME}:${DOMAIN}" - --password "$BOXEL_PASSWORD" - --matrix-url "$MATRIX_URL" - ) - if [ -n "$REALM_SERVER_URL" ]; then - ARGS+=(--realm-server-url "$REALM_SERVER_URL") - fi - boxel "${ARGS[@]}" - - - shell: bash - env: - PUBLISHED_URL: ${{ inputs.published-realm-url }} - run: boxel realm unpublish "$PUBLISHED_URL" --tolerate-missing diff --git a/.github/actions/workspace-sync/action.yml b/.github/actions/workspace-sync/action.yml deleted file mode 100644 index f7022c4b969..00000000000 --- a/.github/actions/workspace-sync/action.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Sync to Boxel workspace -description: | - Sync a local directory to a Boxel workspace using `boxel realm push` - from the in-tree @cardstack/boxel-cli. Replaces hand-rolled curl / - boxel-cli-clone steps that boxel-home, boxel-catalog, and boxel-skills - each used to maintain independently. - -inputs: - workspace-url: - description: Target workspace URL. - required: true - matrix-url: - description: Matrix server URL. - required: true - matrix-username: - description: Matrix username without the leading @ or :domain suffix. - required: true - matrix-password: - description: Matrix password. - required: true - realm-server-url: - description: | - Optional realm-server URL. Defaults are inferred from matrix-url for - stack.cards / boxel.ai / localhost domains; supply this for any other - environment. - required: false - default: "" - source-path: - description: Local directory to push to the workspace. - required: false - default: "." - delete: - description: When 'true', delete remote files that do not exist locally. - required: false - default: "true" - dry-run: - description: When 'true', show what would change without writing. - required: false - default: "false" - -outputs: - sync-output: - description: Captured stdout/stderr from `boxel realm push`. - value: ${{ steps.sync.outputs.sync-output }} - -# The first seven steps duplicate the same setup-boxel-cli scaffolding in -# publish-preview-realm and unpublish-preview-realm: a composite action's -# `uses:` ref must be a literal string at parse time (no expressions), so -# we cannot delegate to a sibling action and have it run at the same ref -# the consumer pinned. Keep them in sync when changing any one. -runs: - using: composite - steps: - - name: Checkout boxel monorepo for boxel-cli source - shell: bash - env: - BOXEL_REPO: ${{ github.action_repository }} - BOXEL_REF: ${{ github.action_ref }} - run: | - SRC="${RUNNER_TEMP}/boxel-src" - rm -rf "$SRC" - git clone --depth 1 "https://github.com/${BOXEL_REPO}.git" "$SRC" - git -C "$SRC" fetch --depth 1 origin "$BOXEL_REF" - git -C "$SRC" checkout FETCH_HEAD - echo "BOXEL_SRC=$SRC" >> "$GITHUB_ENV" - - - name: Install mise (node + pnpm) - uses: jdx/mise-action@c1ecc8f748cd28cdeabf76dab3cccde4ce692fe4 # v4.0.0 - with: - install: true - working_directory: ${{ env.BOXEL_SRC }} - - - name: Cache pnpm store - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ~/.local/share/pnpm/store - key: ${{ runner.os }}-boxel-pnpm-${{ hashFiles(format('{0}/pnpm-lock.yaml', env.BOXEL_SRC)) }} - restore-keys: | - ${{ runner.os }}-boxel-pnpm- - - - name: Install workspace dependencies - shell: bash - working-directory: ${{ env.BOXEL_SRC }} - run: pnpm install --frozen-lockfile --filter @cardstack/boxel-cli... - - - name: Build boxel-cli - shell: bash - working-directory: ${{ env.BOXEL_SRC }}/packages/boxel-cli - run: pnpm build - - - name: Make `boxel` available on PATH - shell: bash - # The bin directory contains `boxel.js` (the file package.json's - # `bin` field maps to `boxel`). Just prepending the directory to - # PATH would expose `boxel.js`, not `boxel`. Symlink the entry - # point under /usr/local/bin so consumers can call `boxel …` the - # way npm/pnpm-installed callers do. - run: sudo ln -sf "${BOXEL_SRC}/packages/boxel-cli/bin/boxel.js" /usr/local/bin/boxel - - - name: Configure boxel profile - shell: bash - env: - BOXEL_PASSWORD: ${{ inputs.matrix-password }} - MATRIX_URL: ${{ inputs.matrix-url }} - MATRIX_USERNAME: ${{ inputs.matrix-username }} - REALM_SERVER_URL: ${{ inputs.realm-server-url }} - run: | - if echo "$MATRIX_URL" | grep -q "boxel.ai"; then - DOMAIN="boxel.ai" - elif echo "$MATRIX_URL" | grep -q "stack.cards"; then - DOMAIN="stack.cards" - else - DOMAIN="localhost" - fi - ARGS=( - profile add - --user "@${MATRIX_USERNAME}:${DOMAIN}" - --password "$BOXEL_PASSWORD" - --matrix-url "$MATRIX_URL" - ) - if [ -n "$REALM_SERVER_URL" ]; then - ARGS+=(--realm-server-url "$REALM_SERVER_URL") - fi - boxel "${ARGS[@]}" - - - id: sync - shell: bash - env: - SOURCE_PATH: ${{ inputs.source-path }} - WORKSPACE_URL: ${{ inputs.workspace-url }} - DELETE: ${{ inputs.delete }} - DRY_RUN: ${{ inputs.dry-run }} - run: | - URL="${WORKSPACE_URL%/}/" - ARGS=(realm push "$SOURCE_PATH" "$URL") - if [ "$DELETE" = "true" ]; then ARGS+=(--delete); fi - if [ "$DRY_RUN" = "true" ]; then ARGS+=(--dry-run); fi - - set +e - OUTPUT=$(boxel "${ARGS[@]}" 2>&1) - STATUS=$? - set -e - echo "$OUTPUT" - - { - echo "sync-output<> "$GITHUB_OUTPUT" - - exit $STATUS diff --git a/.github/workflows/cs-11161-action-demo.yml b/.github/workflows/cs-11161-action-demo.yml deleted file mode 100644 index 0f33e8fbc7b..00000000000 --- a/.github/workflows/cs-11161-action-demo.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: CS-11161 action demo (TEMPORARY) - -# TEMPORARY: exercises the three new composite actions -# (workspace-sync, publish-preview-realm, unpublish-preview-realm) -# end-to-end against the same localhost matrix + realm-server stack -# that boxel-cli-test boots, so the PR shows them running in CI. -# Delete this file (and the test-web-assets call below) after the -# demo run is captured. - -on: - workflow_dispatch: - push: - branches: [cs-11161-extract-workspace-sync-action] - -concurrency: - group: cs-11161-action-demo-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - test-web-assets: - name: Build test web assets - uses: ./.github/workflows/test-web-assets.yaml - with: - caller: cs-11161-action-demo - - action-demo: - name: Run new actions against local stack - needs: [test-web-assets] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - - name: Download test web assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - - - name: Start PostgreSQL - # register-all (below) writes EXTRA_USERS to the boxel users - # table, so postgres must be up before the matrix-side - # registration runs. boxel-cli-test gets away without this step - # because it uses register-realm-users (realms-only), which - # never touches the DB. - run: pnpm start:pg - working-directory: packages/realm-server - - name: Start Matrix - run: pnpm start:matrix - working-directory: packages/realm-server - - name: Wait for PostgreSQL to accept connections - run: timeout 60 bash -c 'until (echo > /dev/tcp/127.0.0.1/5435) >/dev/null 2>&1; do sleep 1; done' - - name: Run database migrations - # register-all calls ensureUserRecord which writes to the boxel - # users table. The boxel database (and that table) are created - # by the realm-server's migrations on first boot. Run them up - # front so the registration step has a target schema to write - # against. - run: pnpm migrate - working-directory: packages/realm-server - - name: Register matrix users (all) - # `register-realm-users` runs in realms-only mode and skips the - # `user`/`password` account this demo authenticates as. Use the - # `register-all` variant so EXTRA_USERS get created too. - run: pnpm register-all - working-directory: packages/matrix - - name: Start dev stack (base realm + prerenderer) - run: | - mise run test-services:matrix | tee -a /tmp/server.log & - # Realm-server and vite host both speak HTTPS+HTTP/2 only; `-k` - # tolerates the mkcert leaf cert from infra:ensure-dev-cert. The - # %{http_code} check guards against the dispatcher's 3xx ahead - # of the base realm finishing its initial index. - timeout 600 bash -c 'until curl -sk -o /dev/null -w "%{http_code}" https://localhost:4200/ | grep -qx 200 && curl -sk -o /dev/null -w "%{http_code}" -H "Accept: application/vnd.api+json" https://localhost:4201/base/_readiness-check | grep -qx 200; do sleep 2; done' - - - name: Build a small fixture to push - shell: bash - run: | - mkdir -p /tmp/cs-11161-fixture - cat > /tmp/cs-11161-fixture/README.md <<'EOF' - # CS-11161 action demo - Pushed by the temporary demo workflow to prove the new - workspace-sync / publish-preview-realm / unpublish-preview-realm - actions work end-to-end on a fresh runner. - EOF - - - name: Demo publish-preview-realm - id: publish_demo - uses: cardstack/boxel/.github/actions/publish-preview-realm@cs-11161-extract-workspace-sync-action - with: - realm-name: cs-11161-demo-${{ github.run_id }} - display-name: "CS-11161 demo realm" - published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ - source-path: /tmp/cs-11161-fixture - matrix-url: http://localhost:8008 - matrix-username: user - matrix-password: password - realm-server-url: https://localhost:4201/ - readiness-timeout-ms: "120000" - - - name: Demo workspace-sync (re-push to the just-created source realm) - uses: cardstack/boxel/.github/actions/workspace-sync@cs-11161-extract-workspace-sync-action - with: - workspace-url: ${{ steps.publish_demo.outputs.source-realm-url }} - source-path: /tmp/cs-11161-fixture - matrix-url: http://localhost:8008 - matrix-username: user - matrix-password: password - realm-server-url: https://localhost:4201/ - - - name: Demo unpublish-preview-realm - if: always() && steps.publish_demo.outcome == 'success' - uses: cardstack/boxel/.github/actions/unpublish-preview-realm@cs-11161-extract-workspace-sync-action - with: - published-realm-url: https://cs-11161-demo-${{ github.run_id }}.localhost:4201/ - matrix-url: http://localhost:8008 - matrix-username: user - matrix-password: password - realm-server-url: https://localhost:4201/ - - - name: Print server logs - if: ${{ !cancelled() }} - run: cat /tmp/server.log