Skip to content

feat(release): automate lash releases without an interactive OTP (LAC-4453) - #50

Open
lacymorrow wants to merge 4 commits into
lashfrom
LAC-4453/automate-lash-release
Open

lacymorrow wants to merge 4 commits into
lashfrom
LAC-4453/automate-lash-release

Conversation

@lacymorrow

Copy link
Copy Markdown
Owner

bun run release on the lash branch now completes the npm publish with nobody typing a code, and the same thing can be triggered from the Actions tab.

The problem

Two independent blockers, both verified on origin/lash:

  1. No lash release workflow existed. All 27 files in .github/workflows/ came from upstream and every publish-capable one is gated if: github.repository == 'anomalyco/opencode' or 'sst/opencode'. Nothing matched lacymorrow/lash, so nothing ever ran.
  2. npm publish read a 2FA code from stdin. publish-lash.ts prompted via readline and passed --otp= on every npm publish. A human had to type a code that expires in ~30 seconds, so an agent could not do it and no token fixed it alone.

Net effect: npm lashcode has been stuck at 1.7.13 since 2026-05-05. v1.7.14 is tagged and pushed with no GitHub release and no npm publish.

What changed

.github/workflows/release-lash.yml (new). workflow_dispatch, gated if: github.repository == 'lacymorrow/lash', pinned to ref: lash so a release can only ever come off the release branch. Optional version input for a minor or a major; optional dry_run input that checks the credential and the next version, then stops.

One ubuntu-24.04 runner, no build matrix: build-lash.ts already cross-compiles all eleven targets, so a matrix would add eleven jobs and artifact plumbing for no benefit. Also left out: AUR, ghcr, vscode, desktop, and code signing. None of them are part of a lash CLI release today, and signing is a separate decision.

packages/opencode/script/npm-auth.ts (new). With NPM_TOKEN present it writes a 0600 npmrc to a temp path, points npm at it via npm_config_userconfig, and the --otp flag is dropped entirely. Granular automation tokens bypass 2FA, which is the whole mechanism. verifyNpmAuth() runs before the build, so a dead token fails in about two seconds with the command needed to replace it instead of after a fifteen minute build.

packages/opencode/script/release-version.ts (new, with tests). Patch-bumps whichever of npm and the newest GitHub release is further ahead, then skips any version whose tag already exists. This matters immediately: both sources read 1.7.13, so a naive patch bump lands on 1.7.14, whose tag already points at an older commit than HEAD. The next release is 1.7.15.

packages/opencode/script/publish-lash.ts. publishWithInteractiveOtp split into publishPackages (picks the path) and publishBatch (does the work, --otp conditional). The interactive prompt is untouched as the local fallback. The existing idempotency check is kept, so a rerun skips what is already on the registry.

script/release-lash.ts. Refuses to run off lash rather than inferring intent from the preview channel. Throws instead of warning when zero archives were built. Reuses a release a previous run left half finished (gh release upload --clobber). Appends to GITHUB_OUTPUT instead of truncating it, and no longer writes it twice, which was silently dropping the release and tag outputs. Publishes to npm before the homebrew tap, so a missing cross-repo token cannot block the npm publish.

package.json. Dropped the inline awk version bump from the release script so the version has one definition.

LASH_RELEASE.md. Documents the workflow, the dry run, and the three credentials. The old text described the OTP prompt as the only path and pointed at a bun run publish script that does not exist.

Verification

Check Result
bun test test/release-version.test.ts 8 pass, 0 fail. Includes the v1.7.14 case (1.7.13 + stranded tag gives 1.7.15) and the upstream-junk-tags case (v4.7, v2.0.0 must not steer the version)
bun run typecheck in packages/opencode clean, exit 0
tsgo --noEmit on script/release-lash.ts clean, only a pre-existing error in generated packages/sdk/js/src/gen/client/client.gen.ts
bunx oxlint on the three changed scripts 0 errors, 11 warnings, all pre-existing style warnings in untouched code
Branch guard ran bun run script/release-lash.ts off lash, exits non-zero: Releases are cut from the lash branch. You are on LAC-4453/automate-lash-release.
No-token path verifyNpmAuth() logs no NPM_TOKEN set, publish will prompt for an OTP, does not throw, leaves npm_config_userconfig unset
Dead-token path with a bogus token it throws in ~2s with the 401 plus the granular-token instructions; npmrc written mode 100600
Workflow YAML parses; inputs version, dry_run; job if is github.repository == 'lacymorrow/lash'
bun install @opentui/core@catalog: resolves to 0.4.5, confirming the build-lash.ts install line works

