diff --git a/script/package-layout-exclusion.test.ts b/script/package-layout-exclusion.test.ts index 033b7ee56..f52b1f2ee 100644 --- a/script/package-layout-exclusion.test.ts +++ b/script/package-layout-exclusion.test.ts @@ -153,7 +153,18 @@ function writeFakeInternalArtifacts(packagePaths: readonly string[]): void { for (const packagePath of packagePaths) { const artifactPath = join(repositoryRoot, packagePath) mkdirSync(dirname(artifactPath), { recursive: true }) - writeFileSync(artifactPath, "# Fake internal artifact for package-layout-exclusion.test.ts\n") + const content = packagePath.endsWith("/SKILL.md") + ? [ + "---", + `name: ${fakeArtifactName}`, + "description: Internal fake skill artifact for package layout exclusion tests.", + "---", + "", + "# Fake internal artifact for package-layout-exclusion.test.ts", + "", + ].join("\n") + : "# Fake internal artifact for package-layout-exclusion.test.ts\n" + writeFileSync(artifactPath, content) } } @@ -186,6 +197,9 @@ describe("published package layout exclusions", () => { test("#given internal-only skill assets #when packing package #then forbidden skill assets do not ship", async () => { // given expect(collectExistingFakeInternalSkillArtifactPaths()).toEqual(fakeInternalSkillArtifactPaths.toSorted()) + for (const packagePath of fakeInternalSkillArtifactPaths) { + expect(readFileSync(join(repositoryRoot, packagePath), "utf8")).toStartWith("---\n") + } // when const packedPaths = await packDryRunPaths() @@ -193,7 +207,7 @@ describe("published package layout exclusions", () => { // then const packedInternalSkillPaths = fakeInternalSkillArtifactPaths.filter((packagePath) => packedPaths.has(packagePath)) expect(packedInternalSkillPaths).toEqual([]) - }) + }, { timeout: 20_000 }) test("#given internal-only command assets #when packing package #then forbidden command assets do not ship", async () => { // given @@ -207,5 +221,5 @@ describe("published package layout exclusions", () => { // then const packedInternalCommandPaths = fakeInternalCommandArtifactPaths.filter((packagePath) => packedPaths.has(packagePath)) expect(packedInternalCommandPaths).toEqual([]) - }) + }, { timeout: 20_000 }) }) diff --git a/script/package-layout.test.ts b/script/package-layout.test.ts index 5f7c759f3..4dbf095ee 100644 --- a/script/package-layout.test.ts +++ b/script/package-layout.test.ts @@ -7,6 +7,7 @@ const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)) const commandRoots = [".opencode/command", ".agents/command"] as const const skillRoots = [".opencode/skills", ".agents/skills"] as const const packageLayoutTestTimeoutMs = 60_000 +const packDryRunTimeoutMs = 15_000 setDefaultTimeout(packageLayoutTestTimeoutMs) @@ -133,5 +134,5 @@ describe("published package layout", () => { // then const missingPaths = expectedAssetPaths.filter((expectedPath) => !packedPaths.has(expectedPath)) expect(missingPaths).toEqual([]) - }) + }, packDryRunTimeoutMs) }) diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts index ef2f3b070..f99f7e25e 100644 --- a/script/publish-workflow.test.ts +++ b/script/publish-workflow.test.ts @@ -1,22 +1,40 @@ /// import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" +import { readFileSync, readdirSync } from "node:fs" + +const ciWorkflowPath = new URL("../.github/workflows/ci.yml", import.meta.url) +const publishWorkflowPath = new URL("../.github/workflows/publish.yml", import.meta.url) +const publishPlatformWorkflowPath = new URL("../.github/workflows/publish-platform.yml", import.meta.url) +const workflowsDir = new URL("../.github/workflows/", import.meta.url) +const pinnedBunVersion = 'bun-version: "1.3.12"' +const workflowPaths = readdirSync(workflowsDir) + .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) + .map((name) => new URL(name, workflowsDir)) const workflowChecks = [ { - path: new URL("../.github/workflows/ci.yml", import.meta.url), + path: ciWorkflowPath, testRuns: [ "run: bun test", "run: bun test src/shared/dist-bundle-bun-globals.test.ts", ], }, { - path: new URL("../.github/workflows/publish.yml", import.meta.url), + path: publishWorkflowPath, testRuns: ["run: bun test"], }, ] +function sliceWorkflowSection(workflow: string, startMarker: string, endMarker: string): string { + const start = workflow.indexOf(startMarker) + const end = workflow.indexOf(endMarker, start) + if (start < 0 || end < 0 || end <= start) { + throw new Error(`missing workflow section between ${startMarker} and ${endMarker}`) + } + return workflow.slice(start, end) +} + describe("test workflows", () => { test("use pure bun test for workflows", () => { for (const workflowCheck of workflowChecks) { @@ -28,4 +46,171 @@ describe("test workflows", () => { } } }) + + test("exercise root checks across linux macos and windows", () => { + // #given + const workflow = readFileSync(ciWorkflowPath, "utf8") + + // #when + const hasCrossOsMatrix = workflow.includes("os: [ubuntu-latest, macos-latest, windows-latest]") + const hasMatrixRunner = workflow.includes("runs-on: ${{ matrix.os }}") + + // #then + expect(hasCrossOsMatrix, "CI root checks must cover Linux, macOS, and Windows").toBe(true) + expect(hasMatrixRunner, "CI root checks must run on the selected matrix OS").toBe(true) + }) + + test("runs codex compatibility checks on every supported os", () => { + // #given + const workflow = readFileSync(ciWorkflowPath, "utf8") + + // #when + const hasCodexMatrixJob = workflow.includes("codex-compatibility:") + const hasCodexCommand = workflow.includes("run: bun run test:codex") + const buildNeedsCodexMatrix = workflow.includes("needs: [test, typecheck, codex-compatibility]") + + // #then + expect(hasCodexMatrixJob, "CI must expose a Codex compatibility matrix job").toBe(true) + expect(hasCodexCommand, "Codex compatibility job must run the shared Codex test script").toBe(true) + expect(buildNeedsCodexMatrix, "Build must wait for Codex compatibility checks").toBe(true) + }) + + test("pins every workflow Bun setup to the tested runtime", () => { + for (const workflowPath of workflowPaths) { + // #given + const workflow = readFileSync(workflowPath, "utf8") + const bunVersionLines = workflow.match(/bun-version: .*/g) ?? [] + + // #when + const unpinnedBunLines = bunVersionLines.filter((line) => line !== pinnedBunVersion) + + // #then + expect(unpinnedBunLines, `${workflowPath.pathname} must pin Bun to 1.3.12`).toEqual([]) + } + }) + + test("publishes platform packages before installable wrappers", () => { + // #given + const workflow = readFileSync(publishWorkflowPath, "utf8") + + // #when + const computesReleaseMetadata = workflow.includes("release-metadata:") && + workflow.includes("outputs:") && + workflow.includes("version: ${{ steps.version.outputs.version }}") && + workflow.includes("dist_tag: ${{ steps.version.outputs.dist_tag }}") + const computesVersionOnce = (workflow.match(/id: version/g) ?? []).length === 1 + const platformUsesMetadata = workflow.includes("version: ${{ needs.release-metadata.outputs.version }}") && + workflow.includes("dist_tag: ${{ needs.release-metadata.outputs.dist_tag }}") + const mainWaitsForPlatform = workflow.includes("needs: [test, typecheck, preflight-trust, release-metadata, publish-platform]") && + workflow.includes("inputs.skip_platform == true || needs.publish-platform.result == 'success'") + const releaseUsesMetadata = workflow.includes("VERSION: ${{ needs.release-metadata.outputs.version }}") + const wrappersVerifyPlatformPackages = workflow.includes("name: Verify platform packages are published") && + workflow.includes("Missing platform package(s); refusing to publish wrappers.") + + // #then + expect(computesReleaseMetadata, "release metadata must be a first-class job output").toBe(true) + expect(computesVersionOnce, "version and dist tag must be computed exactly once").toBe(true) + expect(platformUsesMetadata, "platform workflow must consume the shared release metadata").toBe(true) + expect(mainWaitsForPlatform, "wrapper publish must wait for platform success unless pre-published platforms are explicitly verified").toBe(true) + expect(releaseUsesMetadata, "release tail must use the shared release metadata").toBe(true) + expect(wrappersVerifyPlatformPackages, "wrappers must verify matching platform binaries exist before npm publish").toBe(true) + }) + + test("fails when a required platform artifact is missing", () => { + // #given + const workflow = readFileSync(publishPlatformWorkflowPath, "utf8") + + // #when + const downloadStep = sliceWorkflowSection( + workflow, + " - name: Download artifact", + " - name: Extract artifact", + ) + const downloadsWhenPublishNeeded = downloadStep.includes("if: steps.check.outputs.skip_all != 'true'") + const suppressesDownloadFailure = downloadStep.includes("continue-on-error: true") + + // #then + expect(downloadsWhenPublishNeeded, "publish job must download artifacts for packages that still need publishing").toBe(true) + expect(suppressesDownloadFailure, "missing required artifacts must fail the reusable publish workflow").toBe(false) + }) + + test("keeps the release tail safe to rerun after a tag exists", () => { + // #given + const workflow = readFileSync(publishWorkflowPath, "utf8") + + // #when + const hasExistingTagResolver = workflow.includes("id: release-state") && + workflow.includes("tag_exists=true") && + workflow.includes('git checkout --detach "v${VERSION}"') + const tagStepSkipsExistingTag = workflow.includes("if: steps.release-state.outputs.tag_exists != 'true'") + const noHardExistingTagFailure = !workflow.includes("Tag v${VERSION} already exists") + const pushSkipsExistingRemoteTag = workflow.includes("Release tag v${VERSION} already exists on origin") + const marketplacePushSkipsWhenClean = workflow.includes("LazyCodex marketplace already up to date") + + // #then + expect(hasExistingTagResolver, "release must detect and reuse an existing tag on rerun").toBe(true) + expect(tagStepSkipsExistingTag, "tag creation must skip when the release tag already exists").toBe(true) + expect(noHardExistingTagFailure, "existing tags must not make reruns fail").toBe(true) + expect(pushSkipsExistingRemoteTag, "tag push must be skip-if-exists").toBe(true) + expect(marketplacePushSkipsWhenClean, "marketplace sync must skip push when rerun has no changes").toBe(true) + }) + + test("keeps LazyCodex deployment behind an explicit publish flag", () => { + // #given + const workflow = readFileSync(publishWorkflowPath, "utf8") + + // #when + const appliesCodexPluginVersion = workflow.includes("packages/omo-codex/plugin/.codex-plugin/plugin.json") + const flagDefaultsOff = workflow.includes("publish_lazycodex:") && + workflow.includes('description: "Publish lazycodex npm alias and sync Codex marketplace"') && + workflow.includes("default: false") + const syncsLazycodexMarketplace = workflow.includes("bun run script/sync-lazycodex-marketplace.ts") + const syncBuildsMcpDists = + workflow.includes("bun run build:ast-grep-mcp") && + workflow.includes("bun run build:lsp-tools-mcp") && + workflow.indexOf("bun run build:lsp-tools-mcp") < workflow.indexOf("bun run script/sync-lazycodex-marketplace.ts") + const syncBuildsCodexPlugin = + workflow.includes("bun run --cwd packages/omo-codex/plugin build") && + workflow.indexOf("bun run --cwd packages/omo-codex/plugin build") < workflow.indexOf("bun run script/sync-lazycodex-marketplace.ts") + const pushesLazycodexMarketplace = workflow.includes("code-yeongyu/lazycodex") + const gatesLazycodexNpmPublish = workflow.includes("name: Publish lazycodex") && + workflow.includes("if: inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true'") + const gatesLazycodexMarketplaceSync = workflow.includes("name: Sync LazyCodex Codex marketplace") && + workflow.includes("if: inputs.publish_lazycodex == true") + const tokenRequirementBeforePublish = workflow.indexOf("name: Require LazyCodex sync token") < + workflow.indexOf("publish-main:") + const requiresLazycodexSyncToken = workflow.includes("LAZYCODEX_SYNC_TOKEN: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}") && + workflow.includes("token: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}") && + tokenRequirementBeforePublish + + // #then + expect(appliesCodexPluginVersion, "release must version the Codex plugin manifest before marketplace sync").toBe(true) + expect(flagDefaultsOff, "LazyCodex deployment must default to disabled").toBe(true) + expect(syncsLazycodexMarketplace, "release must sync the LazyCodex marketplace bundle").toBe(true) + expect(syncBuildsMcpDists, "release must build bundled MCP dists before LazyCodex marketplace sync").toBe(true) + expect(syncBuildsCodexPlugin, "release must build the aggregate Codex plugin before LazyCodex marketplace sync").toBe(true) + expect(pushesLazycodexMarketplace, "release must target the LazyCodex repository").toBe(true) + expect(gatesLazycodexNpmPublish, "lazycodex npm publish must require publish_lazycodex=true").toBe(true) + expect(gatesLazycodexMarketplaceSync, "LazyCodex marketplace push must require publish_lazycodex=true").toBe(true) + expect(requiresLazycodexSyncToken, "release must require a cross-repo token for LazyCodex push").toBe(true) + }) + + test("keeps lazycodex platform dependencies aligned with shim resolution", () => { + // #given + const workflow = readFileSync(publishWorkflowPath, "utf8") + const platformResolver = readFileSync(new URL("../bin/platform.js", import.meta.url), "utf8") + + // #when + const lazycodexStepOnlyRenamesWrapper = workflow.includes('.name = "lazycodex" |') && + workflow.includes('.version = $v |') && + workflow.includes('.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)') + const lazycodexStepDoesNotRenameOptionalDeps = !workflow.includes('sub("^oh-my-opencode-"; "lazycodex-")') + const shimMapsLazycodexToPublishedPlatformFamily = + platformResolver.includes("lazycodex") && platformResolver.includes("oh-my-opencode") + + // #then + expect(lazycodexStepOnlyRenamesWrapper, "lazycodex publish step should only rename wrapper metadata").toBe(true) + expect(lazycodexStepDoesNotRenameOptionalDeps, "lazycodex publish step must keep optionalDependencies on published platform packages").toBe(true) + expect(shimMapsLazycodexToPublishedPlatformFamily, "platform resolver must map lazycodex to the real published platform package family").toBe(true) + }) }) diff --git a/script/tsconfig.json b/script/tsconfig.json index b4a10ba05..b2a515aba 100644 --- a/script/tsconfig.json +++ b/script/tsconfig.json @@ -11,5 +11,5 @@ "allowImportingTsExtensions": true, "noEmit": true }, - "include": ["./publish-workflow.test.ts", "./package-layout.test.ts"] + "include": ["./publish-workflow.test.ts", "./package-layout.test.ts", "./lazycodex-marketplace-validation.ts", "./sync-lazycodex-marketplace.ts", "./sync-lazycodex-marketplace.test.ts"] }