ci(lazycodex): gate publish and install

This commit is contained in:
YeonGyu-Kim
2026-05-29 12:18:42 +09:00
parent 4bf9095456
commit 0edf087117
14 changed files with 227 additions and 23 deletions
+17 -5
View File
@@ -22,6 +22,11 @@ on:
required: false
type: boolean
default: false
publish_lazycodex:
description: "Publish lazycodex npm alias and sync Codex marketplace"
required: false
type: boolean
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -72,10 +77,11 @@ jobs:
id-token: write
contents: read
steps:
- name: Verify trusted publisher for all 25 packages
- name: Verify trusted publisher for release packages
env:
REPO: code-yeongyu/oh-my-openagent
WORKFLOW_FILE: publish.yml
PUBLISH_LAZYCODEX: ${{ inputs.publish_lazycodex }}
run: |
OIDC_TOKEN=$(curl -sH "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org" \
@@ -87,7 +93,10 @@ jobs:
fi
PLATFORMS=(darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline)
ALL_PACKAGES=(oh-my-opencode oh-my-openagent lazycodex)
ALL_PACKAGES=(oh-my-opencode oh-my-openagent)
if [ "${PUBLISH_LAZYCODEX}" = "true" ]; then
ALL_PACKAGES+=(lazycodex)
fi
for plat in "${PLATFORMS[@]}"; do
ALL_PACKAGES+=("oh-my-opencode-${plat}")
ALL_PACKAGES+=("oh-my-openagent-${plat}")
@@ -327,6 +336,7 @@ jobs:
git checkout -- package.json
- name: Check if lazycodex already published
if: inputs.publish_lazycodex == true
id: check-lazycodex
env:
VERSION: ${{ steps.version.outputs.version }}
@@ -340,7 +350,7 @@ jobs:
fi
- name: Publish lazycodex
if: steps.check-lazycodex.outputs.skip != 'true'
if: inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true'
continue-on-error: true
env:
VERSION: ${{ steps.version.outputs.version }}
@@ -368,7 +378,7 @@ jobs:
fi
- name: Restore package.json after lazycodex publish attempt
if: always() && steps.check-lazycodex.outputs.skip != 'true'
if: always() && inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true'
run: |
git checkout -- package.json
@@ -452,12 +462,13 @@ jobs:
git push origin "v${VERSION}"
- name: Require LazyCodex sync token
if: ${{ secrets.LAZYCODEX_SYNC_TOKEN == '' }}
if: inputs.publish_lazycodex == true && secrets.LAZYCODEX_SYNC_TOKEN == ''
run: |
echo "::error::LAZYCODEX_SYNC_TOKEN is required to push the Codex marketplace bundle to code-yeongyu/lazycodex."
exit 1
- name: Checkout LazyCodex marketplace
if: inputs.publish_lazycodex == true
uses: actions/checkout@v4
with:
repository: code-yeongyu/lazycodex
@@ -466,6 +477,7 @@ jobs:
fetch-depth: 0
- name: Sync LazyCodex Codex marketplace
if: inputs.publish_lazycodex == true
env:
VERSION: ${{ needs.publish-main.outputs.version }}
run: |
+11 -1
View File
@@ -59,21 +59,31 @@ describe("test workflows", () => {
expect(buildNeedsCodexMatrix, "Build must wait for Codex compatibility checks").toBe(true)
})
test("syncs the LazyCodex Codex marketplace bundle during release", () => {
test("keeps LazyCodex deployment behind an explicit publish flag", () => {
// #given
const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "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 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 requiresLazycodexSyncToken = workflow.includes("secrets.LAZYCODEX_SYNC_TOKEN == ''") &&
workflow.includes("token: ${{ secrets.LAZYCODEX_SYNC_TOKEN }}")
// #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(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)
})
})
+7
View File
@@ -60,13 +60,20 @@ function stubOpenCodeSuccess(): void {
describe("runCliInstaller platform branching", () => {
const consoleLog = console.log
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => {
console.log = mock(() => {})
process.env.OMO_PUBLISH_LAZYCODEX = "true"
})
afterEach(() => {
console.log = consoleLog
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
mock.restore()
})
+7
View File
@@ -9,6 +9,7 @@ describe("runCliInstaller", () => {
const mockConsoleError = mock(() => {})
const originalConsoleLog = console.log
const originalConsoleError = console.error
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => {
console.log = mockConsoleLog
@@ -20,6 +21,11 @@ describe("runCliInstaller", () => {
afterEach(() => {
console.log = originalConsoleLog
console.error = originalConsoleError
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
mock.restore()
})
@@ -128,6 +134,7 @@ describe("runCliInstaller", () => {
it("skips OpenCode checks and writes for platform=codex", async () => {
// given
process.env.OMO_PUBLISH_LAZYCODEX = "true"
const detectSpy = spyOn(configManager, "detectCurrentConfig")
const installedSpy = spyOn(configManager, "isOpenCodeInstalled")
const versionSpy = spyOn(configManager, "getOpenCodeVersion")
+7 -1
View File
@@ -6,6 +6,7 @@ import { doctor } from "./doctor"
import { refreshModelCapabilities } from "./refresh-model-capabilities"
import { createMcpOAuthCommand } from "./mcp-oauth"
import { boulder } from "./boulder"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
@@ -31,17 +32,22 @@ type InstallCommandOptions = {
readonly skipAuth?: boolean
}
type Environment = Readonly<Record<string, string | undefined>>
export function resolveInstallArgs(
options: InstallCommandOptions,
invocationName: string | undefined = process.env.OMO_INVOCATION_NAME,
env: Environment = process.env,
): InstallArgs {
const defaultPlatform = invocationName === "lazycodex" && isLazycodexPublishingEnabled(env) ? "codex" : undefined
return {
tui: options.tui !== false,
claude: options.claude,
openai: options.openai,
gemini: options.gemini,
copilot: options.copilot,
platform: options.platform ?? (invocationName === "lazycodex" ? "codex" : undefined),
platform: options.platform ?? defaultPlatform,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
@@ -7,18 +7,46 @@ import { argsToConfig } from "../install-validators"
describe("lazycodex install routing", () => {
const originalInvocationName = process.env.OMO_INVOCATION_NAME
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
afterEach(() => {
if (originalInvocationName === undefined) {
delete process.env.OMO_INVOCATION_NAME
return
} else {
process.env.OMO_INVOCATION_NAME = originalInvocationName
}
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
process.env.OMO_INVOCATION_NAME = originalInvocationName
})
test("defaults platform to codex when invoked as lazycodex without --platform", () => {
test("leaves lazycodex invocation unresolved when lazycodex publishing is disabled", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazycodex"
delete process.env.OMO_PUBLISH_LAZYCODEX
// when
const args = resolveInstallArgs({
tui: false,
claude: "no",
gemini: "no",
copilot: "no",
})
const config = argsToConfig(args)
// then
expect(args.platform).toBeUndefined()
expect(config.hasCodex).toBe(false)
expect(config.hasOpenCode).toBe(true)
})
test("defaults platform to codex when invoked as lazycodex with lazycodex publishing enabled", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazycodex"
process.env.OMO_PUBLISH_LAZYCODEX = "true"
// when
const args = resolveInstallArgs({
@@ -35,9 +63,10 @@ describe("lazycodex install routing", () => {
expect(config.hasOpenCode).toBe(false)
})
test("respects explicit --platform=both when invoked as lazycodex", () => {
test("respects explicit --platform=both when lazycodex publishing is enabled", () => {
// given
process.env.OMO_INVOCATION_NAME = "lazycodex"
process.env.OMO_PUBLISH_LAZYCODEX = "true"
// when
const args = resolveInstallArgs({
+12 -1
View File
@@ -48,13 +48,24 @@ describe("install platform resolution", () => {
expect(args.platform).toBe("opencode")
})
test("defaults lazycodex install to codex platform", () => {
test("leaves lazycodex install unresolved when lazycodex publishing is disabled", () => {
// given
const invocationName = "lazycodex"
// when
const args = resolveInstallArgs({ tui: true }, invocationName)
// then
expect(args.platform).toBeUndefined()
})
test("defaults lazycodex install to codex platform when lazycodex publishing is enabled", () => {
// given
const invocationName = "lazycodex"
// when
const args = resolveInstallArgs({ tui: true }, invocationName, { OMO_PUBLISH_LAZYCODEX: "true" })
// then
expect(args.platform).toBe("codex")
})
+30 -2
View File
@@ -117,24 +117,52 @@ describe("validateNonTuiArgs", () => {
expect(result.errors).toContain("--copilot is required (values: no, yes)")
})
test("allows codex-only non-TUI installs without OpenCode provider flags", () => {
test("rejects codex-only non-TUI installs when lazycodex publishing is disabled", () => {
// #given
const args: InstallArgs = { tui: false, platform: "codex" }
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain(
"Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.",
)
})
test("allows codex-only non-TUI installs with lazycodex publishing enabled", () => {
// #given
const args: InstallArgs = { tui: false, platform: "codex" }
// #when
const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" })
// #then
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
})
test("rejects platform=both when lazycodex publishing is disabled", () => {
// #given
const args = createArgs({ platform: "both" })
// #when
const result = validateNonTuiArgs(args)
// #then
expect(result.valid).toBe(false)
expect(result.errors).toContain(
"Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install.",
)
})
test("rejects OpenCode flags for codex-only non-TUI installs", () => {
// #given
const args = createArgs({ platform: "codex", claude: "yes" })
// #when
const result = validateNonTuiArgs(args)
const result = validateNonTuiArgs(args, { OMO_PUBLISH_LAZYCODEX: "true" })
// #then
expect(result.valid).toBe(false)
+14 -1
View File
@@ -7,6 +7,11 @@ import type {
InstallConfig,
InstallPlatform,
} from "./types"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
platformRequiresLazycodex,
} from "./lazycodex-feature-flag"
export const SYMBOLS = {
check: color.green("[OK]"),
@@ -19,6 +24,7 @@ export const SYMBOLS = {
}
const ANSI_COLOR_PATTERN = new RegExp("\u001b\\[[0-9;]*m", "g")
type Environment = Readonly<Record<string, string | undefined>>
function formatProvider(name: string, enabled: boolean, detail?: string): string {
const status = enabled ? SYMBOLS.check : color.dim("○")
@@ -116,12 +122,19 @@ export function printBox(content: string, title?: string): void {
console.log()
}
export function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string[] } {
export function validateNonTuiArgs(
args: InstallArgs,
env: Environment = process.env,
): { valid: boolean; errors: string[] } {
const errors: string[] = []
const platform = resolvePlatform(args)
const hasOpenCode = platform === "opencode" || platform === "both"
const hasCodexOnly = platform === "codex"
if (platformRequiresLazycodex(platform) && !isLazycodexPublishingEnabled(env)) {
errors.push(LAZYCODEX_DISABLED_MESSAGE)
}
if (hasOpenCode && args.claude === undefined) {
errors.push("--claude is required (values: no, yes, max20)")
} else if (args.claude !== undefined && !["no", "yes", "max20"].includes(args.claude)) {
+15
View File
@@ -0,0 +1,15 @@
import type { InstallPlatform } from "./types"
export const LAZYCODEX_PUBLISH_FLAG = "OMO_PUBLISH_LAZYCODEX"
export const LAZYCODEX_DISABLED_MESSAGE =
"Codex platform install is disabled. Set OMO_PUBLISH_LAZYCODEX=true to enable LazyCodex publish/install."
type Environment = Readonly<Record<string, string | undefined>>
export function isLazycodexPublishingEnabled(env: Environment = process.env): boolean {
return env[LAZYCODEX_PUBLISH_FLAG] === "true"
}
export function platformRequiresLazycodex(platform: InstallPlatform | undefined): boolean {
return platform === "codex" || platform === "both"
}
+18 -2
View File
@@ -46,12 +46,12 @@ describe("promptInstallPlatform", () => {
mock.restore()
})
test("offers OpenCode, Codex, and Both choices with OpenCode as the default", async () => {
test("offers OpenCode, Codex, and Both choices when lazycodex publishing is enabled", async () => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("opencode")
// when
const value = await prompts.promptInstallPlatform()
const value = await prompts.promptInstallPlatform("opencode", true)
// then
expect(value).toBe("opencode")
@@ -65,6 +65,22 @@ describe("promptInstallPlatform", () => {
],
})
})
test("hides Codex platform choices when lazycodex publishing is disabled", async () => {
// given
const selectSpy = spyOn(p, "select").mockResolvedValue("opencode")
// when
const value = await prompts.promptInstallPlatform("codex", false)
// then
expect(value).toBe("opencode")
expect(selectSpy).toHaveBeenCalledTimes(1)
expect(selectSpy.mock.calls[0]?.[0]).toMatchObject({
initialValue: "opencode",
options: [{ value: "opencode" }],
})
})
})
describe("promptInstallConfig platform branching", () => {
+16 -6
View File
@@ -7,6 +7,7 @@ import type {
InstallPlatform,
} from "./types"
import { detectedToInitialValues } from "./install-validators"
import { isLazycodexPublishingEnabled } from "./lazycodex-feature-flag"
async function selectOrCancel<TValue extends Readonly<string | boolean | number>>(params: {
message: string
@@ -29,15 +30,24 @@ async function selectOrCancel<TValue extends Readonly<string | boolean | number>
export async function promptInstallPlatform(
initialValue: InstallPlatform = "opencode",
lazycodexEnabled = isLazycodexPublishingEnabled(),
): Promise<InstallPlatform | null> {
return selectOrCancel<InstallPlatform>({
message: "Which platform do you want to install?",
options: [
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
const options: Option<InstallPlatform>[] = [
{ value: "opencode", label: "OpenCode", hint: "Install OpenCode plugin only" },
]
if (lazycodexEnabled) {
options.push(
{ value: "codex", label: "Codex", hint: "Install Codex harness adapter only" },
{ value: "both", label: "Both", hint: "Install OpenCode plugin and Codex adapter" },
],
initialValue,
)
}
const safeInitialValue = lazycodexEnabled || initialValue === "opencode" ? initialValue : "opencode"
return selectOrCancel<InstallPlatform>({
message: "Which platform do you want to install?",
options,
initialValue: safeInitialValue,
})
}
+30
View File
@@ -19,6 +19,7 @@ function createMockSpinner(): ReturnType<typeof p.spinner> {
describe("runTuiInstaller", () => {
const originalIsStdinTty = process.stdin.isTTY
const originalIsStdoutTty = process.stdout.isTTY
const originalPublishLazycodex = process.env.OMO_PUBLISH_LAZYCODEX
beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
@@ -28,6 +29,11 @@ describe("runTuiInstaller", () => {
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
if (originalPublishLazycodex === undefined) {
delete process.env.OMO_PUBLISH_LAZYCODEX
} else {
process.env.OMO_PUBLISH_LAZYCODEX = originalPublishLazycodex
}
})
it("blocks installation when OpenCode is below the minimum version", async () => {
@@ -76,6 +82,29 @@ describe("runTuiInstaller", () => {
outroSpy.mockRestore()
})
it("blocks codex platform when lazycodex publishing is disabled", async () => {
// given
delete process.env.OMO_PUBLISH_LAZYCODEX
const platformSpy = spyOn(tuiInstallPrompts, "promptInstallPlatform").mockResolvedValue("codex")
const promptConfigSpy = spyOn(tuiInstallPrompts, "promptInstallConfig")
const logErrorSpy = spyOn(p.log, "error").mockImplementation(() => undefined)
const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined)
// when
const result = await runTuiInstaller({ tui: true, platform: "codex" }, "3.16.0")
// then
expect(result).toBe(1)
expect(platformSpy).toHaveBeenCalled()
expect(promptConfigSpy).not.toHaveBeenCalled()
expect(logErrorSpy).toHaveBeenCalled()
platformSpy.mockRestore()
promptConfigSpy.mockRestore()
logErrorSpy.mockRestore()
outroSpy.mockRestore()
})
it("proceeds when OpenCode meets the minimum version", async () => {
// given
const restoreSpies = [
@@ -143,6 +172,7 @@ describe("runTuiInstaller", () => {
it("skips OpenCode checks and writes when platform is codex", async () => {
// given
process.env.OMO_PUBLISH_LAZYCODEX = "true"
const restoreSpies = [
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined),
+10
View File
@@ -13,6 +13,11 @@ import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig, promptInstallPlatform } from "./tui-install-prompts"
import { runCodexInstaller } from "./install-codex"
import {
LAZYCODEX_DISABLED_MESSAGE,
isLazycodexPublishingEnabled,
platformRequiresLazycodex,
} from "./lazycodex-feature-flag"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -22,6 +27,11 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
const selectedPlatform = await promptInstallPlatform(args.platform ?? "opencode")
if (!selectedPlatform) return 1
if (platformRequiresLazycodex(selectedPlatform) && !isLazycodexPublishingEnabled()) {
p.log.error(LAZYCODEX_DISABLED_MESSAGE)
p.outro(color.red("Installation blocked."))
return 1
}
const hasOpenCode = selectedPlatform === "opencode" || selectedPlatform === "both"
const detected = hasOpenCode