Skip to content
Open
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
70 changes: 70 additions & 0 deletions .github/workflows/release-lash.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: release lash
run-name: "release lash ${{ inputs.version && format('v{0}', inputs.version) || '(patch bump)' }}${{ inputs.dry_run && ' [dry run]' || '' }}"

# Lash releases only. Everything inherited from upstream is gated to sst/opencode or
# anomalyco/opencode and never runs here, which is why lashcode sat on npm 1.7.13 from
# 2026-05-05 while GitHub releases moved on. See LAC-4453.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, without the leading v. Leave blank to patch bump."
required: false
type: string
dry_run:
description: "Check the npm credential and the next version, then stop without publishing."
required: false
default: false
type: boolean

concurrency: ${{ github.workflow }}

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-24.04
if: github.repository == 'lacymorrow/lash'
# build-lash.ts cross-compiles all eleven targets on this one runner, so no build matrix.
timeout-minutes: 90
steps:
# Releases are cut from lash, so pin the ref rather than trusting the dispatch branch.
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
ref: lash
fetch-depth: 0 # full history and tags: the changelog and version bump both need them

- uses: ./.github/actions/setup-bun

# --global, because the homebrew tap is a second clone and a repo-level identity would not
# reach it. Without this its "Update to v<version>" commit fails on an unknown author.
- name: Setup git committer
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Release
id: release
run: bun run release
env:
OPENCODE_VERSION: ${{ inputs.version }}
LASH_RELEASE_DRY_RUN: ${{ inputs.dry_run }}
# Granular automation token for lashcode. Bypasses 2FA, so no OTP prompt.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: false
# gh needs the repo token; the homebrew tap push is cross-repo and needs its own.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN || secrets.GITHUB_TOKEN }}

- name: Summary
if: always()
run: |
{
echo "### lash release"
echo
echo "- version: \`${{ steps.release.outputs.version || 'not reached' }}\`"
echo "- tag: \`${{ steps.release.outputs.tag || 'not reached' }}\`"
echo "- release: https://github.com/lacymorrow/lash/releases/tag/${{ steps.release.outputs.tag }}"
echo "- npm: https://www.npmjs.com/package/lashcode"
} >> "$GITHUB_STEP_SUMMARY"
70 changes: 43 additions & 27 deletions LASH_RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
# Lash Release Engineering

> `bun run release`

Lash uses a specialized release pipeline designed to exist alongside the upstream OpenCode project without modifying upstream source files. This ensures easier merges and cleaner separation of concerns.

## Release Scripts
## How to cut a release

The release process is driven by **Lash-specific scripts** that exist in parallel to the upstream scripts.
**From GitHub (preferred).** Actions → **release lash** → Run workflow. Leave the version blank for a
patch bump, or type one to ship a minor or a major. Nothing to type while it runs. The workflow is
`.github/workflows/release-lash.yml`, it is gated to `lacymorrow/lash`, and it always builds the
`lash` branch.

### 1. `script/release-lash.ts`
Tick **dry_run** first if you want to confirm the npm credential and the next version number without
publishing anything. That takes about two minutes; a real release takes closer to twenty.

**The Entry Point.**
**From a laptop.** On the `lash` branch:

1. **Run the Release Script**:
1. **Publish (with Build)**:
```bash
bun run release
```

```bash
bun run publish
```
With `NPM_TOKEN` exported this runs unattended. Without it, the script prompts for an npm OTP, which
is the fallback for publishing from an account with 2FA and no automation token.

This will build the artifacts and then **interactively prompt** for your NPM OTP to publish them.
Either way the run produces, in this order: a version bump commit and a `v<version>` tag, a GitHub
release carrying all eleven archives, the npm publish, and an updated `lash.rb` in
`lacymorrow/homebrew-tap`. A rerun after a partial failure picks up where it stopped rather than
duplicating work.

**Skip Build:** If you have already built artifacts (e.g. via `bun run build`) and just want to publish:
## Release Scripts

```bash
bun run publish --skip-build
```
The release process is driven by **Lash-specific scripts** that exist in parallel to the upstream scripts.

### 1. `script/release-lash.ts`

1. **NPM Publishing (Interactive)**:
- The script will pause and ask for your NPM 2FA/OTP code.
- Enter the code. The script will attempt to publish all packages.
- If the OTP expires, the script will pause and ask for a new code.
- You can Ctrl+C to skip remaining packages if needed.
**The Entry Point.** It refuses to run outside the `lash` branch, picks the version, checks the npm
credential before spending time on a build, then drives the steps listed above.

Version selection, when no version is given: patch-bump whichever of npm and the newest GitHub
release is further ahead, then skip past any version whose tag already exists. The logic lives in
`packages/opencode/script/release-version.ts` and is covered by
`packages/opencode/test/release-version.test.ts`.

### 2. `packages/opencode/script/publish-lash.ts`

