Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: CI

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches:
- main
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Release Candidate Built-Dist

on:
pull_request:
types: [opened, synchronize, reopened, labeled]
push:
branches:
- main
- "release/**"

permissions:
contents: read

concurrency:
group: release-candidate-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
built-dist:
name: Release Candidate Built-Dist
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'release-candidate') || startsWith(github.head_ref, 'release/')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8

- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Test release controls
run: node --test scripts/atomic-release-tag.test.mjs scripts/require-release-candidate-check.test.mjs scripts/validate-release-resume.test.mjs

- name: Build package and plugin release artifacts
run: pnpm build:packages
env:
NODE_OPTIONS: --max-old-space-size=4096

- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-

- name: Install Playwright browser
run: pnpm exec playwright install --with-deps chromium

- name: Run built-dist release-candidate smoke
run: |
env -u BORING_RC_BREAK_CSS -u BORING_RC_BREAK_FIRST_SEND \
pnpm --filter workspace-playground run test:e2e:release-candidate
env:
BORING_PLAYGROUND_DIST_ONLY: "1"
BORING_AGENT_WORKSPACE_ROOT: ${{ runner.temp }}/workspace-playground-release-candidate
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test"
import { assertReleaseCandidateAgentCss } from "../src/release-candidate-css"

const agentDistCssPath = "/packages/agent/dist/front/styles.css"

function isAgentDistStylesheet(url: string): boolean {
return decodeURIComponent(new URL(url).pathname).replaceAll("\\", "/").includes(agentDistCssPath)
}

test("boots built dist and completes one Alpha first send", async ({ page }) => {
test.setTimeout(120_000)
expect(process.env.BORING_PLAYGROUND_DIST_ONLY, "RC smoke requires explicit dist-only mode").toBe("1")

const cssFault = process.env.BORING_RC_BREAK_CSS
if (cssFault === "small" || cssFault === "mime") {
await page.route(`**${agentDistCssPath}*`, async (route) => {
if (route.request().resourceType() !== "stylesheet") {
await route.continue()
return
}
const response = await route.fetch()
const body = cssFault === "small" ? Buffer.from("/* deliberately small RC fixture */") : await response.body()
await route.fulfill({
response,
body,
headers: {
...response.headers(),
"content-type": cssFault === "mime" ? "application/javascript" : "text/css; charset=utf-8",
},
})
})
}

let breakFirstSend = process.env.BORING_RC_BREAK_FIRST_SEND === "1"
if (breakFirstSend) {
await page.route("**/api/v1/agents/alpha/sessions", async (route) => {
if (breakFirstSend && route.request().method() === "POST") {
breakFirstSend = false
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: { code: "RC_EXPECTED_FIRST_SEND_FAILURE" } }),
})
return
}
await route.continue()
})
}

const agentCssResponse = page.waitForResponse(
(response) => response.request().resourceType() === "stylesheet"
&& isAgentDistStylesheet(response.url()),
{ timeout: 30_000 },
)
await page.goto("/?fresh=1")
const cssResponse = await agentCssResponse
const cssBody = await cssResponse.body()
console.log(
`[release-candidate] Agent stylesheet ${cssResponse.url()} ${cssResponse.headers()["content-type"] ?? "missing"} ${cssBody.byteLength} bytes`,
)
assertReleaseCandidateAgentCss(
cssResponse.url(),
cssResponse.headers()["content-type"],
cssBody.byteLength,
)
await expect(page.locator('aside[aria-label="App navigation"]')).toBeVisible({ timeout: 30_000 })

await page.getByRole("button", { name: "New chat with Alpha", exact: true }).click()
const chat = page.locator('[data-boring-agent-part="chat"][data-agent-type-id="alpha"]').last()
await expect(chat).toHaveAttribute("data-pi-chat-session-id", /^local-/, { timeout: 15_000 })
const localSessionId = await chat.getAttribute("data-pi-chat-session-id")

const created = page.waitForResponse((response) => {
const url = new URL(response.url())
return response.request().method() === "POST"
&& url.pathname === "/api/v1/agents/alpha/sessions"
})
await chat.getByRole("textbox", { name: "Agent prompt" }).fill(`release candidate ${Date.now()}`)
await chat.locator('[data-boring-agent-part="composer-submit"]').click()
expect((await created).status(), "first send must create an addressed session").toBe(201)

