Skip to content

Commit 4b30957

Browse files
committed
fix: harden fallback handling and trusted release flow
1 parent 50c42ca commit 4b30957

19 files changed

+656
-79
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ on:
77
jobs:
88
validate:
99
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
1012

1113
steps:
1214
- name: Checkout
1315
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
16+
with:
17+
persist-credentials: false
1418

1519
- name: Setup Bun
1620
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -21,12 +25,12 @@ jobs:
2125
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
2226
with:
2327
path: ~/.bun/install/cache
24-
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
28+
key: ${{ runner.os }}-bun-ci-${{ hashFiles('bun.lock') }}
2529
restore-keys: |
26-
${{ runner.os }}-bun-
30+
${{ runner.os }}-bun-ci-
2731
2832
- name: Install dependencies
29-
run: bun install
33+
run: bun install --frozen-lockfile
3034

3135
- name: Lint
3236
run: bun run lint

.github/workflows/release-gate.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Release Gate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
jobs:
8+
validate:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
16+
with:
17+
fetch-depth: 0
18+
persist-credentials: false
19+
20+
- name: Setup Bun
21+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
22+
with:
23+
bun-version: latest
24+
25+
- name: Cache Bun packages
26+
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
27+
with:
28+
path: ~/.bun/install/cache
29+
key: ${{ runner.os }}-bun-release-gate-${{ hashFiles('bun.lock') }}
30+
restore-keys: |
31+
${{ runner.os }}-bun-release-gate-
32+
33+
- name: Install dependencies
34+
run: bun install --frozen-lockfile
35+
36+
- name: Lint
37+
run: bun run lint
38+
39+
- name: Test
40+
run: bun test
41+
42+
- name: Type check
43+
run: bunx tsc --noEmit
44+
45+
- name: Build
46+
run: bun run build

.github/workflows/release.yml

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ name: Release
22

33
on:
44
workflow_run:
5-
workflows: ["CI"]
5+
workflows: ["Release Gate"]
66
types: [completed]
77

8+
concurrency:
9+
group: release-main
10+
cancel-in-progress: false
11+
812
jobs:
913
release:
10-
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
14+
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_sha == github.sha }}
1115
runs-on: ubuntu-latest
1216
permissions:
1317
contents: write
@@ -19,43 +23,66 @@ jobs:
1923
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
2024
with:
2125
fetch-depth: 0
22-
ref: ${{ github.event.workflow_run.head_sha }}
23-
24-
- name: Setup Node.js
25-
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
26-
with:
27-
node-version: 24
26+
persist-credentials: false
2827

2928
- name: Setup Bun
3029
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
3130
with:
3231
bun-version: latest
3332

33+
- name: Setup Node.js
34+
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
35+
with:
36+
node-version: 24
37+
3438
- name: Cache Bun packages
3539
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
3640
with:
3741
path: ~/.bun/install/cache
38-
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
42+
key: ${{ runner.os }}-bun-release-${{ hashFiles('bun.lock') }}
3943
restore-keys: |
40-
${{ runner.os }}-bun-
44+
${{ runner.os }}-bun-release-
4145
4246
- name: Install dependencies
43-
run: bun install
47+
run: bun install --frozen-lockfile
48+
49+
- name: Lint
50+
run: bun run lint
4451

4552
- name: Build
4653
run: bun run build
4754