Expand All @@ -53,21 +60,28 @@ A dedicated build script that outputs `lash` binaries directly, removing the nee
- **`script/changelog-lash.ts`**: Fetches commits/releases from `lacymorrow/lash` instead of upstream.
- **`packages/opencode/script/postinstall-lash.mjs`**: A modified postinstall script that creates the correct symlinks for `lash` binaries.

## Prerequisites
## Credentials

| Name | Where it lives | What it is for |
| --- | --- | --- |
| `NPM_TOKEN` | repo secret on `lacymorrow/lash` | Publishing `lashcode`. Must be a **granular automation token** scoped to the package with read and write permission. Automation tokens bypass 2FA, which is what lets the workflow publish without a human typing an OTP. Classic tokens on a 2FA account will not work. |
| `GITHUB_TOKEN` | provided by Actions | Pushing the tag and creating the release. |
| `HOMEBREW_TAP_TOKEN` | repo secret on `lacymorrow/lash` | Pushing `lash.rb` to `lacymorrow/homebrew-tap`. That push is cross-repo, so the token Actions provides is not enough. |

To run a release, you need:
A dead or missing `NPM_TOKEN` fails the run in the first couple of minutes with the exact command
needed to replace it, rather than after a full build.

- **Bun** (v1.3.x+)
- **NPM Access** to `lashcode` package.
- **GitHub Token** with permissions for `lacymorrow/lash` and `lacymorrow/homebrew-tap`.
- **AUR Key** (optional, for Arch Linux releases).
Local runs also need **Bun** (v1.3.x+) and a `gh` login with access to `lacymorrow/lash` and
`lacymorrow/homebrew-tap`. An **AUR key** is optional and currently unused.

## Directory Structure

Items marked with `(*)` are Lash-specific additions.

```text
root/
├── .github/workflows/
│ └── release-lash.yml (*) # workflow_dispatch entry point
├── script/
│ ├── release-lash.ts (*) # MAIN ENTRY POINT
│ ├── changelog-lash.ts (*) # Lash history logic
Expand All @@ -76,6 +90,8 @@ root/
└── opencode/
└── script/
├── publish-lash.ts (*) # Build/Rename/Publish logic
├── release-version.ts (*) # Which version to ship next
├── npm-auth.ts (*) # NPM_TOKEN handling, OTP fallback
├── postinstall-lash.mjs (*) # Runtime binary finder
├── build.ts # Upstream builder (used by us)
└── publish.ts # Upstream publisher (ignored)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"release": "OPENCODE_CHANNEL=latest OPENCODE_VERSION=$(npm view lashcode version 2>/dev/null | awk -F. '{print $1\".\"$2\".\"$3+1}') bun run script/release-lash.ts",
"release": "OPENCODE_CHANNEL=latest bun run script/release-lash.ts",
"sso": "aws sso login --sso-session=opencode --no-browser",
"translate:app": "bun run script/translate-app.ts",
"test": "echo 'do not run tests from root' && exit 1"
Expand Down
53 changes: 53 additions & 0 deletions packages/opencode/script/npm-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bun

import { $ } from "bun"
import { rmSync } from "fs"
import os from "os"
import path from "path"

const REGISTRY = "registry.npmjs.org"
const TOKEN_HELP = [
"Create a granular access token for 'lashcode' at https://www.npmjs.com/settings/lacymorrow/tokens",
"with read and write permission on the package, then store it:",
" gh secret set NPM_TOKEN --repo lacymorrow/lash",
"Granular automation tokens bypass 2FA on publish, which is what lets a release run unattended.",
].join("\n")

export const npmToken = process.env["NPM_TOKEN"] || process.env["NODE_AUTH_TOKEN"]

let userconfig: string | undefined

/**
* Point npm at a throwaway userconfig holding the token so `npm publish` authenticates without
* an OTP. Without a token we leave npm's own config alone and the caller falls back to prompting.
*/
export async function configureNpmAuth() {
if (!npmToken) return
if (userconfig) return
const file = path.join(os.tmpdir(), `lash-npmrc-${process.pid}`)
await Bun.write(file, `//${REGISTRY}/:_authToken=${npmToken}\n`)
await $`chmod 600 ${file}`.nothrow().quiet()
process.env["npm_config_userconfig"] = file
userconfig = file
// A CI runner is thrown away, but a laptop is not: don't leave the token sitting in tmp.
process.once("exit", () => rmSync(file, { force: true }))
}

/**
* Check the token against the registry up front. A dead token should cost two seconds, not a
* fifteen minute build followed by a 401.
*/
export async function verifyNpmAuth() {
if (!npmToken) {
console.log("npm auth: no NPM_TOKEN set, publish will prompt for an OTP")
return
}
await configureNpmAuth()
const whoami = await $`npm whoami`.nothrow().quiet()
if (whoami.exitCode !== 0) {
throw new Error(
["NPM_TOKEN was rejected by the registry.", whoami.stderr.toString().trim(), "", TOKEN_HELP].join("\n"),
)
}
console.log(`npm auth: authenticated as ${whoami.stdout.toString().trim()} via NPM_TOKEN`)
}
Loading
Loading