From 090e780974db26b8907e268991d850f5e6dee2be Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 23:34:13 +0900 Subject: [PATCH] fix: publish lazycodex as release alias --- .github/workflows/publish.yml | 107 ++++++++++---------------------- bin/oh-my-opencode.js | 39 ++---------- bin/oh-my-opencode.test.ts | 80 +++++++++++++++++++++--- script/publish-workflow.test.ts | 35 +++++++---- 4 files changed, 132 insertions(+), 129 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 81bb79219..e72675ecf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,8 +22,8 @@ on: required: false type: boolean default: false - publish_lazycodex: - description: "Publish lazycodex npm alias and sync Codex marketplace" + sync_lazycodex_marketplace: + description: "Sync the LazyCodex Codex marketplace repository" required: false type: boolean default: false @@ -35,9 +35,6 @@ permissions: id-token: write actions: write -env: - LAZYCODEX_VERSION: "0.1.0" - jobs: test: runs-on: ubuntu-latest @@ -81,7 +78,7 @@ jobs: contents: read steps: - name: Require LazyCodex sync token - if: inputs.publish_lazycodex == true + if: inputs.sync_lazycodex_marketplace == true env: LAZYCODEX_SYNC_TOKEN: ${{ secrets.LAZYCODEX_SYNC_TOKEN }} run: | @@ -94,7 +91,6 @@ jobs: 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" \ @@ -106,10 +102,7 @@ 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) - if [ "${PUBLISH_LAZYCODEX}" = "true" ]; then - ALL_PACKAGES+=(lazycodex) - fi + ALL_PACKAGES=(oh-my-opencode oh-my-openagent lazycodex) for plat in "${PLATFORMS[@]}"; do ALL_PACKAGES+=("oh-my-opencode-${plat}") ALL_PACKAGES+=("oh-my-openagent-${plat}") @@ -135,45 +128,20 @@ jobs: done if [ ${#FAILED[@]} -gt 0 ]; then - HARD_FAILED=() - SOFT_FAILED=() - for pkg in "${FAILED[@]}"; do - if [ "${pkg}" = "lazycodex" ]; then - SOFT_FAILED+=("${pkg}") - else - HARD_FAILED+=("${pkg}") - fi - done - - if [ ${#HARD_FAILED[@]} -gt 0 ]; then - { - echo - echo "::error::Trusted publisher not configured for ${#HARD_FAILED[@]} required package(s)." - echo "::error::Configure each below at the URL with these values:" - echo "::error:: Provider: GitHub Actions" - echo "::error:: Organization: code-yeongyu" - echo "::error:: Repository: ${REPO}" - echo "::error:: Workflow filename: ${WORKFLOW_FILE}" - echo - for pkg in "${HARD_FAILED[@]}"; do - echo "::error:: https://www.npmjs.com/package/${pkg}/access" - done - } >&2 - exit 1 - fi - - if [ ${#SOFT_FAILED[@]} -gt 0 ]; then - { - echo - echo "::warning::Trusted publisher is not configured for lazycodex yet." - echo "::warning::First publish can require a one-time manual npm publish to claim the name." - echo "::warning::After claiming, configure GitHub Actions trusted publishing at:" - echo "::warning:: https://www.npmjs.com/package/lazycodex/access" - echo "::warning:: Organization: code-yeongyu" - echo "::warning:: Repository: ${REPO}" - echo "::warning:: Workflow filename: ${WORKFLOW_FILE}" - } - fi + { + echo + echo "::error::Trusted publisher not configured for ${#FAILED[@]} required package(s)." + echo "::error::Configure each below at the URL with these values:" + echo "::error:: Provider: GitHub Actions" + echo "::error:: Organization: code-yeongyu" + echo "::error:: Repository: ${REPO}" + echo "::error:: Workflow filename: ${WORKFLOW_FILE}" + echo + for pkg in "${FAILED[@]}"; do + echo "::error:: https://www.npmjs.com/package/${pkg}/access" + done + } >&2 + exit 1 fi echo @@ -313,13 +281,14 @@ jobs: fi - name: Check if lazycodex already published - if: inputs.publish_lazycodex == true id: check-lazycodex + env: + VERSION: ${{ needs.release-metadata.outputs.version }} run: | - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/lazycodex/${LAZYCODEX_VERSION}") + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/lazycodex/${VERSION}") if [ "$STATUS" = "200" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" - echo "✓ lazycodex@${LAZYCODEX_VERSION} already published" + echo "✓ lazycodex@${VERSION} already published" else echo "skip=false" >> "$GITHUB_OUTPUT" fi @@ -328,7 +297,7 @@ jobs: if: >- steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true' || - (inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true') + steps.check-lazycodex.outputs.skip != 'true' env: VERSION: ${{ needs.release-metadata.outputs.version }} run: | @@ -346,14 +315,14 @@ jobs: if: >- steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true' || - (inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true') + steps.check-lazycodex.outputs.skip != 'true' run: bun run build - name: Strip token auth from .npmrc to force OIDC if: >- steps.check.outputs.skip != 'true' || steps.check-openagent.outputs.skip != 'true' || - (inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true') + steps.check-lazycodex.outputs.skip != 'true' run: | for f in .npmrc "$HOME/.npmrc"; do if [ -f "$f" ]; then @@ -372,7 +341,7 @@ jobs: if [ -n "$DIST_TAG" ]; then npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose else - npm publish --access public --provenance --loglevel verbose + npm publish --access public --provenance --tag latest --loglevel verbose fi - name: Publish oh-my-openagent @@ -405,36 +374,26 @@ jobs: git checkout -- package.json - name: Publish lazycodex - if: inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true' - continue-on-error: true + if: steps.check-lazycodex.outputs.skip != 'true' env: OMO_VERSION: ${{ needs.release-metadata.outputs.version }} DIST_TAG: ${{ needs.release-metadata.outputs.dist_tag }} NPM_CONFIG_PROVENANCE: true run: | - jq --arg lazycodex_version "$LAZYCODEX_VERSION" --arg omo_version "$OMO_VERSION" ' + jq --arg omo_version "$OMO_VERSION" ' .name = "lazycodex" | - .version = $lazycodex_version | + .version = $omo_version | .optionalDependencies = (.optionalDependencies | to_entries | map(.value = $omo_version) | from_entries) ' package.json > tmp.json && mv tmp.json package.json - set +e if [ -n "$DIST_TAG" ]; then npm publish --access public --provenance --tag "$DIST_TAG" --loglevel verbose else - npm publish --access public --provenance --loglevel verbose - fi - PUBLISH_STATUS=$? - set -e - - if [ "$PUBLISH_STATUS" -ne 0 ]; then - echo "::warning::lazycodex publish failed. If this is the first publish, claim it once manually from a trusted environment (with NPM_AUTH_TOKEN) using npm publish." - echo "::warning::Then configure GitHub Actions trusted publishing at https://www.npmjs.com/package/lazycodex/access with org=code-yeongyu repo=oh-my-openagent workflow=publish.yml." - exit 0 + npm publish --access public --provenance --tag latest --loglevel verbose fi - name: Restore package.json after lazycodex publish attempt - if: always() && inputs.publish_lazycodex == true && steps.check-lazycodex.outputs.skip != 'true' + if: always() && steps.check-lazycodex.outputs.skip != 'true' run: | git checkout -- package.json @@ -572,7 +531,7 @@ jobs: fi - name: Checkout LazyCodex marketplace - if: inputs.publish_lazycodex == true + if: inputs.sync_lazycodex_marketplace == true uses: actions/checkout@v4 with: repository: code-yeongyu/lazycodex @@ -581,7 +540,7 @@ jobs: fetch-depth: 0 - name: Sync LazyCodex Codex marketplace - if: inputs.publish_lazycodex == true + if: inputs.sync_lazycodex_marketplace == true env: VERSION: ${{ needs.release-metadata.outputs.version }} run: | diff --git a/bin/oh-my-opencode.js b/bin/oh-my-opencode.js index a064bf19f..1a712c9c6 100755 --- a/bin/oh-my-opencode.js +++ b/bin/oh-my-opencode.js @@ -6,7 +6,6 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { basename } from "node:path"; -import { fileURLToPath } from "node:url"; import { getPlatformPackageCandidates, getBinaryPath, @@ -97,10 +96,13 @@ function getWrapperPackageName() { * (e.g. `lazycodex` defaults to the Codex install flow). * @returns {string} */ -function getInvocationName() { +function getInvocationName(wrapperPackageName) { if (process.env.OMO_INVOCATION_NAME) { return process.env.OMO_INVOCATION_NAME; } + if (getPackageBareName(wrapperPackageName) === "lazycodex") { + return "lazycodex"; + } const argv1 = process.argv[1] ?? ""; if (!argv1) { return "oh-my-opencode"; @@ -108,42 +110,11 @@ function getInvocationName() { return basename(argv1, ".js").replace(/\.exe$/, ""); } -function shouldRunBundledLazyCodexCli(packageName, invocationName) { - return getPackageBareName(packageName) === "lazycodex" && invocationName === "lazycodex"; -} - -function runBundledLazyCodexCli(invocationName) { - const cliPath = fileURLToPath(new URL("../dist/cli/index.js", import.meta.url)); - const result = spawnSync("bun", [cliPath, ...process.argv.slice(2)], { - stdio: "inherit", - env: { - ...process.env, - OMO_INVOCATION_NAME: invocationName, - }, - }); - - if (result.error) { - console.error("\nlazycodex: Failed to execute bundled CLI with Bun."); - console.error("Install Bun or run through `bunx lazycodex`."); - console.error(`Error: ${result.error.message}\n`); - process.exit(2); - } - - if (result.signal) { - process.exit(getSignalExitCode(result.signal)); - } - - process.exit(result.status ?? 1); -} - function main() { const { platform, arch } = process; const libcFamily = getLibcFamily(); const wrapperPackageName = getWrapperPackageName(); - const invocationName = getInvocationName(); - if (shouldRunBundledLazyCodexCli(wrapperPackageName, invocationName)) { - runBundledLazyCodexCli(invocationName); - } + const invocationName = getInvocationName(wrapperPackageName); const packageBaseName = resolvePlatformPackageBaseName(wrapperPackageName); const avx2Supported = supportsAvx2(); diff --git a/bin/oh-my-opencode.test.ts b/bin/oh-my-opencode.test.ts index c3aecf16b..692ee7434 100644 --- a/bin/oh-my-opencode.test.ts +++ b/bin/oh-my-opencode.test.ts @@ -1,9 +1,12 @@ +/// + import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmod, cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { chmod, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { getPlatformPackageCandidates } from "./platform.js"; const testRoots: string[] = []; @@ -12,7 +15,7 @@ afterEach(async () => { }); describe("lazycodex bin wrapper", () => { - test("runs the bundled Bun CLI instead of stale OMO platform binaries", async () => { + test("runs the platform binary so npx lazycodex does not require Bun", async () => { // #given const fixture = await createLazyCodexFixture(); const nodePath = Bun.which("node") ?? "node"; @@ -23,7 +26,7 @@ describe("lazycodex bin wrapper", () => { env: { ...process.env, CAPTURE_DIR: fixture.captureDir, - PATH: `${fixture.fakeBinDir}:${process.env.PATH ?? ""}`, + PATH: fixture.fakeBinDir, }, }); @@ -31,13 +34,12 @@ describe("lazycodex bin wrapper", () => { expect(result.status).toBe(23); expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ - await realpath(fixture.bundledCli), "install", "--no-tui", ]); }); - test("runs the bundled Bun CLI when published under an npm scope", async () => { + test("runs the platform binary when published under an npm scope", async () => { // #given const fixture = await createLazyCodexFixture({ packageName: "@code-yeongyu/lazycodex" }); const nodePath = Bun.which("node") ?? "node"; @@ -48,7 +50,31 @@ describe("lazycodex bin wrapper", () => { env: { ...process.env, CAPTURE_DIR: fixture.captureDir, - PATH: `${fixture.fakeBinDir}:${process.env.PATH ?? ""}`, + PATH: fixture.fakeBinDir, + }, + }); + + // #then + expect(result.status).toBe(23); + expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); + expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ + "install", + "--no-tui", + ]); + }); + + test("routes npm shim execution from the lazycodex package to the Codex installer", async () => { + // #given + const fixture = await createLazyCodexFixture({ wrapperFileName: "oh-my-opencode.js" }); + const nodePath = Bun.which("node") ?? "node"; + + // #when + const result = spawnSync(nodePath, [fixture.wrapperBin, "install", "--no-tui"], { + encoding: "utf8", + env: { + ...process.env, + CAPTURE_DIR: fixture.captureDir, + PATH: fixture.fakeBinDir, }, }); @@ -56,14 +82,13 @@ describe("lazycodex bin wrapper", () => { expect(result.status).toBe(23); expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ - await realpath(fixture.bundledCli), "install", "--no-tui", ]); }); }); -async function createLazyCodexFixture(options: { packageName?: string } = {}) { +async function createLazyCodexFixture(options: { packageName?: string; wrapperFileName?: string } = {}) { const root = await mkdtemp(join(tmpdir(), "lazycodex-bin-wrapper-")); testRoots.push(root); @@ -76,10 +101,16 @@ async function createLazyCodexFixture(options: { packageName?: string } = {}) { await mkdir(fakeBinDir, { recursive: true }); await mkdir(captureDir, { recursive: true }); - await cp(fileURLToPath(new URL("./oh-my-opencode.js", import.meta.url)), join(binDir, "lazycodex")); + const wrapperFileName = options.wrapperFileName ?? "lazycodex"; + const wrapperBin = join(binDir, wrapperFileName); + await cp(fileURLToPath(new URL("./oh-my-opencode.js", import.meta.url)), wrapperBin); + if (wrapperFileName !== "lazycodex") { + await symlink(wrapperFileName, join(binDir, "lazycodex")); + } await cp(fileURLToPath(new URL("./platform.js", import.meta.url)), join(binDir, "platform.js")); await writeFile(join(root, "package.json"), JSON.stringify({ name: options.packageName ?? "lazycodex", type: "module" })); await writeFile(distCli, "#!/usr/bin/env bun\n"); + await writePlatformPackages(root); const fakeBun = join(fakeBinDir, "bun"); await writeFile( @@ -99,5 +130,36 @@ async function createLazyCodexFixture(options: { packageName?: string } = {}) { captureDir, fakeBinDir, lazycodexBin: join(binDir, "lazycodex"), + wrapperBin, }; } + +async function writePlatformPackages(root: string): Promise { + const packages = getPlatformPackageCandidates({ + platform: process.platform, + arch: process.arch, + libcFamily: process.platform === "linux" ? "glibc" : undefined, + packageBaseName: "oh-my-opencode", + }); + for (const packageName of packages) { + const binaryPath = join(root, "node_modules", packageName, "bin", process.platform === "win32" ? "oh-my-opencode.exe" : "oh-my-opencode"); + await mkdir(dirname(binaryPath), { recursive: true }); + await writeFile( + binaryPath, + [ + "#!/bin/sh", + "printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"", + "printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"", + "exit 23", + "", + ].join("\n"), + ); + await chmod(binaryPath, 0o755); + } + + if (process.platform === "linux") { + const detectLibcPath = join(root, "node_modules", "detect-libc", "index.js"); + await mkdir(dirname(detectLibcPath), { recursive: true }); + await writeFile(detectLibcPath, 'exports.familySync = () => "glibc";\n'); + } +} diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts index ab5a87b08..1c9c9ea39 100644 --- a/script/publish-workflow.test.ts +++ b/script/publish-workflow.test.ts @@ -155,7 +155,7 @@ describe("test workflows", () => { expect(marketplacePushSkipsWhenClean, "marketplace sync must skip push when rerun has no changes").toBe(true) }) - test("keeps LazyCodex deployment behind an explicit publish flag", () => { + test("publishes the LazyCodex npm alias on every release while keeping marketplace sync explicit", () => { // #given const workflow = readFileSync(publishWorkflowPath, "utf8") @@ -163,8 +163,8 @@ describe("test workflows", () => { const keepsCodexPluginVersionIndependent = !workflow.includes("jq --arg v \"$VERSION\" '.version = $v' packages/omo-codex/plugin/.codex-plugin/plugin.json") && !workflow.includes("jq --arg v \"$VERSION\" '.version = $v' packages/omo-codex/plugin/package.json") - const flagDefaultsOff = workflow.includes("publish_lazycodex:") && - workflow.includes('description: "Publish lazycodex npm alias and sync Codex marketplace"') && + const flagDefaultsOff = workflow.includes("sync_lazycodex_marketplace:") && + workflow.includes('description: "Sync the LazyCodex Codex marketplace repository"') && workflow.includes("default: false") const syncsLazycodexMarketplace = workflow.includes("bun run script/sync-lazycodex-marketplace.ts") const syncBuildsMcpDists = @@ -175,10 +175,18 @@ describe("test workflows", () => { 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 publishLazycodexStep = workflow.slice( + workflow.indexOf("name: Publish lazycodex"), + workflow.indexOf("name: Restore package.json after lazycodex publish attempt"), + ) + const alwaysChecksLazycodexNpm = workflow.includes("name: Check if lazycodex already published") && + workflow.includes('https://registry.npmjs.org/lazycodex/${VERSION}') + const publishesLazycodexNpm = publishLazycodexStep.includes("name: Publish lazycodex") && + publishLazycodexStep.includes("if: steps.check-lazycodex.outputs.skip != 'true'") && + publishLazycodexStep.includes("npm publish --access public --provenance --tag latest --loglevel verbose") && + !publishLazycodexStep.includes("continue-on-error: true") const gatesLazycodexMarketplaceSync = workflow.includes("name: Sync LazyCodex Codex marketplace") && - workflow.includes("if: inputs.publish_lazycodex == true") + workflow.includes("if: inputs.sync_lazycodex_marketplace == 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 }}") && @@ -187,13 +195,14 @@ describe("test workflows", () => { // #then expect(keepsCodexPluginVersionIndependent, "LazyCodex plugin metadata must keep its own 0.1.0 version").toBe(true) - expect(flagDefaultsOff, "LazyCodex deployment must default to disabled").toBe(true) + expect(flagDefaultsOff, "LazyCodex marketplace sync 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(alwaysChecksLazycodexNpm, "release must always check lazycodex using the release version").toBe(true) + expect(publishesLazycodexNpm, "lazycodex npm publish must be part of the normal release, tag stable releases as latest, and fail loudly").toBe(true) + expect(gatesLazycodexMarketplaceSync, "LazyCodex marketplace push must require sync_lazycodex_marketplace=true").toBe(true) expect(requiresLazycodexSyncToken, "release must require a cross-repo token for LazyCodex push").toBe(true) }) @@ -203,8 +212,10 @@ describe("test workflows", () => { const platformResolver = readFileSync(new URL("../bin/platform.js", import.meta.url), "utf8") // #when - const lazycodexStepPinsWrapperVersion = workflow.includes('LAZYCODEX_VERSION: "0.1.0"') && - workflow.includes(".version = $lazycodex_version |") + const lazycodexStepUsesReleaseVersion = + !workflow.includes('LAZYCODEX_VERSION: "0.1.0"') && + workflow.includes(".name = \"lazycodex\" |") && + workflow.includes(".version = $omo_version |") const lazycodexStepUsesOmoPlatformVersion = workflow.includes( ".optionalDependencies = (.optionalDependencies | to_entries | map(.value = $omo_version) | from_entries)", ) @@ -213,7 +224,7 @@ describe("test workflows", () => { platformResolver.includes("lazycodex") && platformResolver.includes("oh-my-opencode") // #then - expect(lazycodexStepPinsWrapperVersion, "lazycodex publish step must pin wrapper metadata to 0.1.0").toBe(true) + expect(lazycodexStepUsesReleaseVersion, "lazycodex publish step must use the release version so unpublished versions are not reused").toBe(true) expect(lazycodexStepUsesOmoPlatformVersion, "lazycodex must depend on the matching OMO platform packages").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)