4855
- name: Import GPG key
49-
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6
50-
with:
51-
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
52-
passphrase: ${{ secrets.GPG_PASSPHRASE }}
53-
git_user_signingkey: true
54-
git_commit_gpgsign: true
55-
git_tag_gpgsign: true
56+
env:
57+
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
58+
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
59+
run: |
60+
set -euo pipefail
61+
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
62+
KEYGRIP=$(gpg --with-keygrip --list-secret-keys --with-colons \
63+
| awk -F: '/^grp/{print $10; exit}')
64+
if [ -z "$KEYGRIP" ]; then
65+
echo "Unable to determine GPG keygrip" >&2
66+
exit 1
67+
fi
68+
echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
69+
gpgconf --kill gpg-agent
70+
gpg-connect-agent reloadagent /bye
71+
"$(gpgconf --list-dirs libexecdir)/gpg-preset-passphrase" \
72+
--preset -P "$GPG_PASSPHRASE" "$KEYGRIP"
73+
KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')
74+
if [ -z "$KEY_ID" ]; then
75+
echo "Unable to determine imported GPG key id" >&2
76+
exit 1
77+
fi
78+
git config --global user.signingkey "$KEY_ID"
79+
git config --global commit.gpgsign true
80+
git config --global tag.gpgsign true
5681
57-
- name: Set GPG_TTY
58-
run: echo "GPG_TTY=$(tty)" >> $GITHUB_ENV
82+
- name: Configure git auth for release
83+
run: git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
84+
env:
85+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5986

6087
- name: Release
6188
run: bun run release

AGENTS.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ OpenCode plugin that adds ordered model fallback chains with a health state mach
99
## Commands
1010

1111
```bash
12-
bun test # Run all unit tests (145 tests across 11 files)
12+
bun test # Run all unit tests (163 tests across 16 files)
1313
bunx tsc --noEmit # Type check
1414
bun run build # Build to dist/
1515
```
@@ -34,7 +34,7 @@ After each successful implementation cycle (feature, fix, refactor), execute all
3434

3535
```
3636
src/
37-
plugin.ts # Entry point — event router + chat.message hook (preemptive redirect)
37+
plugin.ts # Entry point — event router + chat.message hook + /fallback-status command bootstrap
3838
preemptive.ts # Sync preemptive redirect logic for chat.message hook
3939
types.ts # Shared type definitions
4040
config/ # Zod schema, file discovery, defaults, auto-migration
@@ -114,16 +114,18 @@ Commits and tags are signed with GPG key `60BFBD78D728EEE4`.
114114

115115
Fetches the passphrase via `op environment read opencode-env` (1Password Environments) and presets it into `gpg-agent` so subsequent `git commit`/`git tag` calls don't prompt. Env var overrides:
116116

117-
| Variable | Default | Purpose |
118-
|---|---|---|
119-
| `GPG_SIGN_KEY` | `60BFBD78D728EEE4` | Key ID to unlock |
120-
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_ENV_ID` | `opencode-env` | 1Password environment name |
121-
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_ACCOUNT` | _(CLI default)_ | 1Password account |
122-
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_VAR` | `OPENCODE_MANIFEST_SIGN_PASSPHRASE` | Variable name in the env |
117+
| Variable | Default | Purpose |
118+
| ------------------------------------------ | ----------------------------------- | -------------------------- |
119+
| `GPG_SIGN_KEY` | `60BFBD78D728EEE4` | Key ID to unlock |
120+
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_ENV_ID` | `opencode-env` | 1Password environment name |
121+
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_ACCOUNT` | _(CLI default)_ | 1Password account |
122+
| `OPENCODE_MANIFEST_SIGN_1PASSWORD_VAR` | `OPENCODE_MANIFEST_SIGN_PASSPHRASE` | Variable name in the env |
123123

124124
**CI — GitHub Actions:**
125125

126-
`release.yml` uses `crazy-max/ghaction-import-gpg` with `secrets.GPG_PRIVATE_KEY` and `secrets.GPG_PASSPHRASE` stored as org-level GitHub secrets. The CI key is a dedicated key separate from the personal signing key — rotate it independently without touching local config. No 1Password service account is needed in CI.
126+
`release-gate.yml` runs trusted validation on `push` to `main`. `release.yml` is a privileged `workflow_run` that fires only after `Release Gate` succeeds for a `push` on `main`.
127+
128+
`release.yml` imports the CI GPG key from `secrets.GPG_PRIVATE_KEY`, presets the passphrase from `secrets.GPG_PASSPHRASE`, and enables commit/tag signing before `semantic-release`. The CI key is a dedicated key separate from the personal signing key — rotate it independently without touching local config. No 1Password service account is needed in CI.
127129

128130
## Testing
129131

@@ -133,6 +135,8 @@ Integration tests for the replay orchestrator and full fallback flow exist in `t
133135

134136
Plugin event-handler hardening tests are in `test/plugin.test.ts`. `/fallback-status` tool output tests are in `test/fallback-status.test.ts`.
135137

138+
Additional coverage includes startup command bootstrap (`test/plugin-create.test.ts`), logger redaction/fault-tolerance (`test/logger.test.ts`), fallback usage aggregation (`test/usage.test.ts`), and health-store timer lifecycle (`test/model-health-lifecycle.test.ts`).
139+
136140
## Config
137141

138142
Plugin reads `model-fallback.json` from:

Implementation.plan.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ opencode-model-fallback/
3434
├── .github/
3535
│ ├── workflows/
3636
│ │ ├── ci.yml # CI quality gates (lint, test, typecheck, build)
37-
│ │ └── release.yml # semantic-release pipeline on main
37+
│ │ ├── release-gate.yml # Trusted push-to-main validation gate for releases
38+
│ │ └── release.yml # Privileged semantic-release workflow_run (gated by Release Gate)
3839
│ └── dependabot.yml # Weekly dependency updates
3940
├── src/
40-
│ ├── plugin.ts # Plugin entry point — event router + chat.message hook
41+
│ ├── plugin.ts # Plugin entry point — event router + chat.message hook + command bootstrap
4142
│ ├── preemptive.ts # Sync preemptive redirect logic for chat.message hook
4243
│ ├── types.ts # Shared type definitions
4344
│ ├── config/
@@ -77,6 +78,12 @@ opencode-model-fallback/
7778
│ ├── plugin.test.ts # ✓ Event handler hardening (malformed payloads, recovery toast dedupe)
7879
│ ├── fallback-status.test.ts # ✓ Tool output with partially seeded session state
7980
│ ├── agent-loader.test.ts # ✓ Agent file parsing, frontmatter, overrides
81+
│ ├── notifier.test.ts # ✓ Notification message rendering and labels
82+
│ ├── plugin-create.test.ts # ✓ Startup command-file bootstrap and write-failure handling
83+
│ ├── logger.test.ts # ✓ Redaction and logging fault tolerance
84+
│ ├── usage.test.ts # ✓ Usage aggregation and fallback-period boundaries
85+
│ ├── health-tick.test.ts # ✓ Tick-driven transition and callback behavior
86+
│ ├── model-health-lifecycle.test.ts # ✓ Timer unref + destroy lifecycle
8087
│ └── helpers/
8188
│ └── mock-client.ts # Mock OpenCode client for integration tests
8289
└── examples/
@@ -315,7 +322,7 @@ Addresses two problems: wasted 429 round-trips per message after a successful fa
315322

316323
## Verification Plan
317324

318-
1. **Unit tests** (per module): config validation, pattern matching, classification, health transitions, chain resolution, message conversion, agent loader, preemptive redirect, plugin events, fallback-status tool, tick recovery transitions, path traversal security, YAML schema enforcement — **145/145 passing**
325+
1. **Unit tests** (per module): config validation, pattern matching, classification, health transitions, chain resolution, message conversion, agent loader, preemptive redirect, plugin events, plugin startup bootstrap, logger redaction/fault tolerance, usage aggregation, fallback-status tool, tick recovery transitions, health timer lifecycle, path traversal security, YAML schema enforcement — **163/163 passing**
319326
2. **Integration tests** (mock client): full fallback flow, cascading, max depth, concurrent events, session deletion — **complete**
320327
3. **Manual E2E test**: Install as local plugin, configure fallback chains, trigger rate limit, verify:
321328
- Detection logged correctly

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ OpenCode plugin that adds automatic model fallback when your primary model hits
55
## How it works
66

77
1. **Preemptive redirect** — intercepts outgoing messages via `chat.message` hook; if the target model is known to be rate-limited, redirects the message to a healthy fallback _before_ it hits the provider (no 429 round-trip)
8-
2. **Reactive fallback** — if a 429 still occurs (first hit, or preemptive not available), listens for `session.status: retry` events, aborts the retry loop, reverts the failed message, and replays it with the next healthy fallback model
8+
2. **Reactive fallback** — if a fallback-triggering error still occurs (first hit, or preemptive not available), listens for both `session.status: retry` and `session.error` (`APIError`) events, aborts the retry loop, reverts the failed message, and replays it with the next healthy fallback model
99
3. Shows an inline toast notification and logs the event
1010
4. Tracks model health globally (rate limits are account-wide) — automatically recovers after configurable cooldown periods
1111
5. **Depth reset** — when the TUI reverts to the original model between messages, `fallbackDepth` resets so `maxFallbackDepth` only guards true cascading failures within a single message
@@ -176,6 +176,8 @@ With the `verbose` flag:
176176

177177
Includes token/cost breakdown per model period.
178178

179+
When enabled, the plugin auto-creates `~/.config/opencode/commands/fallback-status.md` at startup so the slash command is available without manual setup.
180+
179181
## Health state machine
180182

181183
```
@@ -187,14 +189,17 @@ cooldown ──[retryOriginalAfterMs elapsed]──→ healthy
187189
- **healthy** — model is usable; preferred for fallback selection
188190
- **rate_limited** — recently hit a limit; skipped when walking fallback chain
189191
- **cooldown** — cooling off; used as last resort if no healthy model is available
190-
- State transitions are checked every 30 seconds via a background timer
192+
- State transitions are checked every 30 seconds via a background timer (the timer is unref'ed so it does not keep the process alive)
191193
- When the original model recovers to healthy, a toast appears on the next `session.idle`
192194

193195
## Troubleshooting
194196

195197
**Toast doesn't appear**
196198
The TUI notification requires an active OpenCode TUI session. Headless/API usage won't show toasts but logs are always written.
197199

200+
**`/fallback-status` command is missing**
201+
The plugin writes `~/.config/opencode/commands/fallback-status.md` on startup. If the command does not appear, verify the directory is writable and check for `fallback-status.command.write.failed` in OpenCode logs.
202+
198203
**"no fallback chain configured"**
199204
Your `model-fallback.json` has no `agents["*"].fallbackModels` (or no entry for the active agent). Add at least a wildcard entry with one model.
200205

@@ -214,11 +219,14 @@ Key log events: `plugin.init`, `retry.detected`, `fallback.success`, `fallback.e
214219

215220
To see the full event stream (including `event.received` and `retry.nomatch`), set `"logLevel": "debug"` in your config and restart OpenCode.
216221

222+
For safety, free-form provider error text is redacted in plugin logs; use category/model/session fields for diagnosis.
223+
217224
## Release automation
218225

219226
- Uses **Conventional Commits** + `semantic-release` for automated versioning/changelog/release notes
220227
- CI runs lint, tests, type check, and build on every push/PR via `.github/workflows/ci.yml`
221-
- Release workflow runs on `main` after successful CI via `.github/workflows/release.yml`
228+
- Trusted release gate runs on pushes to `main` via `.github/workflows/release-gate.yml`
229+
- Release workflow (`.github/workflows/release.yml`) runs on successful `Release Gate` completion (`workflow_run`), only for `push` events on `main`, and only when `head_sha` matches `github.sha`
222230
- Published as `@smart-coders-hq/opencode-model-fallback`
223231
- To publish to npm, set repository secret `NPM_TOKEN`
224232

@@ -227,7 +235,7 @@ To see the full event stream (including `event.received` and `retry.nomatch`), s
227235
```bash
228236
bun install
229237
bun run lint # lint checks
230-
bun test # 145 tests across 11 files
238+
bun test # 163 tests across 16 files
231239
bunx tsc --noEmit # type check
232240
bun run build # build to dist/
233241
```

0 commit comments

Comments
 (0)