Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .agents/skills/versioned-tool/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ This project manages external CLI tools through a **single-source-of-truth versi
| --- | --- | --- | --- |
| OpenCode (action default) | `DEFAULT_OPENCODE_VERSION` | `opencode-version` | _Release-job coupled — NOT Renovate (see below)_ |
| OpenCode (action fallback) | `FALLBACK_VERSION` in `src/services/setup/opencode.ts` | _(internal only)_ | `github-releases` (`anomalyco/opencode`) |
| OpenCode (workspace image) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ |
| OpenCode (workspace image, V1 default) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ |
| OpenCode (workspace image, V2) | `version` in `packages/runtime/src/shared/opencode-v2-pin.json`, passed as the workspace build's `--build-arg OPENCODE_VERSION` | _(internal only)_ | `github-releases` (`anomalyco/opencode`, major 2 only; custom manager on the JSON file) |
| 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 (`<base>+harness.<sha>`). 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. 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 (`<base>+harness.<sha>`); 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`.

## Quick Start

Expand Down
27 changes: 27 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@
datasourceTemplate: 'github-releases',
extractVersionTemplate: '^bun-v(?<version>.*)$',
},
{
// 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).
customType: 'regex',
managerFilePatterns: ['/packages\\/runtime\\/src\\/shared\\/opencode-v2-pin\\.json/'],
matchStrings: ['"version":\\s*"(?<currentValue>\\d+\\.\\d+\\.\\d+)"'],
depNameTemplate: 'anomalyco/opencode',
datasourceTemplate: 'github-releases',
extractVersionTemplate: '^v?(?<version>.*)$',
},
{
// Tracks the packageManager field in package.json (bun@X.Y.Z).
// CI installs this version through .github/actions/setup; keep it in lockstep with
Expand Down Expand Up @@ -148,6 +160,21 @@
matchPackageNames: ['@opencode-ai/sdk', 'anomalyco/opencode'],
allowedVersions: '<=1.19.4',
},
{
// 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.
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.',
],
},
{
// Cap Bun at 1.4.4. The build gate requires an exact match with upstream
// anomalyco/opencode's packageManager field at the harness base version.
Expand Down
95 changes: 95 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,101 @@ jobs:
# watcher assertion above: nothing else asserts it, so pin it here.
docker exec "$cid" sh -c '[ "$OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" = "true" ]'
echo "OK: clone API up on :9100, opencode present, base config (no baked model), watcher disabled, background subagents enabled, auth fail-soft"
- name: Build workspace Docker image (OpenCode V2)
run: |
# The V2 leg of this job. The Dockerfile selects the stock V2 binary when OPENCODE_VERSION
# has major 2 or higher; the only valid value is the pin, so it is read from the pin file
# (never hard-coded here) and bumps in one place. Every stage before the OpenCode version
# is shared with the V1 build above and comes from this runner's layer cache.
v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')"
echo "V2 pin: ${v2_version}"
for attempt in 1 2 3; do
if docker build --build-arg "OPENCODE_VERSION=${v2_version}" -f deploy/workspace.Dockerfile -t fro-bot-workspace:smoke-v2 .; then
exit 0
fi
echo "::warning::docker build (V2) attempt ${attempt} failed"
[ "${attempt}" -eq 3 ] && { echo "docker build (V2) failed after 3 attempts"; exit 1; }
sleep $((attempt * 15))
done
- name: Smoke-test workspace image (OpenCode V2 boots through its entrypoint, no network)
run: |
set -euo pipefail
v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')"
case "${v2_version}" in
[2-9].*|[1-9][0-9]*.*) : ;;
*) echo "opencode-v2-pin.json version is not a V2 version: ${v2_version}" >&2; exit 1 ;;
esac

# 1. The stock musl binary starts as the agent uid with NO network, and both plugins load
# from the image's cache and report active. Plugin specs come from the runtime pins, the
# same values the Dockerfile installs (workspace-v2-image.test.mjs pins ARG == constant).
systematic_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/DEFAULT_SYSTEMATIC_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")"
plugin_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/FRO_BOT_PLUGIN_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")"
docker run --rm --network none --user 10001:10001 \
-e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config \
-e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_CACHE_HOME=/home/opencode/.cache \
-e XDG_STATE_HOME=/home/opencode/.local/state \
-v "$PWD/deploy/scripts/v2-image-smoke.mjs:/smoke.mjs:ro" \
--entrypoint node fro-bot-workspace:smoke-v2 /smoke.mjs \
"@fro.bot/systematic@${systematic_version}" "@fro.bot/opencode-plugin@${plugin_version}"

# 2. The real entrypoint, with the production security posture and no network. /readyz is 200
# only after the supervisor's plugin gate (systematic + fro-bot.opencode-plugin active) and
# permission gate pass, and it echoes the version the gateway selects its V2 loop from.
# Readiness needs no provider and no network: the plugins are installed at image build.
cid="$(docker run -d --network none \
-e WORKSPACE_OPENCODE_TOKEN=dummy-smoke-token \
-e MITMPROXY_CA_WAIT_SECONDS=0 \
-e NO_PROXY='*' \
$WORKSPACE_DOCKER_SECURITY_FLAGS \
fro-bot-workspace:smoke-v2)"
cleanup() { docker rm -f "$cid" >/dev/null 2>&1 || true; }
trap cleanup EXIT

body=''
ready=false
for _ in $(seq 1 90); do
if body="$(docker exec "$cid" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz 2>/dev/null)"; then
ready=true
break
fi
sleep 1
done
# Read the logs once: a `docker logs | grep -q` pipeline can SIGPIPE under pipefail.
logs="$(docker logs "$cid" 2>&1)"
echo "--- workspace logs ---"
echo "${logs}"
if [ "${ready}" != "true" ]; then
echo "REGRESSION: /readyz did not return 200 within 90s on the V2 image"
exit 1
fi
echo "/readyz: ${body}"
echo "${body}" | node -e '
const body = JSON.parse(fs.readFileSync(0, "utf8"))
if (body.ready !== true || body.opencode !== "ready" || body.opencodeVersion !== process.argv[1]) {
console.error("unexpected /readyz body for the V2 pin " + process.argv[1])
process.exit(1)
}
' "${v2_version}"
echo "${logs}" | grep -q "opencode-server-v2: ready"
if echo "${logs}" | grep -q "ERR_MODULE_NOT_FOUND"; then
echo "REGRESSION: module resolution failed (ERR_MODULE_NOT_FOUND)"
exit 1
fi

# 3. The binary is stock V2 at the pin: no +harness build.
oc_version="$(docker exec "$cid" opencode --version)"
echo "opencode --version: ${oc_version}"
if echo "${oc_version}" | grep -qF "harness"; then
echo "REGRESSION: V2 image reports a harness build: ${oc_version}"
exit 1
fi
reported="${oc_version#opencode }"
if [ "${reported#v}" != "${v2_version}" ]; then
echo "REGRESSION: expected OpenCode ${v2_version}, got: ${oc_version}"
exit 1
fi
echo "OK: V2 image ready on ${oc_version}, plugins active, no network"
- name: Smoke-test auth provisioning (valid blob → auth.json written 0600)
run: |
set -e
Expand Down
167 changes: 167 additions & 0 deletions .github/workflows/opencode-plugin-release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
name: opencode-plugin-release

on:
workflow_dispatch:
inputs:
version:
description: Version to publish (for example 0.1.0 or 0.2.0-rc.1)
required: true
type: string
dry_run:
description: 'Dry run: verify and pack, but do not publish'
required: false
type: boolean
default: false

concurrency:
group: opencode-plugin-release-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

jobs:
verify:
name: verify (gates, build, pack)
runs-on: ubuntu-24.04

permissions:
contents: read

outputs:
version: ${{ steps.params.outputs.version }}
dry_run: ${{ steps.params.outputs.dry_run }}
tarball: ${{ steps.pack.outputs.tarball }}

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Resolve version
id: params
env:
VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail

if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::'${VERSION}' is not a plain semver version"
exit 1
fi

echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "dry_run=${DRY_RUN:-false}" >> "${GITHUB_OUTPUT}"

- name: Setup Node.js and Bun
uses: ./.github/actions/setup

- name: Lint, type-check and test
run: |
set -euo pipefail
bun run --filter @fro.bot/opencode-plugin lint
bun run --filter @fro.bot/opencode-plugin check-types
bun run --filter @fro.bot/opencode-plugin test

- name: Build
run: bun run --filter @fro.bot/opencode-plugin build

- name: Check the bundle only imports what the host provides
working-directory: packages/opencode-plugin
run: |
set -euo pipefail
node --input-type=module -e '
import {readFileSync} from "node:fs"
const source = readFileSync("dist/index.mjs", "utf8")
const patterns = [/\bfrom\s*["\x27]([^"\x27]+)["\x27]/g, /\bimport\s*["\x27]([^"\x27]+)["\x27]/g, /\b(?:import|require)\(\s*["\x27]([^"\x27]+)["\x27]\s*\)/g]
const specifiers = patterns.flatMap(pattern => [...source.matchAll(pattern)].map(m => m[1]))
const allowed = (s) => s === "effect" || s.startsWith("effect/") || s === "@opencode/plugin" || s.startsWith("@opencode/plugin/") || s.startsWith("node:")
const bad = [...new Set(specifiers.filter(s => !allowed(s)))]
if (bad.length > 0) { console.error("::error::unexpected imports in dist/index.mjs: " + bad.join(", ")); process.exit(1) }
if (!specifiers.includes("@opencode/plugin")) { console.error("::error::dist/index.mjs no longer imports @opencode/plugin"); process.exit(1) }
console.log("bundle imports ok: " + [...new Set(specifiers)].join(", "))
'

- name: Pack
id: pack
env:
VERSION: ${{ steps.params.outputs.version }}
run: |
set -euo pipefail
# Packed from a staging copy so the workspace manifest (workspace:* devDependencies) is never published.
STAGE="$(mktemp -d)"
PKG=packages/opencode-plugin
mkdir -p "${STAGE}/pkg" "${STAGE}/out"
cp -R "${PKG}/dist" "${PKG}/README.md" "${PKG}/LICENSE" "${PKG}/package.json" "${STAGE}/pkg/"
(
cd "${STAGE}/pkg"
npm pkg set "version=${VERSION}"
npm pkg delete devDependencies scripts
npm pack --pack-destination "${STAGE}/out"
)
TARBALL="$(ls "${STAGE}/out")"
mkdir -p dist-tarball
cp "${STAGE}/out/${TARBALL}" "dist-tarball/${TARBALL}"
echo "Packed contents:"
tar -tzf "dist-tarball/${TARBALL}"
echo "tarball=${TARBALL}" >> "${GITHUB_OUTPUT}"

- name: Upload tarball
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: opencode-plugin-tarball
path: dist-tarball/
if-no-files-found: error
retention-days: 7

# id-token is granted here only. This job runs none of the repository's code, so nothing in the build can obtain an
# OIDC token; it publishes the tarball verify produced.
publish:
name: publish (npm trusted publishing)
needs: verify
if: ${{ !cancelled() && needs.verify.result == 'success' && needs.verify.outputs.dry_run != 'true' }}
runs-on: ubuntu-24.04

permissions:
id-token: write
contents: read

steps:
- name: Download tarball
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: opencode-plugin-tarball
path: dist-tarball

- name: Setup Node.js (for npm trusted publishing via OIDC)
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'

- name: Check npm supports trusted publishing
run: |
version="$(npm --version)"
node -e 'const [a, b, c] = process.argv[1].split(".").map(Number); process.exit(a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))) ? 0 : 1)' "$version" \
|| { echo "::error::npm ${version} is older than 11.5.1, which trusted publishing requires"; exit 1; }

- name: Publish to npm (trusted publishing)
env:
VERSION: ${{ needs.verify.outputs.version }}
TARBALL: ${{ needs.verify.outputs.tarball }}
run: |
set -euo pipefail
if npm view "@fro.bot/opencode-plugin@${VERSION}" version >/dev/null 2>&1; then
echo "Already published: @fro.bot/opencode-plugin@${VERSION}; skipping"
exit 0
fi

case "${VERSION}" in
*-*) DIST_TAG="next" ;;
*) DIST_TAG="latest" ;;
esac

echo "Publishing @fro.bot/opencode-plugin@${VERSION} (dist-tag ${DIST_TAG})..."
npm publish "./dist-tarball/${TARBALL}" --tag "${DIST_TAG}"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ eslint-typegen.d.ts
packages/runtime/dist/
packages/gateway/dist/
packages/harness/dist/
packages/opencode-plugin/dist/
apps/action/dist/
apps/workspace-agent/dist/

Expand Down
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24.22.1
24.22.2
Loading
Loading