Still unverified by nature: the real end-to-end run. That needs the credential, see below.

What Lacy needs to do

  1. Confirm or replace NPM_TOKEN. A secret by that name exists on the repo from 2025-09-06, but the token in ~/.npmrc returns 401, so it is likely the same dead classic token. It must be a granular automation token for lashcode with read and write permission, not a classic one. Run release lash with dry_run ticked: it either confirms the token in two minutes or prints exactly what to fix.
  2. Add HOMEBREW_TAP_TOKEN. The tap push goes to lacymorrow/homebrew-tap, which is cross-repo, so Actions' GITHUB_TOKEN cannot reach it. Without this the release and the npm publish still succeed and the run ends red on the brew step alone.

Then a real run should produce 1.7.15 on npm, a GitHub release with all eleven archives, and an updated lash.rb.

Issue: LAC-4453. Parent: LAC-2396, open since July for this reason.

🤖 Generated with Claude Code

buildandserve and others added 3 commits October 14, 2026 17:56
…-4453)

Every lash release was a manual run on a laptop, and the npm step could not be
done by an agent at all: publish-lash.ts read a 2FA code from stdin, and no
workflow in .github/workflows matched lacymorrow/lash, so none ever ran.

- Add .github/workflows/release-lash.yml. workflow_dispatch, gated to
  lacymorrow/lash, always builds the lash branch. One ubuntu runner, because
  build-lash.ts already cross-compiles all eleven targets, so a build matrix
  would add plumbing for nothing. A dry_run input checks the credential and the
  next version without publishing.
- Add npm-auth.ts. With NPM_TOKEN set, point npm at a 0600 userconfig holding
  the token and drop the OTP flag entirely; granular automation tokens bypass
  2FA. Without a token, the interactive prompt stays as the local fallback.
  verifyNpmAuth runs before the build so a dead token costs two seconds rather
  than a full build followed by a 401.
- Add release-version.ts plus tests. Patch-bump whichever of npm and the newest
  GitHub release is ahead, then skip versions whose tag already exists. v1.7.14
  was tagged by a run that died before publishing, so reusing it would have
  shipped an older commit than HEAD.
- release-lash.ts: refuse to run off the lash branch rather than inferring
  intent from the preview channel, fail instead of warning when no archives
  were built, reuse a release a previous run left half finished, append to
  GITHUB_OUTPUT instead of truncating it, and publish to npm before the
  homebrew tap so a missing cross-repo token cannot block the npm publish.
- Drop the awk version bump from the root release script so the version has one
  definition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r in tmp

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs that only show up on a runner, both found by re-reading rather than
by a failing test, because nothing exercises this path until a real release.

- updateRegistries resolves the archives as ./dist/lashcode-*.tar.gz, but
  release-lash.ts chdir's back to the repo root before calling it, so every
  sha256sum looked in <root>/dist instead of packages/opencode/dist and found
  nothing. This predates the current change: the homebrew step could not have
  succeeded when driven from release-lash.ts. Run it from packages/opencode and
  restore the cwd either way.
- The workflow set the git identity at repo level, but the tap is a second
  clone, so its "Update to v<version>" commit would fail on an unknown author.
  Use --global.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lacymorrow

Copy link
Copy Markdown
Owner Author

Pushed two more fixes after re-reading the CI path. Both are bugs that only appear on a runner, so no test caught them.

1. The homebrew step could never have worked when driven from release-lash.ts. updateRegistries resolves archives as ./dist/lashcode-linux-x64.tar.gz, but release-lash.ts chdir's back to the repo root before calling it, so every sha256sum looked in <root>/dist instead of packages/opencode/dist. This predates this PR. Fixed by running it from packages/opencode and restoring the cwd on both the success and failure paths.

2. The tap commit would have failed on an unknown author. The workflow set git config user.name/email at repo level, but the homebrew tap is a second clone, so a repo-level identity does not reach it. Changed to --global.

