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
96 changes: 88 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,98 @@ jobs:
needs: changes
if: needs.changes.outputs.docs_only != 'true'
runs-on: windows-latest
# Windows unit jobs are advisory and package-scoped. The 20-minute budget
# gives each matrix child room for Windows runner setup and install overhead
# while still surfacing the package that timed out.
# Windows unit jobs are advisory, package-scoped or shard-scoped. The
# 20-minute budget applies per matrix child; opencode has three parallel
# shards, so a full stall can consume up to three Windows runner slots.
timeout-minutes: 20
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- package: app
uses_turbo: true
command: bun turbo test:ci --filter=@opencode-ai/app
report_path: packages/app/.artifacts/unit/junit.xml
- package: opencode
command: bun turbo test:ci --filter=opencode
report_path: packages/opencode/.artifacts/unit/junit.xml
# The opencode-* package values below are synthetic shard names for
# readable advisory checks and matrix keys, not package.json names.
Comment thread
Astro-Han marked this conversation as resolved.
# Direct bun test keeps opencode's test:ci timeout while allowing
# shard-local paths and reporter output. Shards are balanced by
# observed runtime, not directory count.
# Session also carries plugin, permission, util, skill, and root
# tests to keep the Windows shard runtimes close.
Comment thread
Astro-Han marked this conversation as resolved.
- package: opencode-session
Comment thread
Astro-Han marked this conversation as resolved.
uses_turbo: false
command: >-
cd packages/opencode && bun test
--timeout 30000
Comment thread
Astro-Han marked this conversation as resolved.
--reporter=junit
--reporter-outfile=.artifacts/unit/junit-windows-session.xml
Comment thread
Astro-Han marked this conversation as resolved.
test/session
test/plugin
test/permission
test/util
test/skill
test/index-runtime-namespace.test.ts
test/npm.test.ts
test/permission-task.test.ts
report_path: packages/opencode/.artifacts/unit/junit-windows-session.xml
# Config and project cover configuration, project discovery, file,
# and GitHub workflow tests.
- package: opencode-config-project
uses_turbo: false
command: >-
cd packages/opencode && bun test
--timeout 30000
--reporter=junit
--reporter-outfile=.artifacts/unit/junit-windows-config-project.xml
test/config
test/project
test/file
test/github
report_path: packages/opencode/.artifacts/unit/junit-windows-config-project.xml
Comment thread
Astro-Han marked this conversation as resolved.
Comment thread
Astro-Han marked this conversation as resolved.
# Server tools carries many smaller and faster directories, which is
# why it has more entries than the other two opencode shards.
- package: opencode-server-tools
Comment thread
Astro-Han marked this conversation as resolved.
uses_turbo: false
command: >-
cd packages/opencode && bun test
--timeout 30000
--reporter=junit
--reporter-outfile=.artifacts/unit/junit-windows-server-tools.xml
test/server
test/snapshot
test/tool
test/mcp
test/question
test/effect
test/agent
test/git
test/storage
test/provider
test/pty
test/share
test/script
test/memory
test/lsp
test/fixture
test/acp
test/bus
test/cli
test/global
test/format
test/account
test/sync
test/filesystem
test/patch
test/shell
test/control-plane
test/ide
test/installation
test/auth
report_path: packages/opencode/.artifacts/unit/junit-windows-server-tools.xml
- package: desktop
uses_turbo: true
command: bun turbo test:ci --filter=@opencode-ai/desktop-electron
report_path: packages/desktop-electron/.artifacts/unit/junit.xml
permissions:
Expand Down Expand Up @@ -396,17 +472,21 @@ jobs:
bun-${{ runner.os }}-

- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # actions/cache@v5
if: matrix.uses_turbo
with:
path: .turbo/cache
# Keep Turbo caches package-scoped because each matrix child runs a
# different test filter; restore keys reuse only that package's cache.
# Only app and desktop invoke Turbo in this Windows matrix.
key: turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-
turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-

- run: bun install --frozen-lockfile

- name: Prepare unit artifact directory
if: matrix.uses_turbo == false
run: mkdir -p "$(dirname "${{ matrix.report_path }}")"
Comment thread
Astro-Han marked this conversation as resolved.

- name: unit
id: unit
continue-on-error: true
Expand Down
178 changes: 170 additions & 8 deletions packages/opencode/test/github/ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, test } from "bun:test"
import { existsSync, readdirSync, statSync } from "node:fs"
import path from "node:path"
Comment thread
Astro-Han marked this conversation as resolved.
import { parseWorkflow, readWorkflow } from "./workflow-parser"

const repoRoot = path.join(import.meta.dir, "../../../..")
const workflowPath = path.join(repoRoot, ".github", "workflows", "ci.yml")
const opencodeTestRoot = path.join(repoRoot, "packages", "opencode", "test")

const pinned = {
checkout: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd",
Expand All @@ -19,7 +21,7 @@ const windowsUnitJobName = "unit-windows"

// Suffixes drive readable job and artifact names; commands use package.json names verbatim.
// `opencode` is intentionally unscoped because that is its actual package name.
const unitPackages = [
const linuxUnitPackages = [
{
suffix: "app",
command: "bun turbo test:ci --filter=@opencode-ai/app",
Expand All @@ -37,14 +39,57 @@ const unitPackages = [
},
] as const

const linuxUnitJobs = unitPackages.map((pkg) => ({
const windowsOpencodeShards = [
// Intentional dual source with ci.yml: this pins the exact shard command
// contract while the coverage test expands these paths to catch workflow
// drift and missing opencode tests. Update ci.yml and this list together.
{
suffix: "opencode-session",
usesTurbo: false,
command:
Comment thread
Astro-Han marked this conversation as resolved.
"cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-session.xml test/session test/plugin test/permission test/util test/skill test/index-runtime-namespace.test.ts test/npm.test.ts test/permission-task.test.ts",
reportPath: "packages/opencode/.artifacts/unit/junit-windows-session.xml",
},
{
suffix: "opencode-config-project",
usesTurbo: false,
command:
"cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/file test/github",
reportPath: "packages/opencode/.artifacts/unit/junit-windows-config-project.xml",
},
{
suffix: "opencode-server-tools",
usesTurbo: false,
command:
"cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-server-tools.xml test/server test/snapshot test/tool test/mcp test/question test/effect test/agent test/git test/storage test/provider test/pty test/share test/script test/memory test/lsp test/fixture test/acp test/bus test/cli test/global test/format test/account test/sync test/filesystem test/patch test/shell test/control-plane test/ide test/installation test/auth",
reportPath: "packages/opencode/.artifacts/unit/junit-windows-server-tools.xml",
},
] as const

const windowsUnitPackages = [
{
suffix: "app",
usesTurbo: true,
command: "bun turbo test:ci --filter=@opencode-ai/app",
reportPath: "packages/app/.artifacts/unit/junit.xml",
},
...windowsOpencodeShards,
{
Comment thread
Astro-Han marked this conversation as resolved.
suffix: "desktop",
Comment thread
Astro-Han marked this conversation as resolved.
usesTurbo: true,
command: "bun turbo test:ci --filter=@opencode-ai/desktop-electron",
reportPath: "packages/desktop-electron/.artifacts/unit/junit.xml",
},
] as const

const linuxUnitJobs = linuxUnitPackages.map((pkg) => ({
...pkg,
jobName: `unit-${pkg.suffix}`,
checkName: `unit results (${pkg.suffix})`,
artifactName: `unit-${pkg.suffix}-${runAttempt}`,
}))

const windowsUnitJobs = unitPackages.map((pkg) => ({
const windowsUnitJobs = windowsUnitPackages.map((pkg) => ({
...pkg,
jobName: `unit-windows-${pkg.suffix}`,
artifactName: `unit-windows-${pkg.suffix}-${runAttempt}`,
Expand All @@ -63,6 +108,47 @@ function stepByName(job: string, name: string) {
return steps(job).find((step) => step.name === name)
}

function toPosix(relativePath: string) {
Comment thread
Astro-Han marked this conversation as resolved.
return relativePath.split(path.sep).join("/")
}

function isTestFile(filePath: string) {
return /\.(test|spec)\.(ts|tsx|js|mjs|cjs)$/.test(filePath)
}

function listOpencodeTestFiles(dir = opencodeTestRoot): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
Comment thread
Astro-Han marked this conversation as resolved.
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) return listOpencodeTestFiles(fullPath)
if (!entry.isFile() || !isTestFile(entry.name)) return []

Comment thread
Astro-Han marked this conversation as resolved.
return [`test/${toPosix(path.relative(opencodeTestRoot, fullPath))}`]
Comment thread
Astro-Han marked this conversation as resolved.
})
}

function expandOpencodeTestPath(testPath: string): string[] {
const fullPath = path.join(repoRoot, "packages", "opencode", testPath)
if (!existsSync(fullPath)) {
throw new Error(`Windows opencode shard path does not exist: ${testPath}`)
}
if (statSync(fullPath).isDirectory()) {
return listOpencodeTestFiles(fullPath)
Comment thread
Astro-Han marked this conversation as resolved.
}
return [testPath]
}

function testPathArgs(command: string) {
const testArgs = command.split(" bun test ")[1]
if (!testArgs) {
throw new Error(`Windows opencode shard command does not invoke bun test: ${command}`)
}
return testArgs.split(/\s+/).filter((arg) => arg.startsWith("test/"))
}
Comment thread
Astro-Han marked this conversation as resolved.

function isWindowsOpencodeShard(item: Record<string, unknown>): item is { command: string; package: string } {
return item.uses_turbo === false && typeof item.package === "string" && typeof item.command === "string"
}
Comment thread
Astro-Han marked this conversation as resolved.

describe("ci workflow", () => {
test("pins third-party actions and disables checkout credential persistence", () => {
const workflow = readWorkflow(workflowPath)
Expand Down Expand Up @@ -148,10 +234,9 @@ describe("ci workflow", () => {
}
Comment thread
Astro-Han marked this conversation as resolved.
})

Comment thread
Astro-Han marked this conversation as resolved.
test("splits Windows unit signals by package without publishing advisory check runs", () => {
test("keeps Windows unit package and shard signals advisory", () => {
const parsed = parseWorkflow(workflowPath)
const job = parsed.jobs?.[windowsUnitJobName]
const matrixIncludes = job?.strategy?.matrix?.include ?? []

expect(job?.name).toBe("unit-windows-${{ matrix.package }}")
expect(job?.needs).toBe("changes")
Expand All @@ -162,6 +247,10 @@ describe("ci workflow", () => {
expect(job?.strategy?.["fail-fast"]).toBe(false)
expect(job?.permissions).toEqual({ contents: "read" })
expect(job?.defaults?.run?.shell).toBe("bash")
expect(stepByName(windowsUnitJobName, "Prepare unit artifact directory")?.run).toBe(
'mkdir -p "$(dirname "${{ matrix.report_path }}")"',
)
expect(stepByName(windowsUnitJobName, "Prepare unit artifact directory")?.if).toBe("matrix.uses_turbo == false")
expect(stepByName(windowsUnitJobName, "unit")?.id).toBe("unit")
expect(stepByName(windowsUnitJobName, "unit")?.["continue-on-error"]).toBe(true)
expect(stepByName(windowsUnitJobName, "unit")?.run).toContain("${{ matrix.command }}")
Expand All @@ -175,29 +264,99 @@ describe("ci workflow", () => {
)
expect(stepByName(windowsUnitJobName, "Upload unit artifacts")?.with?.path).toBe("${{ matrix.report_path }}")

const turboCacheStep = steps(windowsUnitJobName).filter((step) => step.uses?.startsWith("actions/cache@"))[1]
const turboCacheStep = steps(windowsUnitJobName).find((step) => step.with?.path === ".turbo/cache")
expect(turboCacheStep?.if).toBe("matrix.uses_turbo")
expect(turboCacheStep?.with?.key).toBe(
"turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-${{ github.sha }}",
)
expect(turboCacheStep?.with?.["restore-keys"]).toBe(
"turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-\n" +
"turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-\n",
)
})

test("defines Windows unit packages and opencode shards", () => {
const parsed = parseWorkflow(workflowPath)
const job = parsed.jobs?.[windowsUnitJobName]
const matrixIncludes = job?.strategy?.matrix?.include ?? []

expect(matrixIncludes).toEqual(
windowsUnitJobs.map(({ jobName, command, reportPath }) => ({
windowsUnitJobs.map(({ jobName, usesTurbo, command, reportPath }) => ({
package: jobName.replace("unit-windows-", ""),
uses_turbo: usesTurbo,
command,
report_path: reportPath,
})),
)

for (const { jobName, command, reportPath, artifactName } of windowsUnitJobs) {
for (const { jobName, artifactName } of windowsUnitJobs) {
expect(parsed.jobs?.[jobName]).toBeUndefined()
expect(artifactName).toBe(`unit-windows-${jobName.replace("unit-windows-", "")}-${runAttempt}`)
}
})

Comment thread
Astro-Han marked this conversation as resolved.
test("covers each opencode test file exactly once across Windows opencode shards", () => {
const parsed = parseWorkflow(workflowPath)
const matrixIncludes = parsed.jobs?.[windowsUnitJobName]?.strategy?.matrix?.include ?? []
const opencodeShards = matrixIncludes.filter(isWindowsOpencodeShard)
const allTestFiles = listOpencodeTestFiles().sort()
const allTestFilesSet = new Set(allTestFiles)
const coverage = new Map<string, string[]>()
const extra: string[] = []
const shardFileCounts = new Map<string, number>()

// This repeats names instead of deriving from windowsOpencodeShards so the
// coverage test pins the public advisory check names explicitly.
expect(opencodeShards.map((item) => item.package)).toEqual([
Comment thread
Astro-Han marked this conversation as resolved.
"opencode-session",
"opencode-config-project",
"opencode-server-tools",
])

for (const item of opencodeShards) {
Comment thread
Astro-Han marked this conversation as resolved.
Comment thread
Astro-Han marked this conversation as resolved.
const testPaths = testPathArgs(item.command)
let fileCount = 0
for (const testPath of testPaths) {
const expanded = expandOpencodeTestPath(testPath)
fileCount += expanded.length
for (const file of expanded) {
if (!allTestFilesSet.has(file)) {
extra.push(file)
continue
}
coverage.set(file, [...(coverage.get(file) ?? []), item.package])
}
}
shardFileCounts.set(item.package, fileCount)
}

const missing = allTestFiles.filter((file) => !coverage.has(file))
const duplicates = [...coverage.entries()]
.filter(([, shardNames]) => shardNames.length > 1)
.map(([file, shardNames]) => ({ file, shards: shardNames }))

Comment thread
Astro-Han marked this conversation as resolved.
if (missing.length > 0 || extra.length > 0 || duplicates.length > 0) {
const shardNames = opencodeShards.map((item) => item.package)
const smallestShard = [...shardFileCounts.entries()].sort(([, left], [, right]) => left - right)[0]?.[0]
const details = [
"Windows opencode shard coverage drift.",
missing.length > 0 ? `Uncovered files: ${missing.join(", ")}` : undefined,
extra.length > 0 ? `Unknown shard paths: ${extra.sort().join(", ")}` : undefined,
duplicates.length > 0 ? `Duplicate coverage: ${JSON.stringify(duplicates)}` : undefined,
`Shard choices: ${shardNames.join(" | ")}`,
smallestShard ? `Suggested starting shard for uncovered files: ${smallestShard}` : undefined,
].filter((line): line is string => typeof line === "string")

throw new Error(details.join("\n"))
}

expect({ duplicates, extra: extra.sort(), missing }).toEqual({
duplicates: [],
extra: [],
missing: [],
})
Comment thread
Astro-Han marked this conversation as resolved.
})

test("keeps docs-only behavior and excludes Windows from the blocking aggregate", () => {
const parsed = parseWorkflow(workflowPath)
const check = parsed.jobs?.check
Expand All @@ -210,6 +369,9 @@ describe("ci workflow", () => {
expect(needs).not.toContain("unit-windows-app")
expect(needs).not.toContain("unit-windows-desktop")
expect(needs).not.toContain("unit-windows-opencode")
expect(needs).not.toContain("unit-windows-opencode-session")
expect(needs).not.toContain("unit-windows-opencode-config-project")
expect(needs).not.toContain("unit-windows-opencode-server-tools")
expect(validate?.env?.DOCS_ONLY).toBe("${{ needs.changes.outputs.docs_only }}")
expect(validate?.env?.TYPECHECK_RESULT).toBe("${{ needs.typecheck.result }}")
expect(validate?.env?.UNIT_APP_RESULT).toBe("${{ needs['unit-app'].result }}")
Expand Down
Loading