diff --git a/.github/workflows/publish-platform.yml b/.github/workflows/publish-platform.yml index 7b001a20778..41dd3c99b62 100644 --- a/.github/workflows/publish-platform.yml +++ b/.github/workflows/publish-platform.yml @@ -66,6 +66,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts + - name: Apply native engine patches + run: node packages/omo-native/bin/senpi-patch.mjs + - name: Validate release inputs id: validate env: @@ -381,6 +384,31 @@ jobs: esac timeout-minutes: 20 + - name: Smoke compiled workers and RPC + if: steps.release-assets.outputs.binary_exists != 'true' + env: + SMOKE_DIR: ${{ runner.temp }}/release-binary-rpc + run: | + set -euo pipefail + TARGET="${{ matrix.platform }}" + case "$TARGET" in + darwin-arm64|linux-x64|linux-x64-baseline|windows-x64|windows-x64-baseline) + trap 'rm -rf "$SMOKE_DIR"' EXIT + BINARY=".omo/release-binaries/omo-${TARGET}" + if [[ "$TARGET" == windows-* ]]; then BINARY="${BINARY}.exe"; fi + bun test script/senpi-worker-compile.test.ts + bun script/qa/dependency-audit-capture.ts --phase post \ + --binary "$BINARY" --out "$SMOKE_DIR" --case rpc --case extension + jq -e '.complete == true and .pass == true and ([.cases[].case] == ["rpc", "extension"]) and all(.cases[]; .pass == true and .exitCode == 0)' "$SMOKE_DIR/summary.json" + ;; + *) + # Non-native legs retain their existing platform smoke policy. + # Windows arm64 remains checksum-only: runtime behavior is unverified. + echo "Compiled RPC smoke requires a native runner for ${TARGET}" + ;; + esac + timeout-minutes: 10 + - name: Upload release binary artifact if: steps.release-assets.outputs.binary_exists != 'true' uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release-binary-smoke.yml b/.github/workflows/release-binary-smoke.yml new file mode 100644 index 00000000000..4c67b4a8dbe --- /dev/null +++ b/.github/workflows/release-binary-smoke.yml @@ -0,0 +1,105 @@ +name: Release binary smoke + +on: + pull_request: + paths: + - script/build-omo-binary.ts + - script/build-omo-binary.test.ts + - script/senpi-worker-compile*.ts + - packages/omo-native/** + - script/qa/dependency-audit-*.ts + - script/qa/dependency-audit/** + - script/qa/fixtures/dependency-audit/** + - script/release-compile-argv.fixture.ts + - script/receipt-gate.fixture.ts + - script/release-binary-smoke-workflow.test.ts + - script/publish-release-platform-workflow.test.ts + - .github/workflows/publish-platform.yml + - package.json + - bun.lock + - .github/workflows/release-binary-smoke.yml + +permissions: + contents: read + +jobs: + smoke: + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + binary: omo-linux-x64 + - os: macos-latest + target: darwin-arm64 + binary: omo-darwin-arm64 + - os: windows-latest + target: windows-x64 + binary: omo-windows-x64.exe + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.2" + - name: Install dependencies + id: install + run: bun install --frozen-lockfile --ignore-scripts + - name: Apply native engine patches + id: patch + run: node packages/omo-native/bin/senpi-patch.mjs + - name: Build release binary + id: build + env: + OUT_DIR: ${{ runner.temp }}/release-binary-smoke + run: | + bun run script/build-omo-binary.ts \ + --target "${{ matrix.target }}" \ + --omo-version 0.0.0-ci \ + --omo-ai-version 0.0.0-ci \ + --out-dir "$OUT_DIR" + - name: Test compile and worker contracts + id: contracts + run: | + # The sidecar parity test reads the source plugin's generated skills. + node packages/omo-senpi/plugin/scripts/sync-skills.mjs + bun test script/build-omo-binary.test.ts script/senpi-worker-compile.test.ts + bun test script/release-binary-smoke-workflow.test.ts script/publish-release-platform-workflow.test.ts script/qa/dependency-audit-capture.test.ts + - name: Capture compiled RPC and extension receipts + id: capture + env: + OUT_DIR: ${{ runner.temp }}/release-binary-smoke + run: | + bun script/qa/dependency-audit-capture.ts --phase post \ + --binary "$OUT_DIR/${{ matrix.binary }}" --out "$OUT_DIR/post" \ + --case bytes --case graph --case rpc --case extension + jq -e '.complete == true and .pass == true and ([.cases[].case] == ["bytes", "graph", "rpc", "extension"]) and all(.cases[]; .pass == true and .exitCode == 0)' "$OUT_DIR/post/summary.json" + - name: Upload JSON receipts only + id: receipts + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-binary-smoke-${{ matrix.target }} + path: ${{ runner.temp }}/release-binary-smoke/post/*.json + if-no-files-found: error + retention-days: 7 + - name: Write job summary + id: summary + if: always() + env: + JOB_SUMMARY_TITLE: Release binary smoke (${{ matrix.target }}) + JOB_SUMMARY_STATUS: ${{ job.status }} + JOB_SUMMARY_DETAILS: | + - Builds the native release target with splitting and name-preserving minification. + - Tests relocated workers and compiled RPC/extension behavior. + - Uploads JSON receipts only; never publishes a binary. + JOB_SUMMARY_NEXT: Inspect the first failed build, contract, or receipt gate for this target. + run: GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" bash .github/scripts/write-job-summary.sh diff --git a/script/build-omo-binary.test.ts b/script/build-omo-binary.test.ts index cffc153262c..7cbcb4897cd 100644 --- a/script/build-omo-binary.test.ts +++ b/script/build-omo-binary.test.ts @@ -9,13 +9,16 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, rmSync, statSync, + truncateSync, writeFileSync, } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" +import { z } from "zod" import { assertBinarySizeBudget, assertEngineGraphBundled, @@ -242,6 +245,29 @@ describe("runtime manifest", () => { }) describe("size budget", () => { + test.each([ + ["darwin-arm64", 104_857_600, true], + ["darwin-arm64", 104_857_601, false], + ["linux-x64", 104_857_601, true], + ["windows-x64", 157_286_400, true], + ["windows-x64", 157_286_401, false], + ] as const)("#given %s at %d bytes #when its target budget is enforced #then accepted is %s", (target, size, accepted) => { + // given + const root = makeTempDir("omo-size-boundary-") + const binary = join(root, "binary") + try { + writeFileSync(binary, "") + truncateSync(binary, size) + // when + const enforce = (): void => assertBinarySizeBudget(target, binary) + // then + if (accepted) expect(enforce).not.toThrow() + else expect(enforce).toThrow(new RegExp(target)) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + test("#given a synthetic oversize binary #when the budget is enforced #then it fails loud naming the target", () => { // given const stageDir = makeTempDir("omo-size-") @@ -387,6 +413,45 @@ describe("plugin staging isolation guard", () => { }) describe("engine graph bundling", () => { + test("#given a release build #when it reaches the compiler #then flags and both ordered entries satisfy the contract", () => { + // given: intercept only the target compiler; staging and the asset probe run normally. + const root = makeTempDir("omo-compile-argv-") + const capture = join(root, "argv.json") + try { + // when + const result = spawnSync(process.execPath, [join(scriptDir, "release-compile-argv.fixture.ts"), capture, root], { + cwd: repoRoot, encoding: "utf8", timeout: 120_000, + }) + // then: independent flags may move; only entry order determines the executable's main. + expect(result.status, result.stderr).toBe(0) + const { command, args } = z.object({ command: z.string(), args: z.array(z.string()) }).parse(JSON.parse(readFileSync(capture, "utf8"))) + expect(command).toBe("bun") + expect(args[0]).toBe("build") + for (const flag of ["--compile", "--target=bun-linux-x64", "--splitting", "--minify", "--keep-names", "--compile-autoload-package-json", "--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig"]) { + expect(args).toContain(flag) + } + expect(args).not.toContain("--minify-whitespace") + const main = args.indexOf(join(repoRoot, "packages/omo-native/compile-entry.ts")) + const worker = args.indexOf(realpathSync(join(repoRoot, "node_modules/@code-yeongyu/senpi/dist/modes/rpc/session-worker.js"))) + expect(main).toBeGreaterThanOrEqual(0) + expect(worker).toBeGreaterThanOrEqual(0) + expect(main).toBeLessThan(worker) + expect(args).toContain(`--root=${repoRoot}`) + expect(args.some((arg) => arg.startsWith("--define=SENPI_RPC_SESSION_WORKER_ENTRY="))).toBe(true) + expect(args.some((arg) => arg.startsWith("--asset="))).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 150_000) + + test("#given recorded splitting output #when parsed #then the observed 4476 modules are extracted", () => { + // given: recorded two-entry splitting probe; not a production count pin. + const output = "\n [300ms] bundle 4476 modules\n\n [132ms] compile /tmp/x\n" + // when / then + expect(parseBundledModuleCount(output)).toBe(4476) + expect(assertEngineGraphBundled(output)).toBe(4476) + }) + test("#given the compiled OMO entry #when its engine imports are inspected #then both retain the standard patched engine literal", () => { // given const compileEntrySource = readFileSync( diff --git a/script/build-omo-binary.ts b/script/build-omo-binary.ts index de39e2c10a2..37c46a53bfa 100644 --- a/script/build-omo-binary.ts +++ b/script/build-omo-binary.ts @@ -38,8 +38,10 @@ const compileEntry = join(repoRoot, "packages", "omo-native", "compile-entry.ts" export const EMBEDDED_PAYLOAD_ROOT = "omo-runtime" /** Relative path of the embedded runtime manifest inside the payload root. */ export const RUNTIME_MANIFEST_REL_PATH = "runtime-manifest.json" -/** Hard per-binary size budget (150MB). */ +/** Hard per-binary size budget (150 MiB). */ export const MAX_BINARY_BYTES = 150 * 1024 * 1024 +/** P0 release budget for the measured darwin-arm64 target (100 MiB). */ +export const P0_MAX_BINARY_BYTES = 104_857_600 export interface ReleaseBinaryTarget { /** Release asset platform slug, e.g. `darwin-arm64`. */ @@ -204,7 +206,7 @@ export function assertBinarySizeBudget( binaryPath: string, options: { readonly maxBytes?: number } = {}, ): void { - const maxBytes = options.maxBytes ?? MAX_BINARY_BYTES + const maxBytes = options.maxBytes ?? (target === "darwin-arm64" ? P0_MAX_BINARY_BYTES : MAX_BINARY_BYTES) const size = statSync(binaryPath).size if (size > maxBytes) { throw new Error( @@ -625,7 +627,7 @@ export async function buildReleaseBinary( ) } - // Flags mirror senpi's own scripts.build:binary (node_modules/@code-yeongyu/senpi/package.json). + // Split the shared engine graph while preserving runtime function/class names. // A binary that fails post-compile verification must not survive on disk. let compileOutput: string try { @@ -634,8 +636,10 @@ export async function buildReleaseBinary( [ "build", "--compile", + "--splitting", `--target=${target.bunTarget}`, - "--minify-whitespace", + "--minify", + "--keep-names", "--compile-autoload-package-json", "--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", diff --git a/script/ci-job-summary-workflow.test.ts b/script/ci-job-summary-workflow.test.ts index f1215d55101..693deb8c329 100644 --- a/script/ci-job-summary-workflow.test.ts +++ b/script/ci-job-summary-workflow.test.ts @@ -4,63 +4,15 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "n import { tmpdir } from "node:os" import { join } from "node:path" -type WorkflowExpectation = { - readonly path: string - readonly jobs: readonly string[] -} +import { load } from "js-yaml" +import { z } from "zod" +import { readWorkflowSteps, workflowStepSchema } from "./receipt-gate.fixture" const workflowDirectory = ".github/workflows" const WINDOWS_INTEGRATION_TEST_TIMEOUT = process.platform === "win32" ? 20_000 : 5_000 - -const workflowExpectations = [ - { - path: ".github/workflows/ci.yml", - jobs: [ - "ci-mode", - "block-master-pr", - "test", - "typecheck", - "codex-compatibility", - "senpi-compatibility", - "lazycodex-published-smoke", - "build", - "omo-ai-payload-check", - "auto-commit-schema", - "draft-release", - ], - }, - { path: ".github/workflows/cla.yml", jobs: ["cla"] }, - { path: ".github/workflows/compiled-worker.yml", jobs: ["relocated-worker"] }, - { path: ".github/workflows/bot-merge.yml", jobs: ["merge"] }, - { path: ".github/workflows/lint-workflows.yml", jobs: ["actionlint"] }, - { path: ".github/workflows/npm-dist-tag-rollback.yml", jobs: ["retag"] }, - { path: ".github/workflows/package-labels.yml", jobs: ["ensure-labels", "label-pull-request", "label-issue"] }, - { path: ".github/workflows/publish-platform.yml", jobs: ["build", "publish", "smoke-linux-arm64"] }, - { - path: ".github/workflows/publish.yml", - jobs: [ - "gate-reuse", - "preflight-trust", - "release-metadata", - "prepare-release-state", - "dispatch-provenance-safe-publish", - "publish-main", - "verify-release-notes", - "release", - "post-publish-verify", - ], - }, - { - path: ".github/workflows/review-claims.yml", - jobs: ["gate", "claim", "release-claim", "stale-sweep"], - }, - { path: ".github/workflows/refresh-model-capabilities.yml", jobs: ["refresh"] }, - { path: ".github/workflows/sisyphus-agent.yml", jobs: ["agent"] }, - { path: ".github/workflows/stats.yml", jobs: ["stats"] }, - { path: ".github/workflows/web-ci.yml", jobs: ["format-lint-typecheck-build"] }, - { path: ".github/workflows/web-deploy.yml", jobs: ["deploy"] }, - { path: ".github/workflows/windows-flake-soak.yml", jobs: ["soak"] }, -] as const satisfies readonly WorkflowExpectation[] +const summaryWorkflowSchema = z.object({ + jobs: z.record(z.string(), z.object({ steps: z.array(workflowStepSchema).optional() })), +}) function discoverWorkflowPaths(): readonly string[] { return readdirSync(workflowDirectory) @@ -69,59 +21,9 @@ function discoverWorkflowPaths(): readonly string[] { .sort() } -function discoverStepBasedJobs(workflow: string): readonly string[] { - const jobsStart = workflow.match(/^jobs:\s*$/m) - if (jobsStart?.index === undefined) return [] - - const jobs: string[] = [] - const lines = workflow.slice(jobsStart.index + jobsStart[0].length).split("\n") - let currentJob: string | undefined - let currentJobHasSteps = false - - function flushCurrentJob(): void { - if (currentJob !== undefined && currentJobHasSteps) jobs.push(currentJob) - } - - for (const line of lines) { - const jobMatch = line.match(/^ ([A-Za-z0-9_-]+):\s*$/) - if (jobMatch !== null) { - flushCurrentJob() - - const nextJob = jobMatch[1] - if (nextJob === undefined) throw new Error(`Unable to read job name from line: ${line}`) - currentJob = nextJob - currentJobHasSteps = false - continue - } - - if (currentJob !== undefined && /^ steps:\s*$/.test(line)) currentJobHasSteps = true - } - - flushCurrentJob() - return jobs -} - -function sliceJob(workflow: string, jobName: string): string { - const marker = ` ${jobName}:` - const start = workflow.indexOf(marker) - if (start < 0) throw new Error(`missing job ${jobName}`) - - const afterMarker = start + marker.length - const nextJob = workflow.slice(afterMarker).match(/\n [A-Za-z0-9_-]+:\n/) - if (nextJob?.index === undefined) return workflow.slice(start) - - return workflow.slice(start, afterMarker + nextJob.index) -} - -function sliceWorkflowSectionToEnd(workflow: string, startMarker: string): string { - const start = workflow.indexOf(startMarker) - if (start < 0) throw new Error(`missing workflow section starting at ${startMarker}`) - - return workflow.slice(start) -} - -function hasSummaryWriter(jobSection: string): boolean { - return jobSection.includes("name: Write job summary") && jobSection.includes("GITHUB_STEP_SUMMARY") +function discoverStepBasedJobs(workflow: string) { + const parsed = summaryWorkflowSchema.parse(load(workflow)) + return Object.entries(parsed.jobs).flatMap(([name, job]) => job.steps === undefined ? [] : [{ name, steps: job.steps }]) } describe("GitHub workflow job summaries", () => { @@ -144,34 +46,32 @@ describe("GitHub workflow job summaries", () => { "", ].join("\n") - expect(discoverStepBasedJobs(workflow)).toEqual(["existing", "newly-added"]) + expect(discoverStepBasedJobs(workflow).map((job) => job.name)).toEqual(["existing", "newly-added"]) }) test("#given repository workflows #when inspected #then every step-based job writes a concise Markdown summary", () => { - const expectedWorkflowPaths = workflowExpectations.map((expectation) => expectation.path).sort() - expect(discoverWorkflowPaths()).toEqual(expectedWorkflowPaths) - - for (const expectation of workflowExpectations) { - const workflow = readFileSync(expectation.path, "utf8") - expect(discoverStepBasedJobs(workflow), `${expectation.path} step-based job list must stay covered`).toEqual( - expectation.jobs, - ) - - for (const job of expectation.jobs) { - const jobSection = sliceJob(workflow, job) - - expect(hasSummaryWriter(jobSection), `${expectation.path} ${job} must write a job summary`).toBe(true) + // given: discover real jobs, so an unrelated new workflow needs no mirrored inventory. + const paths = discoverWorkflowPaths() + expect(paths.length).toBeGreaterThan(0) + for (const path of paths) { + // when + const jobs = discoverStepBasedJobs(readFileSync(path, "utf8")) + // then + for (const job of jobs) { + const summary = job.steps.find((step) => step.run?.includes("GITHUB_STEP_SUMMARY")) + expect(summary, `${path} ${job.name} must write a job summary`).toBeDefined() + expect(summary?.if, `${path} ${job.name} must summarize failures too`).toBe("always()") } } }) test("#given a privileged publish summary #when it renders dispatch inputs #then raw inputs are passed through env", () => { - const workflow = readFileSync(".github/workflows/publish-platform.yml", "utf8") - const summaryStep = sliceWorkflowSectionToEnd(workflow, " - name: Write job summary") - - expect(summaryStep).toContain("JOB_SUMMARY_DIST_TAG: ${{ inputs.dist_tag || 'latest' }}") - expect(summaryStep).toContain("\\`$JOB_SUMMARY_DIST_TAG\\`") - expect(summaryStep).not.toContain("`${{ inputs.dist_tag || 'latest' }}`") + // given / when + const summary = readWorkflowSteps("publish-platform.yml", "publish").find((step) => step.run?.includes("GITHUB_STEP_SUMMARY")) + // then: raw dispatch input must not become executable shell source. + expect(summary?.env?.JOB_SUMMARY_DIST_TAG).toBe("${{ inputs.dist_tag || 'latest' }}") + expect(summary?.run).toContain("$JOB_SUMMARY_DIST_TAG") + expect(summary?.run).not.toContain("${{ inputs.dist_tag") }) test("#given summary inputs #when the shared writer runs #then it emits the Markdown contract GitHub renders", () => { diff --git a/script/publish-release-platform-workflow.test.ts b/script/publish-release-platform-workflow.test.ts index e7577aefacc..59176008b0b 100644 --- a/script/publish-release-platform-workflow.test.ts +++ b/script/publish-release-platform-workflow.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test" import { readFileSync, readdirSync } from "node:fs" import { PLATFORMS } from "./build-binaries" +import { readWorkflowSteps, receiptGateScenarios, runReceiptGate } from "./receipt-gate.fixture" const publishWorkflowPath = new URL("../.github/workflows/publish.yml", import.meta.url) const publishPlatformWorkflowPath = new URL("../.github/workflows/publish-platform.yml", import.meta.url) @@ -242,6 +243,42 @@ describe("release and platform publish workflows", () => { }) describe("release binary asset lane in the platform publish workflow", () => { + test("requires the receipt gate before uploading a newly built release binary", () => { + // given + const steps = readWorkflowSteps("publish-platform.yml", "build") + // when + const build = steps.findIndex((step) => step.run?.includes("script/build-omo-binary.ts")) + const gate = steps.findIndex((step) => step.run?.includes("script/qa/dependency-audit-capture.ts")) + const upload = steps.findIndex((step) => step.uses?.startsWith("actions/upload-artifact@") && step.with?.name === "release-binary-${{ matrix.platform }}") + // then: only dependencies and scheduling policy are fixed, not step labels. + expect(build).toBeGreaterThanOrEqual(0) + expect(gate).toBeGreaterThan(build) + expect(upload).toBeGreaterThan(gate) + expect(steps[gate]?.if).toBe("steps.release-assets.outputs.binary_exists != 'true'") + expect(steps[upload]?.if).toBe(steps[gate]?.if) + expect(steps[gate]?.["continue-on-error"]).not.toBe(true) + }) + + describe.each(["darwin-arm64", "linux-x64", "linux-x64-baseline", "windows-x64", "windows-x64-baseline"])("native receipt gate for %s", (target) => { + test.each([ + ...receiptGateScenarios(["rpc", "extension"]), + ...receiptGateScenarios(["rpc", "extension"]).filter((scenario) => scenario.accepted).map((scenario) => ({ ...scenario, name: "failed worker test with passing receipts", workerExit: 17, accepted: false })), + ])("propagates failure when given $name", (scenario) => { + // given + const gate = readWorkflowSteps("publish-platform.yml", "build").find((step) => step.run?.includes("script/qa/dependency-audit-capture.ts")) + if (gate?.run === undefined) throw new Error("missing release receipt gate") + // when + const result = runReceiptGate(gate.run, scenario, target) + // then + expect(result.status === 0, result.stderr).toBe(scenario.accepted) + expect(result.reached).toBe(scenario.accepted) + if (scenario.accepted) { + expect(result.captureArgs.filter((_, index, args) => args[index - 1] === "--case")).toEqual(["rpc", "extension"]) + expect(result.captureArgs).toContain(`.omo/release-binaries/omo-${target}${target.startsWith("windows-") ? ".exe" : ""}`) + } + }, 25_000) + }) + test("plumbs omo_ai_version into the release-binary build", () => { // #given const workflow = readFileSync(publishPlatformWorkflowPath, "utf8") diff --git a/script/qa/dependency-audit-capture.test.ts b/script/qa/dependency-audit-capture.test.ts index 0115871cdf2..662a97a3740 100644 --- a/script/qa/dependency-audit-capture.test.ts +++ b/script/qa/dependency-audit-capture.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { join } from "node:path" import * as comparison from "./dependency-audit/comparison" import "./dependency-audit/compare-cli.cases" import { @@ -7,6 +8,20 @@ import { } from "./dependency-audit/contracts" describe("dependency audit parsers", () => { + test("retains every selected probe when --case is repeated", () => { + // given + const args = ["--phase", "post", "--binary", "/tmp/audit/binary", "--out", "/tmp/audit/post", "--case", "bytes", "--case", "graph", "--case", "rpc", "--case", "extension"] + // when + const options = parseCaptureArgs(args) + // then + expect(options.case).toEqual(["bytes", "graph", "rpc", "extension"]) + }) + test("rejects an invalid earlier probe when a valid --case follows it", () => { + // given + const args = ["--phase", "post", "--binary", "/tmp/audit/binary", "--out", "/tmp/audit/post", "--case", "../summary", "--case", "rpc"] + // when / then + expect(() => parseCaptureArgs(args)).toThrow() + }) test.each(["Bundled 4476 modules in 300ms", "[100ms] bundle 3995 modules"])("reads module counts when Bun emits %s", (output) => { // given const expected = output.includes("4476") ? 4476 : 3995 @@ -25,7 +40,7 @@ describe("dependency audit parsers", () => { // when const result = graphArguments(release, "/graph") // then - expect(result).toEqual(["build", "--target=bun", "--minify-whitespace", "entry.ts", "worker.ts", "--outdir", "/graph", "--metafile=/graph/meta.json"]) + expect(result).toEqual(["build", "--target=bun", "--minify-whitespace", "entry.ts", "worker.ts", "--outdir", "/graph", `--metafile=${join("/graph", "meta.json")}`]) }) test("rejects malformed CLI input when a case can escape the output directory", () => { // given diff --git a/script/qa/dependency-audit-capture.ts b/script/qa/dependency-audit-capture.ts index 56212318be0..06661fdc5d6 100644 --- a/script/qa/dependency-audit-capture.ts +++ b/script/qa/dependency-audit-capture.ts @@ -18,8 +18,9 @@ const captures: Readonly Promise> } export async function capture(args: readonly string[]): Promise { const options = parseCaptureArgs(args) + const selected = options.case ?? CASES const results = [] - for (const name of options.case === undefined ? CASES : [options.case]) { + for (const name of selected) { const runtime = await createRuntime(options) const timestamp = new Date().toISOString() let observables: object @@ -43,7 +44,7 @@ export async function capture(args: readonly string[]): Promise { await Bun.write(join(options.out, "summary.json"), `${JSON.stringify({ command: ["bun", "script/qa/dependency-audit-capture.ts", ...args], machine: results[0]?.machine, versions: results[0]?.versions, exitCode: 0, timestamp: new Date().toISOString(), schemaVersion: 1, phase: options.phase, - complete: results.length === (options.case === undefined ? CASES.length : 1), + complete: results.length === selected.length, pass: results.every((result) => result.pass === true), cases: results, cleanup: results.map((result) => ({ case: result.case, receipt: result.cleanup })), }, null, 2)}\n`) diff --git a/script/qa/dependency-audit/README.md b/script/qa/dependency-audit/README.md index cc13ac121cb..e99201f0e64 100644 --- a/script/qa/dependency-audit/README.md +++ b/script/qa/dependency-audit/README.md @@ -10,7 +10,7 @@ bun test script/qa/dependency-audit-capture.test.ts bunx --no-install tsgo --noEmit -p script/qa/tsconfig.json ``` -`--case` accepts `bytes`, `graph`, `startup`, `rpc`, `extension`, `webfetch`, `photon`, `changelog`, `providers`, `skills`, or `bytes-targets`. Each is independently runnable. Capture exits zero when recording completes, including recorded baseline failures; it does not mean every probe passed. Check each row's `pass`/`exitCode`, or use compare, which exits nonzero for a failed gate or malformed/incomplete receipts. A partial capture cannot satisfy a full compare. +`--case` accepts `bytes`, `graph`, `startup`, `rpc`, `extension`, `webfetch`, `photon`, `changelog`, `providers`, `skills`, or `bytes-targets`. Each is independently runnable; repeat the flag to select several probes, for example `--case rpc --case extension`. The summary records every selected case in argument order. Capture exits zero when recording completes, including recorded baseline failures; it does not mean every probe passed. Check each row's `pass`/`exitCode`, or use compare, which exits nonzero for a failed gate or malformed/incomplete receipts. A partial capture cannot satisfy a full compare. Each case copies and hashes the supplied binary before launch, provisions once into a fresh HOME, uses isolated USERPROFILE/XDG/agent directories, and removes its sandbox in `finally`. The extension and its helper/PNG are copied outside the checkout so installed packages cannot accidentally satisfy compiled-loader imports. Process environments are allowlisted; only fake credentials enter the audited process. Offline mode and telemetry opt-outs are set. Background memory automation is disabled in the isolated config, while the shipped plugin remains loaded for the startup benchmark (no `--no-extensions`). Fixtures bind only loopback ephemeral ports. Public-network access is limited to the explicitly requested GitHub release-asset acquisition; those downloads are never executed. diff --git a/script/qa/dependency-audit/contracts.ts b/script/qa/dependency-audit/contracts.ts index b40563f0456..53eeeab5846 100644 --- a/script/qa/dependency-audit/contracts.ts +++ b/script/qa/dependency-audit/contracts.ts @@ -6,7 +6,7 @@ export const CASES = ["bytes", "graph", "startup", "rpc", "extension", "webfetch export type CaseName = typeof CASES[number] export const captureSchema = z.object({ phase: z.enum(["baseline", "post"]), binary: z.string().min(1), out: z.string().min(1), - case: z.enum(CASES).optional(), + case: z.array(z.enum(CASES)).readonly().optional(), }) export type CaptureOptions = z.infer export class AuditError extends Error { @@ -15,7 +15,7 @@ export class AuditError extends Error { } export function parseCaptureArgs(args: readonly string[]): CaptureOptions { return captureSchema.parse(parseArgs({ args: [...args], options: { - phase: { type: "string" }, binary: { type: "string" }, out: { type: "string" }, case: { type: "string" }, + phase: { type: "string" }, binary: { type: "string" }, out: { type: "string" }, case: { type: "string", multiple: true }, }, strict: true, allowPositionals: false }).values) } export function parseModuleCount(output: string): number { diff --git a/script/receipt-gate.fixture.ts b/script/receipt-gate.fixture.ts new file mode 100644 index 00000000000..127e4db6363 --- /dev/null +++ b/script/receipt-gate.fixture.ts @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { load } from "js-yaml" +import { z } from "zod" + +export const workflowStepSchema = z.object({ + id: z.string().optional(), uses: z.string().optional(), run: z.string().optional(), + if: z.string().optional(), "continue-on-error": z.boolean().optional(), + env: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]).transform(String)).optional(), + with: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(), +}) + +export function readWorkflowSteps(filename: string, job: string) { + const workflow = z.object({ jobs: z.record(z.string(), z.object({ steps: z.array(workflowStepSchema) })) }) + .parse(load(readFileSync(new URL(`../.github/workflows/${filename}`, import.meta.url), "utf8"))) + const selected = workflow.jobs[job] + if (selected === undefined) throw new Error(`missing workflow job ${job}`) + return selected.steps +} + +type GateScenario = { + readonly name: string + readonly receipt?: string + readonly workerExit?: number + readonly captureExit?: number + readonly accepted: boolean +} + +export function receiptGateScenarios(names: readonly string[]): readonly GateScenario[] { + const cases = names.map((name) => ({ case: name, pass: true, exitCode: 0 })) + const passing = { complete: true, pass: true, cases } + return [ + { name: "passing receipts", receipt: JSON.stringify(passing), accepted: true }, + { name: "missing receipt", accepted: false }, + { name: "malformed receipt", receipt: "{", accepted: false }, + { name: "incomplete capture", receipt: JSON.stringify({ ...passing, complete: false }), accepted: false }, + { name: "failed summary", receipt: JSON.stringify({ ...passing, pass: false }), accepted: false }, + { name: "failed smoke leg despite passing summary", receipt: JSON.stringify({ ...passing, cases: cases.map((row, index) => index === 0 ? { ...row, pass: false } : row) }), accepted: false }, + { name: "nonzero smoke exit despite passing summary", receipt: JSON.stringify({ ...passing, cases: cases.map((row, index) => index === 0 ? { ...row, exitCode: 1 } : row) }), accepted: false }, + { name: "missing selected case", receipt: JSON.stringify({ ...passing, cases: cases.slice(1) }), accepted: false }, + { name: "duplicate selected cases", receipt: JSON.stringify({ ...passing, cases: cases.concat(cases) }), accepted: false }, + { name: "failed capture with stale passing receipts", receipt: JSON.stringify(passing), captureExit: 23, accepted: false }, + ] +} + +export function runReceiptGate(command: string, scenario: GateScenario, target = "darwin-arm64") { + const root = mkdtempSync(join(tmpdir(), "omo-receipt-gate-")) + try { + if (scenario.receipt !== undefined) writeFileSync(join(root, "fixture.json"), scenario.receipt) + // Only expensive Bun probes are substituted. Bash, jq, the workflow's entire + // run block, and errexit/pipefail are real, including any accidental || true. + const script = ` + bun() { + if [[ "$1" == test ]]; then return "$WORKER_EXIT"; fi + if [[ "$1" != script/qa/dependency-audit-capture.ts ]]; then return 64; fi + printf '%s\\n' "$@" > "$CAPTURE_ARGS" + local out="" + while [[ "$#" -gt 0 ]]; do + if [[ "$1" == --out ]]; then out="$2"; shift; fi + shift + done + mkdir -p "$out" + if [[ -f "$FIXTURE" ]]; then cp "$FIXTURE" "$out/summary.json"; fi + return "$CAPTURE_EXIT" + } + ${command.replaceAll("${{ matrix.platform }}", target).replaceAll("${{ matrix.binary }}", `omo-${target}${target.startsWith("windows-") ? ".exe" : ""}`)} + touch "$REACHED" + ` + const path = root.replaceAll("\\", "/") + const result = spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], { + cwd: root, encoding: "utf8", timeout: 20_000, + env: { + ...process.env, OUT_DIR: `${path}/out`, SMOKE_DIR: `${path}/smoke`, + FIXTURE: `${path}/fixture.json`, CAPTURE_ARGS: `${path}/args`, REACHED: `${path}/reached`, + WORKER_EXIT: String(scenario.workerExit ?? 0), CAPTURE_EXIT: String(scenario.captureExit ?? 0), + }, + }) + if (result.error !== undefined) throw result.error + return { + status: result.status, stderr: result.stderr, reached: existsSync(join(root, "reached")), + captureArgs: existsSync(join(root, "args")) ? readFileSync(join(root, "args"), "utf8").trim().split("\n") : [], + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} diff --git a/script/release-binary-smoke-workflow.test.ts b/script/release-binary-smoke-workflow.test.ts new file mode 100644 index 00000000000..a093d6242d1 --- /dev/null +++ b/script/release-binary-smoke-workflow.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { load } from "js-yaml" +import { z } from "zod" +import { receiptGateScenarios, runReceiptGate, workflowStepSchema } from "./receipt-gate.fixture" + +const workflowSchema = z.object({ + on: z.record(z.string(), z.object({ paths: z.array(z.string()) })), + permissions: z.record(z.string(), z.string()), + jobs: z.object({ smoke: z.object({ + "runs-on": z.string(), + strategy: z.object({ "fail-fast": z.boolean(), matrix: z.object({ include: z.array(z.object({ os: z.string(), target: z.string(), binary: z.string() })) }) }), + steps: z.array(workflowStepSchema), + }) }), +}) +function readWorkflow() { + return workflowSchema.parse(load(readFileSync(new URL("../.github/workflows/release-binary-smoke.yml", import.meta.url), "utf8"))) +} + +const nativeTargets = [ + { os: "ubuntu-latest", target: "linux-x64", binary: "omo-linux-x64" }, + { os: "macos-latest", target: "darwin-arm64", binary: "omo-darwin-arm64" }, + { os: "windows-latest", target: "windows-x64", binary: "omo-windows-x64.exe" }, +] as const + +describe("release binary PR smoke workflow", () => { + test.each([ + "script/build-omo-binary.ts", "script/build-omo-binary.test.ts", + "script/senpi-worker-compile.ts", "script/senpi-worker-compile.test.ts", + "script/release-compile-argv.fixture.ts", "script/receipt-gate.fixture.ts", + "script/release-binary-smoke-workflow.test.ts", "script/publish-release-platform-workflow.test.ts", + "packages/omo-native/compile-entry.ts", "script/qa/dependency-audit-capture.ts", + "script/qa/dependency-audit/contracts.ts", "script/qa/dependency-audit/runtime.ts", + "script/qa/dependency-audit/session-cases.ts", "script/qa/fixtures/dependency-audit/extension.ts", + "script/qa/fixtures/dependency-audit/baseline-76e54b0-806f8e0/summary.json", + "package.json", "bun.lock", ".github/workflows/release-binary-smoke.yml", + ".github/workflows/publish-platform.yml", + ])("schedules native smoke when %s changes", (changedPath) => { + // given + const paths = readWorkflow().on.pull_request?.paths ?? [] + // when + const scheduled = paths.some((pattern) => new Bun.Glob(pattern).match(changedPath)) + // then: path filters are an unordered OR, not a YAML snapshot. + expect(scheduled).toBe(true) + }) + + test("runs read-only PR checks when a release input changes", () => { + // given / when + const workflow = readWorkflow() + // then + expect(Object.keys(workflow.on)).toEqual(["pull_request"]) + expect(Object.values(workflow.permissions).every((value) => value !== "write")).toBe(true) + }) + + test.each([...nativeTargets])("uses a native executable when scheduling $target", (native) => { + // given / when + const job = readWorkflow().jobs.smoke + // then + expect(job["runs-on"]).toBe("${{ matrix.os }}") + expect(job.strategy["fail-fast"]).toBe(false) + expect(job.strategy.matrix.include).toContainEqual(native) + }) + + test.each([ + ["install", "patch"], ["patch", "build"], ["build", "contracts"], + ["contracts", "capture"], ["capture", "receipts"], + ])("requires %s before %s when executing smoke", (before, after) => { + // given + const steps = readWorkflow().jobs.smoke.steps + // when + const first = steps.findIndex((step) => step.id === before) + const second = steps.findIndex((step) => step.id === after) + // then: unrelated diagnostic steps may appear anywhere. + expect(first).toBeGreaterThanOrEqual(0) + expect(second).toBeGreaterThan(first) + expect(steps.every((step) => step["continue-on-error"] !== true)).toBe(true) + }) + + test("writes a job summary even when a smoke step fails", () => { + // given / when + const summary = readWorkflow().jobs.smoke.steps.find((step) => step.id === "summary") + // then + expect(summary?.if).toBe("always()") + expect(summary?.env?.JOB_SUMMARY_STATUS).toBe("${{ job.status }}") + expect(summary?.run).toContain(".github/scripts/write-job-summary.sh") + }) + + test.each([...receiptGateScenarios(["bytes", "graph", "rpc", "extension"])])("propagates the gate result when given $name", (scenario) => { + // given + const capture = readWorkflow().jobs.smoke.steps.find((step) => step.id === "capture") + if (capture?.run === undefined) throw new Error("missing capture gate") + // when + const result = runReceiptGate(capture.run, scenario) + // then: assert the real shell exit and whether a downstream step could run. + expect(result.status === 0, result.stderr).toBe(scenario.accepted) + expect(result.reached).toBe(scenario.accepted) + if (scenario.accepted) { + expect(result.captureArgs.filter((_, index, args) => args[index - 1] === "--case")).toEqual(["bytes", "graph", "rpc", "extension"]) + } + }, 25_000) + + test("uploads only JSON receipts when collecting CI evidence", () => { + // given / when + const steps = readWorkflow().jobs.smoke.steps + const uploads = steps.filter((step) => step.uses?.startsWith("actions/upload-artifact@")) + // then + expect(uploads.length).toBeGreaterThan(0) + for (const upload of uploads) { + const paths = z.string().parse(upload.with?.path).trim().split("\n") + expect(paths.every((path) => path.endsWith(".json"))).toBe(true) + expect(upload.with?.["if-no-files-found"]).toBe("error") + } + expect(steps.some((step) => /(?:npm|bun) publish|gh release|gh workflow run/.test(step.run ?? ""))).toBe(false) + }) +}) diff --git a/script/release-compile-argv.fixture.ts b/script/release-compile-argv.fixture.ts new file mode 100644 index 00000000000..954efbe38cc --- /dev/null +++ b/script/release-compile-argv.fixture.ts @@ -0,0 +1,29 @@ +import { mock } from "bun:test" +import * as childProcess from "node:child_process" +import { writeFileSync } from "node:fs" + +// A separate process keeps the command interception out of other Bun tests. +class CompileCaptured extends Error {} +const originalSpawn = childProcess.spawnSync +const output = process.argv[2] +const outDir = process.argv[3] +if (output === undefined || outDir === undefined) throw new Error("capture paths are required") +mock.module("node:child_process", () => ({ + ...childProcess, + spawnSync(command: string, args: readonly string[], options: childProcess.SpawnSyncOptions) { + if (args.includes("--compile") && args.some((arg) => arg.startsWith("--target="))) { + writeFileSync(output, JSON.stringify({ command, args })) + throw new CompileCaptured() + } + return originalSpawn(command, args, options) + }, +})) +const { buildReleaseBinary, RELEASE_BINARY_TARGETS } = await import("./build-omo-binary") +const target = RELEASE_BINARY_TARGETS.find((entry) => entry.target === "linux-x64") +if (target === undefined) throw new Error("linux-x64 release target is missing") +try { + await buildReleaseBinary(target, { omoVersion: "0.0.0-test", omoAiVersion: "0.0.0-test", outDir }) + throw new Error("release compile was not captured") +} catch (error) { + if (!(error instanceof CompileCaptured)) throw error +} diff --git a/script/senpi-worker-compile.test.ts b/script/senpi-worker-compile.test.ts index 2ca73446391..cde50e9a6a5 100644 --- a/script/senpi-worker-compile.test.ts +++ b/script/senpi-worker-compile.test.ts @@ -6,55 +6,89 @@ import { tmpdir } from "node:os" import { dirname, join } from "node:path" import { senpiWorkerCompileArgs } from "./senpi-worker-compile" -test.each(["directory", "bun-link", "external-link"])("#given a %s worker engine #when compiled and relocated #then two workers start without source files", (layout) => { +const variants = (["unsplit", "split"] as const).flatMap((mode) => + (["directory", "bun-link", "external-link"] as const).map((layout) => ({ mode, layout })), +) + +test.each(variants)("#given $mode/$layout workers #when compiled and relocated #then two SAB round trips finish with worker exits", ({ mode, layout }) => { const scratch = mkdtempSync(join(tmpdir(), "omo-worker-compile-")) try { // given: mirror the published engine layout and compile-time worker contract. const buildRoot = join(scratch, "build") const root = join(buildRoot, "source") const packagePath = join(root, "node_modules/@code-yeongyu/senpi") - const physicalPackage = layout === "directory" ? packagePath : layout === "bun-link" - ? join(root, "node_modules/.bun/senpi/node_modules/@code-yeongyu/senpi") - : join(buildRoot, "engine") + const physicalPackage = { + directory: packagePath, + "bun-link": join(root, "node_modules/.bun/senpi/node_modules/@code-yeongyu/senpi"), + "external-link": join(buildRoot, "engine"), + }[layout] mkdirSync(join(physicalPackage, "dist/modes/rpc"), { recursive: true }) - if (layout !== "directory") { + if (physicalPackage !== packagePath) { mkdirSync(dirname(packagePath), { recursive: true }) symlinkSync(physicalPackage, packagePath, "junction") } const worker = join(packagePath, "dist/modes/rpc/session-worker.js") - writeFileSync(worker, `import { parentPort } from "node:worker_threads"; parentPort.postMessage("ready");`) + writeFileSync(worker, `import { parentPort } from "node:worker_threads"; +parentPort.once("message", (shared) => { + const view = new Int32Array(shared); + Atomics.add(view, 0, 7); + parentPort.postMessage(Atomics.load(view, 0)); + parentPort.close(); +});`) const entry = join(root, "entry.ts") - writeFileSync(entry, `import { Worker } from "node:worker_threads"; + writeFileSync(entry, `import assert from "node:assert/strict"; +import { once } from "node:events"; +import { Worker } from "node:worker_threads"; import { fileURLToPath } from "node:url"; const path = typeof SENPI_RPC_SESSION_WORKER_ENTRY === "string" ? SENPI_RPC_SESSION_WORKER_ENTRY : "./src/modes/rpc/session-worker.ts"; -await Promise.all([1, 2].map(() => new Promise((resolve, reject) => { +const results = await Promise.all([11, 23].map(async (input) => { + const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT); + const view = new Int32Array(shared); + Atomics.store(view, 0, input); const worker = new Worker(fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\\\", "/")); - worker.once("error", reject); - worker.once("message", async (message) => { await worker.terminate(); resolve(message); }); -}))); -console.log("two-workers-ready");`) + const signal = AbortSignal.timeout(5000); + const reply = once(worker, "message", { signal }); + const exited = once(worker, "exit", { signal }); + try { + worker.postMessage(shared); + const [[message], [exitCode]] = await Promise.all([reply, exited]); + assert.equal(message, input + 7); + assert.equal(Atomics.load(view, 0), input + 7); + assert.equal(exitCode, 0); + return { input, message, shared: Atomics.load(view, 0), exitCode }; + } finally { + await worker.terminate(); + } +})); +console.log(JSON.stringify(results));`) const binary = join(scratch, process.platform === "win32" ? "omo.exe" : "omo") - // when: use the same args as the release builder, then remove the entire source. - const built = spawnSync(process.execPath, ["build", "--compile", entry, ...senpiWorkerCompileArgs(root), "--outfile", binary], { cwd: root, encoding: "utf8", timeout: 30_000 }) + // when: use the release optimization flags in split mode, then remove all source. + const flags = { unsplit: [], split: ["--splitting", "--minify", "--keep-names"] }[mode] + const built = spawnSync(process.execPath, ["build", "--compile", ...flags, entry, ...senpiWorkerCompileArgs(root), "--outfile", binary], { cwd: root, encoding: "utf8", timeout: 30_000 }) expect(built.status, built.stderr).toBe(0) - console.log(JSON.stringify({ layout, bun: Bun.version, revision: Bun.revision, platform: process.platform, arch: process.arch, binarySha256: createHash("sha256").update(readFileSync(binary)).digest("hex") })) + console.log(JSON.stringify({ mode, layout, bun: Bun.version, revision: Bun.revision, platform: process.platform, arch: process.arch, binarySha256: createHash("sha256").update(readFileSync(binary)).digest("hex") })) const relocated = join(scratch, "relocated") mkdirSync(relocated) const moved = join(relocated, process.platform === "win32" ? "omo.exe" : "omo") renameSync(binary, moved) rmSync(buildRoot, { recursive: true }) const result = spawnSync(moved, [], { cwd: relocated, encoding: "utf8", timeout: 10_000 }) - // then + // then: assert machine-consumed values, including the shared memory and natural exit. expect(result.status, result.stderr).toBe(0) - expect(result.stdout.trim()).toBe("two-workers-ready") + expect(JSON.parse(result.stdout)).toEqual([ + { input: 11, message: 18, shared: 18, exitCode: 0 }, + { input: 23, message: 30, shared: 30, exitCode: 0 }, + ]) } finally { rmSync(scratch, { recursive: true, force: true }) } }, 45_000) test("#given a pre-worker engine #when resolving compile args #then the legacy graph stays unchanged", () => { + // given const root = mkdtempSync(join(tmpdir(), "omo-worker-legacy-")) try { + // when / then expect(senpiWorkerCompileArgs(root)).toEqual([]) } finally { rmSync(root, { recursive: true, force: true })