diff --git a/.agents/skills/versioned-tool/SKILL.md b/.agents/skills/versioned-tool/SKILL.md
index b2cb32ce94..343179909a 100644
--- a/.agents/skills/versioned-tool/SKILL.md
+++ b/.agents/skills/versioned-tool/SKILL.md
@@ -20,7 +20,7 @@ This project manages external CLI tools through a **single-source-of-truth versi
| Systematic | `DEFAULT_SYSTEMATIC_VERSION` | `systematic-version` | `npm` (`@fro.bot/systematic`) |
| OpenCode (harness base) | `base_version` in `packages/harness/harness.config.json` | _(internal only — config-JSON, not a TS constant)_ | `github-releases` (`anomalyco/opencode`) |
-**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`); the Dockerfile ARG default stays this V1 harness build, and V2 is selected by build-arg instead. Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. **V2 pin:** the JSON file's `version` is Renovate-tracked, but its per-asset digests are not; after a bump, run `node scripts/sync-opencode-v2-pin.ts` and commit the result (see `deploy/README.md` § OpenCode V2). See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`.
+**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`); the Dockerfile ARG default stays this V1 harness build, and V2 is selected by build-arg instead. Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. **V2 pin:** Renovate bumps the JSON file's `version`; its per-asset digests are not Renovate-tracked, so the rule's `postUpgradeTasks` run `bun run postupgrade` (`scripts/sync-opencode-v2-pin.ts`) to regenerate them on the update branch. Any command added there must match the runner's `allowedCommands` (see `scripts/renovate-v2-pin-postupgrade.test.ts`; `node scripts/...` is not allowed). See `deploy/README.md` § OpenCode V2. See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`.
## Quick Start
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index daa1447a74..4521faee2e 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -85,8 +85,8 @@
{
// Tracks the stock OpenCode V2 pin. The Dockerfile's `opencode-v2` stage and the Action's
// installOpenCode both read this file. Bumping `version` alone leaves the per-asset sha256
- // digests stale, and both consumers fail closed on that: the digests are regenerated by
- // `scripts/sync-opencode-v2-pin.ts` (see the packageRules entry below).
+ // digests stale, and both consumers fail closed on that: the packageRules entry below regenerates
+ // the digests on the update branch with `bun run postupgrade` (scripts/sync-opencode-v2-pin.ts).
customType: 'regex',
managerFilePatterns: ['/packages\\/runtime\\/src\\/shared\\/opencode-v2-pin\\.json/'],
matchStrings: ['"version":\\s*"(?\\d+\\.\\d+\\.\\d+)"'],
@@ -164,16 +164,23 @@
// The V2 pin lives in its own file and follows its own major, so the V1 cap above does not
// apply to it. This entry must stay AFTER that cap (a later rule wins) and must not become the
// first `anomalyco/opencode` rule with `allowedVersions`: pin-invariants.test.ts reads the cap
- // from the first one. The pin PR cannot pass CI as Renovate opens it, because the per-asset
- // digests still describe the old version; prBodyNotes tells the reviewer how to fix that.
+ // from the first one.
+ //
+ // Renovate only changes `version`; the per-asset sha256 digests still describe the old release, and the
+ // Dockerfile and the Action both fail closed on that. `bun run postupgrade` (scripts/sync-opencode-v2-pin.ts)
+ // rewrites the digests from the release's SHA256SUMS on the update branch. A rule-level `postUpgradeTasks`
+ // REPLACES the top-level one rather than merging with it, so the full list is repeated here on purpose, with
+ // the sync between install and build. Every command must match the self-hosted runner's `allowedCommands`
+ // (pinned by scripts/renovate-v2-pin-postupgrade.test.ts); `node scripts/...` is not allowed there.
matchFileNames: ['packages/runtime/src/shared/opencode-v2-pin.json'],
matchPackageNames: ['anomalyco/opencode'],
allowedVersions: '/^2\\./',
commitMessageTopic: 'OpenCode V2 pin',
automerge: false,
- prBodyNotes: [
- 'This only bumps `version`. Regenerate the sha256 digests before merging: `node scripts/sync-opencode-v2-pin.ts`, then commit `packages/runtime/src/shared/opencode-v2-pin.json`. CI builds the V2 workspace image against the new pin.',
- ],
+ postUpgradeTasks: {
+ commands: ['bun install --ignore-scripts', 'bun run postupgrade', 'bun run build'],
+ executionMode: 'branch',
+ },
},
{
// Cap Bun at 1.4.4. The build gate requires an exact match with upstream
diff --git a/STRUCTURE.md b/STRUCTURE.md
index 04f0984d47..c7b7bff862 100644
--- a/STRUCTURE.md
+++ b/STRUCTURE.md
@@ -101,7 +101,7 @@ fro-bot/agent/
- **`deploy/plugins/`**, **`deploy/managed-config/`** — the baked OpenCode reuse-guard plugin (`no-task-reuse.mjs`) and the root-owned managed config (`/etc/opencode/opencode.json`) that loads it; plain Node ESM / JSON, tested from `deploy/scripts/*.test.mjs`. The same `no-task-reuse.mjs` is the single source for the Action's guard: `tsdown.config.ts` builds it into `dist/no-task-reuse.js`, and `src/services/setup/no-task-reuse-config.ts` copies it into the CI OpenCode config dir at every Action server start (`no-task-reuse.d.mts` types it for the Vitest suite).
- **`deploy/scripts/`** — Plain Node ESM (`.mjs`) helpers for deploy-time operations (auth validation, config merge, protected-dir creation, legacy checkout ownership migration, agent-side config provisioning, V2 plugin install and image smoke); uses `node --test`, not Vitest.
- **`deploy/tests/`** — `isolation-harness.sh`, the CI acceptance harness that proves the service/agent uid boundary in a real container started with production's security posture.
-- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch, and `sync-opencode-v2-pin.ts` (regenerates the OpenCode V2 pin's digests on a Renovate bump).
+- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch, and `sync-opencode-v2-pin.ts` (regenerates the OpenCode V2 pin's digests; Renovate runs it as `bun run postupgrade`).
- **`evals/`** — Gated agent-outcome eval corpus that runs the real execution path against disposable fixture repos; the pure gate and baseline tests run in normal CI, while live scenarios require `FRO_BOT_EVAL=1`.
- **`.github/workflows/`** — All CI/CD automation; 12 workflow files covering tests, releases, security scanning, and bot triggers.
- **`RFCs/`** — 19 architecture specification documents; read before making cross-cutting changes.
diff --git a/deploy/README.md b/deploy/README.md
index 67a3327186..e7c436489d 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -570,14 +570,14 @@ A major of 2 or higher selects the V2 path; the value must equal the pin's `vers
**Paired deploy.** Deploy a gateway that includes the V2 run loop (#1985) before the V2 workspace, or together with it, from the same ref. A gateway without that loop runs the V1 loop against a V2 server, which is unsafe. A V2-capable gateway in front of a V1 workspace is safe. The shared control-API token requirement above applies to both.
-**Bumping the pin.** Renovate opens a PR that changes only `version` (major 2 only; no automerge). Regenerate the digests before merging:
+**Bumping the pin.** Renovate opens the PR (major 2 only; no automerge). It changes `version`, then its post-upgrade tasks run `bun run postupgrade` (`scripts/sync-opencode-v2-pin.ts`), which rewrites every asset digest from the release's `SHA256SUMS`, before the build. The PR already carries matching digests. To run the same step by hand:
```bash
-node scripts/sync-opencode-v2-pin.ts # rewrite the digests from the release's SHA256SUMS
-node scripts/sync-opencode-v2-pin.ts --check # exit 1 if the file disagrees with upstream
+bun scripts/sync-opencode-v2-pin.ts # rewrite the digests from the release's SHA256SUMS
+bun scripts/sync-opencode-v2-pin.ts --check # exit 1 if the file disagrees with upstream
```
-Commit the updated `opencode-v2-pin.json`. The **Workspace Image Smoke Test** CI job builds the image against the pin, starts it with no network, and fails on a stale digest.
+The **Workspace Image Smoke Test** CI job builds the image against the pin, starts it with no network, and fails on a stale digest.
**Revert.** Before switching, record the digests of the running workspace and gateway images. To revert, redeploy those previous workspace and gateway images by digest. Rebuilding an older ref does not reproduce the same digests.
diff --git a/package.json b/package.json
index a545ebc440..2265b7ea33 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"dist:escape-hidden-unicode": "node --experimental-strip-types scripts/escape-dist-hidden-unicode.ts",
"fix": "bun run --filter @fro-bot/runtime fix && bun run --filter @fro-bot/action fix && bun run --filter @fro.bot/harness fix && bun run --filter @fro.bot/opencode-plugin fix && bun run --filter @fro-bot/gateway fix && bun run --filter @fro-bot/workspace-agent fix",
"postinstall": "simple-git-hooks",
+ "postupgrade": "bun scripts/sync-opencode-v2-pin.ts",
"lint": "bun run --filter @fro-bot/runtime lint && bun run --filter @fro-bot/action lint && bun run --filter @fro.bot/harness lint && bun run --filter @fro.bot/opencode-plugin lint && bun run --filter @fro-bot/gateway lint && bun run --filter @fro-bot/workspace-agent lint && bun run dist:check-hidden-unicode && bun run check:md-links",
"test": "bun run --filter @fro-bot/runtime test && bun run --filter @fro-bot/action test && bun run --filter @fro-bot/workspace-agent test && bun run --filter @fro.bot/harness test && bun run --filter @fro.bot/opencode-plugin test && bun run --filter @fro-bot/gateway test && bun run test:evals",
"evals:baseline:update": "bun run evals/update-baseline.ts",
diff --git a/scripts/renovate-v2-pin-postupgrade.test.ts b/scripts/renovate-v2-pin-postupgrade.test.ts
new file mode 100644
index 0000000000..f194ce960c
--- /dev/null
+++ b/scripts/renovate-v2-pin-postupgrade.test.ts
@@ -0,0 +1,60 @@
+import {readFileSync} from 'node:fs'
+
+import {describe, expect, it} from 'vitest'
+
+// Source of truth: `allowedCommands` in bfra-me/renovate-action `action.yaml` (the `zzglobal_config` block), which the
+// self-hosted runner applies and this repo cannot override. Only the patterns the V2-pin rule relies on are copied; if
+// the action changes them, update these two and the rule's commands together. A command outside this list is silently
+// refused on the runner, which leaves the pin PR with stale digests.
+const ALLOWED_COMMANDS: readonly RegExp[] = [
+ /^(bun|pnpm|yarn) install( --force)?( --frozen-lockfile)?( --ignore-scripts)?$/,
+ /^(?:bun|npm|pnpm|yarn)(?:\srun)?\s(?:bootstrap|build(-release)?|check|clean|fix|format|generate|lint|postupgrade|typecheck|update-snapshots)$/,
+]
+
+const renovateText = readFileSync(new URL('../.github/renovate.json5', import.meta.url), 'utf8')
+const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
+ readonly scripts: Readonly>
+}
+
+// `.github/renovate.json5` is JSON5 and the repo has no JSON5 parser (see packages/harness/src/pin-invariants.test.ts), so
+// the rule is cut out as text: from its `matchFileNames` line to the rule's closing brace at packageRules indent.
+const ruleMatch = /matchFileNames: \['packages\/runtime\/src\/shared\/opencode-v2-pin\.json'\][\s\S]*?\n {4}\},/.exec(
+ renovateText,
+)
+const rule = ruleMatch?.[0] ?? ''
+
+function ruleCommands(): readonly string[] {
+ const list = /postUpgradeTasks:\s*\{[\s\S]*?commands:\s*\[([^\]]*)\]/.exec(rule)?.[1] ?? ''
+ return [...list.matchAll(/'([^']+)'/g)].map(match => match[1] ?? '')
+}
+
+describe('OpenCode V2 pin Renovate rule', () => {
+ it('runs install, the digest sync, then build, in that order', () => {
+ // #given the rule that matches the pin file
+ expect(rule).not.toBe('')
+
+ // #when reading its post-upgrade commands / #then the sync sits between install and build, branch mode
+ expect(ruleCommands()).toEqual(['bun install --ignore-scripts', 'bun run postupgrade', 'bun run build'])
+ expect(rule).toMatch(/executionMode: 'branch'/)
+ })
+
+ it('uses only commands the self-hosted runner allows', () => {
+ // #given the commands / #when each is checked against the runner's allowedCommands
+ const refused = ruleCommands().filter(command => !ALLOWED_COMMANDS.some(pattern => pattern.test(command)))
+
+ // #then none is refused (`node scripts/...` would be)
+ expect(refused).toEqual([])
+ expect(ALLOWED_COMMANDS.some(pattern => pattern.test('node scripts/sync-opencode-v2-pin.ts'))).toBe(false)
+ })
+
+ it('no longer asks a human to run the sync', () => {
+ // #given / #when / #then
+ expect(rule).not.toContain('prBodyNotes')
+ })
+
+ it('has a postupgrade script that runs the sync under bun, and the build script it chains to', () => {
+ // #given the root scripts / #when / #then
+ expect(packageJson.scripts.postupgrade).toBe('bun scripts/sync-opencode-v2-pin.ts')
+ expect(packageJson.scripts.build).toBeDefined()
+ })
+})
diff --git a/scripts/sync-opencode-v2-pin.test.ts b/scripts/sync-opencode-v2-pin.test.ts
index 08a8e1a44b..7975321b08 100644
--- a/scripts/sync-opencode-v2-pin.test.ts
+++ b/scripts/sync-opencode-v2-pin.test.ts
@@ -163,7 +163,7 @@ describe('main', () => {
// #then it exits 2 with a usage line and touched nothing
expect(code).toBe(EXIT_USAGE)
- expect(h.err.join('')).toContain('usage: node scripts/sync-opencode-v2-pin.ts')
+ expect(h.err.join('')).toContain('usage: bun scripts/sync-opencode-v2-pin.ts')
expect(h.fetchMock).not.toHaveBeenCalled()
expect(h.writes).toEqual([])
},
diff --git a/scripts/sync-opencode-v2-pin.ts b/scripts/sync-opencode-v2-pin.ts
index 45e9040819..96929aaf70 100644
--- a/scripts/sync-opencode-v2-pin.ts
+++ b/scripts/sync-opencode-v2-pin.ts
@@ -1,11 +1,12 @@
#!/usr/bin/env node
// Regenerates the per-asset sha256 digests in packages/runtime/src/shared/opencode-v2-pin.json from the upstream
-// release's SHA256SUMS. Run it on the Renovate bump PR, which changes only `version`:
+// release's SHA256SUMS. Renovate changes only `version`, then runs this as `bun run postupgrade` on the update branch
+// (.github/renovate.json5, the OpenCode V2 pin rule). It also runs by hand:
//
-// node scripts/sync-opencode-v2-pin.ts # rewrite the digests for the pinned version
-// node scripts/sync-opencode-v2-pin.ts 2.3.0 # set the version too, then rewrite the digests
-// node scripts/sync-opencode-v2-pin.ts --check # exit 1 if the file disagrees with upstream; writes nothing
+// bun scripts/sync-opencode-v2-pin.ts # rewrite the digests for the pinned version
+// bun scripts/sync-opencode-v2-pin.ts 2.3.0 # set the version too, then rewrite the digests
+// bun scripts/sync-opencode-v2-pin.ts --check # exit 1 if the file disagrees with upstream; writes nothing
//
// The only option is `--check`, and at most one positional version is accepted. Anything else (`--help`, `--check=true`,
// a second positional) exits 2 with a usage line, before the pin is read, fetched or written.
@@ -14,7 +15,7 @@
// release has no SHA256SUMS line for one. The Dockerfile's `opencode-v2` stage and the Action's installOpenCode
// re-verify each digest against SHA256SUMS and the archive at install time, so a wrong digest cannot ship silently.
//
-// Plain Node (24 strips types), no dependencies. The relative `.ts` extensions in sibling scripts follow the same rule.
+// No dependencies, and no runtime-specific API: it runs under bun (the Renovate runner) and under Node 24, which strips types.
import {readFileSync, writeFileSync} from 'node:fs'
import process from 'node:process'
@@ -82,7 +83,7 @@ function serializePin(pin: OpenCodeV2Pin): string {
return `${JSON.stringify(pin, null, 2)}\n`
}
-const USAGE = 'usage: node scripts/sync-opencode-v2-pin.ts [--check | ]'
+const USAGE = 'usage: bun scripts/sync-opencode-v2-pin.ts [--check | ]'
/** The exit code for a rejected command line (distinct from 1, a failed sync or check). */
export const EXIT_USAGE = 2