let adoptedSessionId = ""
await expect.poll(async () => {
adoptedSessionId = await chat.getAttribute("data-pi-chat-session-id") ?? ""
return adoptedSessionId
}, { timeout: 15_000 }).not.toBe(localSessionId)
expect(adoptedSessionId).not.toMatch(/^local-/)
await expect(chat).toHaveAttribute("data-pi-chat-connection", "connected", { timeout: 15_000 })
await expect(chat.getByText("PI_NATIVE_ASSISTANT_DONE:alpha", { exact: true })).toBeVisible({ timeout: 30_000 })
})
1 change: 1 addition & 0 deletions apps/workspace-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest run",
"test:e2e": "pnpm run build:deps && playwright test",
"test:e2e:addressed-agent": "pnpm run build:deps && playwright test apps/workspace-playground/e2e/agent-host-golden-route.spec.ts apps/workspace-playground/e2e/native-session-regressions.spec.ts",
"test:e2e:release-candidate": "playwright test apps/workspace-playground/e2e/release-candidate-golden-route.spec.ts",
"smoke:bridge": "pnpm --filter @hachej/boring-sandbox build && pnpm --filter @hachej/boring-bash build && pnpm --filter @hachej/boring-agent build && pnpm --filter @hachej/boring-workspace build && pnpm --filter @hachej/boring-ask-user build && tsx scripts/bridge-e2e.ts",
"eval": "AGENT_API_PORT=5350 vite-node src/eval/run.ts",
"eval:slash-command": "AGENT_API_PORT=5350 vite-node src/eval/run.ts src/eval/plugin-slash-command.yaml",
Expand Down
3 changes: 2 additions & 1 deletion apps/workspace-playground/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default defineConfig({
"BORING_AGENT_E2E_SCRIPTED_PI=1",
"BORING_AGENT_E2E_SCRIPTED_PI_TICK_MS=300",
"BORING_AGENT_E2E_SCRIPTED_PI_TOOL_DELAY_TICKS=20",
`BORING_PLAYGROUND_DIST_ONLY=${shell(process.env.BORING_PLAYGROUND_DIST_ONLY || "")}`,
"BORING_AGENT_CUSTOM_MODEL_PROVIDER=scripted-e2e",
"BORING_AGENT_CUSTOM_MODEL_ID=scripted-model",
"BORING_AGENT_CUSTOM_MODEL_BASE_URL=http://127.0.0.1",
Expand All @@ -67,7 +68,7 @@ export default defineConfig({
"pnpm exec vite",
].join(" ")}`,
port: VITE_PORT,
reuseExistingServer: !process.env.CI,
reuseExistingServer: !process.env.CI && !process.env.BORING_PLAYGROUND_DIST_ONLY,
timeout: 300_000,
},
})
6 changes: 6 additions & 0 deletions apps/workspace-playground/src/__tests__/build-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,10 @@ describe("workspace-playground build chain", () => {
it.each(["dev", "build", "test:e2e"])("%s runs build:deps before serving", (name) => {
expect(scripts[name] ?? "").toContain("build:deps")
})

it("keeps the release-candidate smoke build-free", () => {
const script = scripts["test:e2e:release-candidate"] ?? ""
expect(script).toContain("release-candidate-golden-route.spec.ts")
expect(script).not.toContain("build:deps")
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest"
import { assertReleaseCandidateAgentCss } from "../release-candidate-css"

const distUrl = "http://127.0.0.1:5380/@fs/repo/packages/agent/dist/front/styles.css"

describe("release-candidate Agent stylesheet assertion", () => {
it("accepts dist CSS with a CSS MIME and more than 100,000 bytes", () => {
expect(() => assertReleaseCandidateAgentCss(distUrl, "text/css; charset=utf-8", 100_001)).not.toThrow()
})

it("rejects CSS at the 100,000-byte boundary", () => {
expect(() => assertReleaseCandidateAgentCss(distUrl, "text/css", 100_000)).toThrow(/too small/)
})

it("rejects a wrong MIME", () => {
expect(() => assertReleaseCandidateAgentCss(distUrl, "application/javascript", 100_001)).toThrow(/wrong MIME/)
})

it("rejects a source stylesheet path", () => {
const sourceUrl = "http://127.0.0.1:5380/@fs/repo/packages/agent/src/front/styles.css"
expect(() => assertReleaseCandidateAgentCss(sourceUrl, "text/css", 100_001)).toThrow(/did not load from dist/)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"
import { assertReleaseCandidateDistModule } from "../release-candidate-dist"

describe("release-candidate dist-only module guard", () => {
it.each([
"/repo/packages/agent/src/front/index.ts",
"/repo/plugins/tasks/src/front/index.tsx?import",
])("rejects package source loaded through any Vite hook: %s", (id) => {
expect(() => assertReleaseCandidateDistModule(id, "transform")).toThrow(
/release-candidate dist-only resolution violation/,
)
})

it.each([
"/repo/packages/agent/dist/front/index.js",
"/repo/plugins/tasks/dist/front/index.js",
"/repo/apps/workspace-playground/src/front/main.tsx",
])("allows dist packages and playground fixture source: %s", (id) => {
expect(() => assertReleaseCandidateDistModule(id, "load")).not.toThrow()
})
})
8 changes: 7 additions & 1 deletion apps/workspace-playground/src/front/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { WorkspaceShell } from "./App"
import "@hachej/boring-workspace/globals.css"
import "@hachej/boring-agent/front/styles.css"
import agentStylesheetUrl from "@hachej/boring-agent/front/styles.css?url"
import "./app.css"

const agentStylesheet = document.createElement("link")
agentStylesheet.rel = "stylesheet"
agentStylesheet.href = agentStylesheetUrl
agentStylesheet.dataset.boringAgentStylesheet = "package-import"
document.head.append(agentStylesheet)

// The playground is the standalone dev surface for @hachej/boring-workspace.
// Auth, DB, user management, config — all of that belongs to @hachej/boring-core
// and is exercised separately by apps/full-app. This app still starts the
Expand Down
23 changes: 23 additions & 0 deletions apps/workspace-playground/src/release-candidate-css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const RELEASE_CANDIDATE_AGENT_CSS_MIN_BYTES = 100_000

export function assertReleaseCandidateAgentCss(
url: string,
contentType: string | undefined,
byteLength: number,
): void {
const pathname = decodeURIComponent(new URL(url).pathname).replaceAll("\\", "/")
if (!pathname.includes("/packages/agent/dist/front/styles.css")) {
throw new Error(`release-candidate Agent stylesheet did not load from dist: ${pathname}`)
}
if (pathname.includes("/packages/agent/src/")) {
throw new Error(`release-candidate Agent stylesheet loaded from source: ${pathname}`)
}
if (!/^text\/css(?:;|$)/i.test(contentType ?? "")) {
throw new Error(`release-candidate Agent stylesheet has wrong MIME: ${contentType ?? "missing"}`)
}
if (byteLength <= RELEASE_CANDIDATE_AGENT_CSS_MIN_BYTES) {
throw new Error(
`release-candidate Agent stylesheet is too small: ${byteLength} bytes (must be > ${RELEASE_CANDIDATE_AGENT_CSS_MIN_BYTES})`,
)
}
}
8 changes: 8 additions & 0 deletions apps/workspace-playground/src/release-candidate-dist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function assertReleaseCandidateDistModule(id: string, hook: string): void {
const normalized = id.split("?", 1)[0].replaceAll("\\", "/")
if (/\/(packages|plugins)\/[^/]+\/src(?:\/|$)/.test(normalized)) {
throw new Error(
`release-candidate dist-only resolution violation: ${hook} loaded ${normalized}`,
)
}
}
1 change: 1 addition & 0 deletions apps/workspace-playground/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
39 changes: 37 additions & 2 deletions apps/workspace-playground/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import { defineConfig } from "vite"
import { defineConfig, type Plugin } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { dirname, resolve } from "node:path"
import { createBoringAppViteAliases } from "@hachej/boring-core/app/vite"
import { AGENT_API_PORT, VITE_PORT, startPlaygroundServer } from "./src/server/dev"
import { assertReleaseCandidateDistModule } from "./src/release-candidate-dist"

const baseResolve = createBoringAppViteAliases({ appRoot: __dirname })
const repoRoot = resolve(__dirname, "../..")
const releaseCandidateDistOnly = process.env.BORING_PLAYGROUND_DIST_ONLY === "1"

if (releaseCandidateDistOnly) {
console.log("[workspace-playground] release-candidate dist-only package resolution enabled")
}

function releaseCandidateDistOnlyGuard(): Plugin {
return {
name: "boring-release-candidate-dist-only",
enforce: "pre",
async resolveId(source, importer, options) {
if (!source.startsWith("@hachej/boring-")) return null

const resolved = await this.resolve(source, importer, { ...options, skipSelf: true })
if (!resolved || resolved.external) return resolved

assertReleaseCandidateDistModule(resolved.id, `resolved ${source}`)
return resolved
},
load(id) {
assertReleaseCandidateDistModule(id, "load")
return null
},
transform(_code, id) {
assertReleaseCandidateDistModule(id, "transform")
return null
},
}
}
const externalWorkspaceRoot = process.env.BORING_AGENT_WORKSPACE_ROOT?.trim()
const externalRuntimeExtensionsRoot = externalWorkspaceRoot
? resolve(externalWorkspaceRoot, ".pi", "extensions")
Expand Down Expand Up @@ -90,6 +120,7 @@ const pollingInterval = Number(process.env.CHOKIDAR_INTERVAL ?? process.env.BORI

export default defineConfig({
plugins: [
...(releaseCandidateDistOnly ? [releaseCandidateDistOnlyGuard()] : []),
react({
exclude: dynamicPluginReactRefreshExclude,
}),
Expand All @@ -113,7 +144,11 @@ export default defineConfig({
},
],
resolve: {
alias: [...baseResolve.alias, ...playgroundOnlyAliases],
// RC smoke must consume package exports as shipped. Normal playground dev
// keeps its source/HMR aliases unchanged.
alias: releaseCandidateDistOnly
? baseResolve.alias
: [...baseResolve.alias, ...playgroundOnlyAliases],
dedupe: baseResolve.dedupe,
},
server: {
Expand Down
Loading
Loading