CI note

e2e (windows) failed in its Setup Bun step, before any of this code runs. That is the documented non-required flake for this repo (see the e2e (windows) history on #46). This PR touches no Windows path and no dependency. typecheck, nix-eval, check-standards and check-compliance are green.

One correction

In the board comment I claimed npm already has 1.7.9 through 1.7.11. It does not:

$ npm view lashcode versions
["0.0.12","1.7.6","1.7.7","1.7.8","1.7.12","1.7.13"]

The original issue was right. What is true is narrower: the newest npm version matches the newest GitHub release, so "npm matches latest tag" reads green while three intermediate versions are missing. Nothing in this PR changes; backfilling old patches of a fork is not worth doing.

getLatestRelease hits api.github.com unauthenticated, which Actions runners
rate limit because they share outbound IPs. It was called twice: once guarded
inside resolveVersion, once unguarded for the changelog range, so a 403 on the
second call killed the release after the credential check had passed.

Fetch it once, reuse it for both, and treat a miss as unknown: the version falls
back to npm alone and the changelog is skipped with a warning rather than
throwing. The branch guard still runs before any network call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lacymorrow

Copy link
Copy Markdown
Owner Author

Code review: changes requested

Posted as a comment because gh is authenticated as the PR author and GitHub refuses a formal request-changes review on your own PR. Treat this as the verdict: do not merge until item 1 is fixed.

Reviewed at bb9eae43d7 (the two commits pushed after the PR body was written are included). I did not re-run the checks listed in the PR body. Everything below is either a path those checks do not cover or something I verified by hand.

Blocking

1. verifyNpmAuth() does not fail closed, and the no-token path strands a release

packages/opencode/script/npm-auth.ts:40-44, called from script/release-lash.ts:59.

A missing, renamed, or emptied NPM_TOKEN secret interpolates to "" in the workflow, which is falsy, so verifyNpmAuth logs no NPM_TOKEN set, publish will prompt for an OTP and returns without throwing. The run then goes on to bump every package.json, commit, push the tag, and create the GitHub release (script/release-lash.ts:113-143) before it ever reaches npm. Only at :153 does publishPackages discover there is no token and drop into the interactive branch (packages/opencode/script/publish-lash.ts:170-187).

I ran that readline block with stdin on /dev/null, which is what a step gets in Actions:

$ bun /tmp/otptest.ts < /dev/null
[killed after 8s: HUNG]

The promise never resolves. rl.on("line") never fires on a closed stdin and there is no close handler, so the job sits there until timeout-minutes: 90 and ends red with the tag pushed, the release created, and nothing on npm. That is the exact v1.7.14 state this PR exists to clear, re-created by the automation. A dead token fails correctly; it is only the absent token that falls through.

Suggested fix, which keeps item 5 of the brief working (a local TTY run with no token still prompts):

export async function verifyNpmAuth() {
  if (!npmToken) {
    if (process.env["CI"] || !process.stdin.isTTY) {
      throw new Error(["NPM_TOKEN is required for a non-interactive release.", TOKEN_HELP].join("\n"))
    }
    console.log("npm auth: no NPM_TOKEN set, publish will prompt for an OTP")
    return
  }
  ...

The same guard at the top of publishPackages' prompt loop would make the hang unreachable even if some other caller skips verification.

Should fix in this PR

2. The rate-limit fix degrades the release instead of removing the limit

script/release-lash.ts:41-47 and :69-71, new in bb9eae43d7.

getLatestRelease() (script/changelog-lash.ts:20-27) uses an unauthenticated fetch to api.github.com, which is the 60/hr budget being exhausted. The new handling turns that miss into a release that ships with notes No notable changes behind a console.warn, which nobody reads in a green run.

The workflow already exports GH_TOKEN, and getCommits directly below already goes through gh api. Reading the release the same way, gh api repos/lacymorrow/lash/releases/latest --jq .tag_name, gets the authenticated budget and makes the fallback almost unreachable rather than papering over it.

Version selection is fine either way, since the local tag set is the real guard, so this is only about not publishing an empty changelog.

3. Version selection reads local tags before the fetch

script/release-lash.ts:30 reads git tag --list, but git fetch origin is at :125, after the build. In CI fetch-depth: 0 makes this safe. On a laptop with stale tags it will pick a version that is already tagged on origin, spend fifteen minutes building, then die on the --force-with-lease push at :126.

Moving a git fetch origin --tags --force above the resolveVersion() call at :49 makes the guard trustworthy in both environments.

Hardening, not blocking

4. The temp npmrc is world-readable for a moment, and the path is guessable

packages/opencode/script/npm-auth.ts:27-29. Three small things stack up:

  • the path is os.tmpdir()/lash-npmrc-<pid>, which is predictable
  • Bun.write creates it 0644 and follows a pre-existing symlink. I verified both: Bun.write default mode: 644, symlink followed: true
  • the chmod 600 that corrects the mode is .nothrow().quiet(), so if it fails the file stays 0644 and nothing says so

On a GitHub-hosted runner /tmp is single-tenant, so this is not exploitable today. On a self-hosted runner or a shared Linux box it is. mkdtempSync plus writeFileSync(file, body, { mode: 0o600, flag: "wx" }) closes all three and removes the $ call.

Separately, process.once("exit") at :33 does not run on SIGINT, so a Ctrl-C on a local run leaves the token in tmp, which is the case the comment above it is about. Add a SIGINT handler.

5. A non-numeric patch silently becomes 0

packages/opencode/script/release-version.ts:30. Number(part) || 0 means a base of 1.7.14-rc1 produces candidate 1.7.1 and walks up from there instead of failing. The repo already carries a v1.2.22-rc1 tag, so the shape is not hypothetical, and published comes from npm's latest dist-tag, which can point at a prerelease. A /^\d+\.\d+\.\d+$/ check on base with an explicit throw is cheaper than reasoning about whether the tag loop rescues it.

6. Note on pre-existing code, outside the diff

updateRegistries embeds the token in the clone URL, so dist/homebrew-tap/.git/config keeps it after a local run, and git prints the full URL when a push fails. Actions masks the secret, so CI is covered; a laptop run is not. This PR does not touch that function, it only newly routes a PAT into it. Happy to file it separately per Rule 19, just say so.

Verified good

  • branch guard runs before any mutation (:36-39)
  • dry run exits before the tag, the release and npm (:63-67), and === "true" matches what Actions passes for a type: boolean input
  • GITHUB_OUTPUT is appended once with appendFile (:13-17, :149), no truncation and no double write
  • tag-reuse guard uses git rev-list -n 1, so annotated tags resolve to a commit and a rerun on the same commit is idempotent (:117-123)
  • npm at :153 publishes before homebrew at :161, and the chdir into packages/opencode added in 654b012b18 is right, since updateRegistries reads ./dist/*.tar.gz relatively
  • gh release upload --clobber rerun path, guarded by the tag check above it
  • job-level if: github.repository == 'lacymorrow/lash' and ref: lash
  • DEFER_PUBLISH_TASKS keeps publish-lash.ts' own top-level block from publishing a second time, and publishQueue holds absolute paths, so the root chdir at :107 does not break it
  • an empty otpArg interpolates to zero argv entries in Bun's $, tested, so the token path genuinely drops --otp rather than passing an empty argument
  • nextVersion returns 1.7.15 against today's real state: npm 1.7.13, newest release v1.7.13, v1.7.14 tagged at be272f67
  • the f43a0e5f checkout pin really is v3.6.0, and matches 10 other workflows here
  • the inline git config --global is correct rather than reusing .github/actions/setup-git-committer, which requires app secrets this workflow does not have

CI: typecheck, nix-eval, check-standards and check-compliance pass; unit and e2e were still running when I posted this.

@lacymorrow

Copy link
Copy Markdown
Owner Author

Follow-up: answers to the three checks, plus one more finding

1. Is the cwd restored on every exit path?

Yes. script/release-lash.ts:159-168 restores on success at :162 and on failure at :164 before the rethrow. The third path is also covered: if process.chdir(path.join(dir, "packages", "opencode")) at :160 is itself what throws, the catch restores a cwd that never moved, which is a no-op. Nothing after the block reads the cwd, so this is tidiness rather than correctness, but it is complete. A finally would say the same thing in one place instead of two.

2. Can the degraded path produce a bad version?

No, and the reason is worth writing down because it is the whole safety argument.

With released undefined, the base is npm alone. The case that matters is a GitHub release ahead of npm, which is exactly the half-finished state this PR exists to clean up: a run that pushed the tag and created the release, then died at npm. Say the release is v1.7.20 and npm is still 1.7.13. Base becomes 1.7.13, candidate 1.7.14, and the loop then walks the tag set to 1.7.21. Correct.

That works because release-lash.ts:126 pushes the tag before :137-143 creates the release, so a GitHub release can never exist without its tag, and fetch-depth: 0 means every tag is local. The tag set, not the two version lookups, is what prevents a regression.

Which is the reason finding 3 in my first pass matters more than it looks: resolveVersion reads git tag --list at :30 while git fetch origin is at :125. In CI the checkout already fetched the tags, so the invariant holds. On a laptop with stale tags the guard is reading an out-of-date tag set, and that is the one environment where the degraded path can pick a version that is already tagged on origin. Fetching before version selection makes the argument above true everywhere rather than only in CI.

3. Does OPENCODE_VERSION short-circuit before the npm fetch?

Yes for npm. script/release-lash.ts:21-24 returns the override before the registry.npmjs.org request.

Not for GitHub: getLatestRelease() at :44 now runs unconditionally, before resolveVersion(previous) is called. That is correct, since :70 needs previous for the changelog whether or not the version was overridden, but it does mean an override does not avoid the rate-limited call. An override plus a rate limit gives you the version you asked for and an empty changelog, which is the same degradation as finding 2.

New finding: a leading v in the version input is not rejected

script/release-lash.ts:21-22 returns OPENCODE_VERSION verbatim. The workflow input says "without the leading v" and nothing enforces it, so dispatching with v1.8.0 sets Script.version to v1.8.0 and the run goes on to:

  • write "version": "v1.8.0" into every package.json (:82)
  • tag and push vv1.8.0 (:109, :120, :126)
  • create a GitHub release named vv1.8.0
  • emit a homebrew formula with version "v1.8.0" and releases/download/vv1.8.0/ URLs

node-semver treats v1.8.0 as valid and normalizes it to 1.8.0, so npm probably accepts the publish rather than failing loudly, which is the bad outcome: npm ends up at 1.8.0 while the tag is vv1.8.0, and every later run's tags.has(\v${version}`)check looks forv1.8.0`, which does not exist. The next release then happily reuses a version npm already has.

Two lines in resolveVersion close it:

const override = process.env["OPENCODE_VERSION"]
if (override) {
  if (!/^\d+\.\d+\.\d+$/.test(override)) throw new Error(`OPENCODE_VERSION must be x.y.z without a leading v, got ${override}`)
  return override
}

This is the same check finding 5 asks for on the derived base, so one validator covers both.

On CI: typecheck, nix-eval, check-standards and check-compliance are green. unit (linux), unit (windows), e2e (linux) and e2e (windows) are still running on the current head; I have not seen the release-version.test.ts result in CI yet, only the local run recorded in the PR body.

@lacymorrow

Copy link
Copy Markdown
Owner Author

CI triage at bb9eae43d7 (run now complete)

Green: unit (linux), unit (windows), typecheck, nix-eval, check-standards, check-compliance. Both unit jobs passing is the confirmation that release-version.test.ts passes in CI and not just locally.

Red: e2e (linux) and e2e (windows). Neither is caused by this PR, and neither should block the merge.

  • e2e (linux): 2 failed / 4 flaky / 100 passed. The two hard failures are packages/app/e2e/regression/legacy-new-session.spec.ts:9 and session-timeline-projection.spec.ts:131 — the identical pair that fails on base lash at 16e3ca300b. Both specs are upstream-authored (4a181c3574, b4e49d5b32), git log origin/lash..HEAD -- <spec> is empty for each, and this PR touches no packages/app file at all. Tracked in LAC-4467.
  • e2e (windows): 2 failed / 5 flaky / 99 passed, in Run app e2e tests. Worth noting it did not fail in Setup Bun on the completed run, so the documented Setup-Bun flake does not explain this one — but it is the same upstream packages/app spec family tracked in LAC-4138.

Review verdict is unchanged: still blocked on the verifyNpmAuth() fail-closed hole (first review, item 1).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants