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
65 changes: 65 additions & 0 deletions .github/workflows/fintoc-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Fintoc CLI

on:
pull_request:
workflow_dispatch:
inputs:
publish:
description: Publish the tested binary from the default branch
type: boolean
default: false

permissions:
contents: read

jobs:
build:
if: github.repository != 'anomalyco/opencode'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: ./.github/actions/setup-bun
with:
install-flags: --frozen-lockfile
- name: Check permissions and existing GitHub behavior
working-directory: packages/opencode
run: bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts
- name: Check types
working-directory: packages/opencode
run: bun typecheck
- name: Build and smoke-test the patched CLI
run: |
export OPENCODE_VERSION=$(jq -er .version github/fintoc.json)
bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui
mkdir -p release
tar -czf release/opencode-linux-x64.tar.gz -C packages/opencode/dist/opencode-linux-x64/bin opencode
cd release
sha256sum opencode-linux-x64.tar.gz > SHA256SUMS
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fintoc-cli
path: release/

publish:
if: github.event_name == 'workflow_dispatch' && inputs.publish && github.ref_name == github.event.repository.default_branch
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fintoc-cli
path: release
- name: Publish immutable version
env:
GH_TOKEN: ${{ github.token }}
run: |
version=$(jq -er .version github/fintoc.json)
upstream=$(jq -er .upstream github/fintoc.json)
printf 'OpenCode %s with explicit GitHub App authorization.\nSource commit: %s\n' "$upstream" "$GITHUB_SHA" > "$RUNNER_TEMP/release.md"
gh release create "v$version" release/opencode-linux-x64.tar.gz release/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \
--title "OpenCode $version" --notes-file "$RUNNER_TEMP/release.md" --prerelease
77 changes: 77 additions & 0 deletions .github/workflows/fintoc-update.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Update Fintoc OpenCode

on:
schedule:
- cron: "0 12 * * 1"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: fintoc-upstream-update
cancel-in-progress: false

jobs:
update:
if: github.repository != 'anomalyco/opencode'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Incorporate the latest upstream release
id: update
run: |
upstream=$(jq -er .upstream github/fintoc.json)
latest=$(gh api repos/anomalyco/opencode/releases/latest --jq .tag_name)
[[ "$latest" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
if [[ "$latest" == "$upstream" ]]; then exit 0; fi
branch="update-opencode-${latest#v}"
if [[ $(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --state open --json number --jq length) != 0 ]]; then exit 0; fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch https://github.com/anomalyco/opencode.git "refs/tags/$latest:refs/tags/$latest"
git switch --create "$branch"
git merge --no-commit --no-ff "$latest"
jq --arg upstream "$latest" --arg version "${latest#v}-fintoc.1" \
'.upstream = $upstream | .version = $version' github/fintoc.json > "$RUNNER_TEMP/fintoc.json"
mv "$RUNNER_TEMP/fintoc.json" github/fintoc.json
git add github/fintoc.json
git commit -m "chore(fork): retain trusted app reviews on $latest"
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "version=$latest" >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/setup-bun
if: steps.update.outputs.branch != ''
with:
install-flags: --frozen-lockfile
- name: Verify the candidate before opening a PR
if: steps.update.outputs.branch != ''
run: |
bun run --cwd packages/opencode typecheck
cd packages/opencode
bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts
export OPENCODE_VERSION=$(jq -er .version ../../github/fintoc.json)
bun run build --single --skip-install --skip-embed-web-ui
- name: Open an update PR
if: steps.update.outputs.branch != ''
env:
UPDATE_BRANCH: ${{ steps.update.outputs.branch }}
UPDATE_VERSION: ${{ steps.update.outputs.version }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git push origin "$UPDATE_BRANCH"
cat > "$RUNNER_TEMP/update.md" <<'BODY'
Updates the upstream CLI while retaining explicit GitHub App authorization and the fork's binary installer.

Validation: targeted GitHub tests, package typecheck, Linux build, and binary version smoke test passed before opening this PR. These run in the updater because PRs created with GITHUB_TOKEN do not trigger another workflow run.

After merging, publish the new CLI with the Fintoc CLI workflow, then update consumers to the merged action commit. Existing consumers stay pinned to their current version until updated.
BODY
gh pr create --repo "$GITHUB_REPOSITORY" --base "$DEFAULT_BRANCH" --head "$UPDATE_BRANCH" \
--title "chore(fork): update OpenCode to $UPDATE_VERSION" --body-file "$RUNNER_TEMP/update.md"
55 changes: 55 additions & 0 deletions github/FINTOC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Fintoc OpenCode fork

This fork lets configured bots trigger reviews through `pull_request`, `issue_comment`, and `pull_request_review_comment` while preserving upstream's collaborator checks for other callers. The model, prompt, PR context, reactions, and review execution remain in upstream OpenCode.

## Authorization

Set `allowed_bots` to a comma-separated list of exact bot logins in the trusted workflow. The default is empty. The CLI accepts the exception only when GitHub's event identifies the sender as a Bot, the sender matches the triggering actor, and that login is in the list. The PR author and comment text cannot grant this exception. Human users continue to need `write` or `admin` collaborator permission, even if their login is listed. Other event types retain upstream behavior.

The CLI reads `ALLOWED_BOTS`; the action passes its `allowed_bots` input. Both token modes use the same authorization rule. Use full GitHub logins, including `[bot]`; wildcards and partial matches are not supported. Configure the list centrally in Hermes; caller repos and agents keep their existing PR and `fin review` flows.

## Build and release

`github/fintoc.json` records the upstream base and the patched CLI version. Increment the `-fintoc.N` suffix for a new patch on the same upstream release. Do not replace existing release assets.

Run the **Fintoc CLI** workflow on the fork's default branch with `publish` enabled. It runs the GitHub tests and package typecheck, builds and smoke-tests the Linux X64 binary, and publishes a versioned prerelease with its checksum. The build omits the embedded web UI because this distribution is for the GitHub runner. Publishing from other branches is disabled.

The fork action requires `release_repository` and installs exactly the version in its own `github/fintoc.json`. It never installs upstream latest. It checks the archive checksum and the executable's version, and separates its cache from upstream's. This installer supports Linux X64 runners.

Example consumer configuration (replace the action commit):

```yaml
- uses: fintoc-com/opencode/github@FULL_COMMIT_SHA
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
with:
release_repository: fintoc-com/opencode
allowed_bots: "fin-tank-agent[bot],linear-code[bot]"
use_github_token: true
model: openai/gpt-5.4
share: false
prompt: ${{ steps.review-pr-prompt.outputs.prompt }}
```

Publish the matching release before updating consumers. Public forks work with the consumer's built-in GitHub token; access to a private release repository would require a separate installation credential and is not implemented here.

For a local build on the current platform:

```bash
bun install --frozen-lockfile
cd packages/opencode
bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts
bun typecheck
OPENCODE_VERSION=1.18.31-fintoc.1 bun run build --single --skip-install --skip-embed-web-ui
```

## Upstream updates

Use a fork default branch based on the release in `github/fintoc.json`, with these changes committed on top. Enable **Update Fintoc OpenCode** and **Fintoc CLI** in the fork; upstream infrastructure workflows are not part of this fork's release process. The repository must permit Actions to create pull requests for the scheduled updater.

Each Monday, or on manual dispatch, the updater checks the latest stable upstream release. It creates an update branch from the fork's default branch and merges the upstream release tag into it, preserving the fork's commits. A conflict stops the update and leaves the default branch and existing consumers unchanged.

Before opening an update PR, the updater runs the authorization and GitHub tests, package typecheck, Linux build, and version smoke test. These checks run inside the updater because its `GITHUB_TOKEN`-created PR does not trigger another workflow. The updater never merges or publishes automatically.

After reviewing and merging an update, publish the new binary through **Fintoc CLI**, then update consumers to the new action commit. Features in the new upstream release are included; any conflicting fork changes need manual resolution. If upstream adds compatible App authorization, remove this patch and return consumers to the official action.
27 changes: 23 additions & 4 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ branding:
color: "orange"

inputs:
release_repository:
description: "Repository containing the patched CLI releases (owner/repo)."
required: true

model:
description: "Model to use"
required: true
Expand All @@ -26,6 +30,11 @@ inputs:
required: false
default: "false"

allowed_bots:
description: "Comma-separated bot logins allowed to trigger pull request and comment runs. Other actors must have write or admin access."
required: false
default: ""

mentions:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
Expand All @@ -45,20 +54,29 @@ runs:
id: version
shell: bash
run: |
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
echo "version=$(jq -er .version "$GITHUB_ACTION_PATH/fintoc.json")" >> "$GITHUB_OUTPUT"

- name: Cache opencode
id: cache
uses: actions/cache@v4
with:
path: ~/.opencode/bin
key: opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }}
key: opencode-fintoc-${{ inputs.release_repository }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }}

- name: Install opencode
if: steps.cache.outputs.cache-hit != 'true'
shell: bash
run: curl -fsSL https://opencode.ai/install | bash
run: bash "$GITHUB_ACTION_PATH/install-fintoc.sh"
env:
GH_TOKEN: ${{ github.token }}
OPENCODE_RELEASE_REPOSITORY: ${{ inputs.release_repository }}
OPENCODE_RELEASE_VERSION: ${{ steps.version.outputs.version }}

- name: Verify opencode version
shell: bash
run: test "$("$HOME/.opencode/bin/opencode" --version)" = "$EXPECTED_VERSION"
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}

- name: Add opencode to PATH
shell: bash
Expand All @@ -74,6 +92,7 @@ runs:
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
4 changes: 4 additions & 0 deletions github/fintoc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"upstream": "v1.18.31",
"version": "1.18.31-fintoc.1"
}
31 changes: 31 additions & 0 deletions github/install-fintoc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ "${RUNNER_OS:-}" != Linux || "${RUNNER_ARCH:-}" != X64 ]]; then
echo "The Fintoc CLI release supports Linux X64 GitHub runners." >&2
exit 1
fi
if [[ ! "${OPENCODE_RELEASE_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
echo "Set release_repository to the fork's owner/repo." >&2
exit 1
fi
if [[ ! "${OPENCODE_RELEASE_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-fintoc\.[0-9]+$ ]]; then
echo "Expected an explicit Fintoc CLI version." >&2
exit 1
fi

download_dir=$(mktemp -d)
trap 'rm -rf "$download_dir"' EXIT
gh release download "v$OPENCODE_RELEASE_VERSION" \
--repo "$OPENCODE_RELEASE_REPOSITORY" \
--pattern opencode-linux-x64.tar.gz \
--pattern SHA256SUMS \
--dir "$download_dir"
(
cd "$download_dir"
sha256sum --check SHA256SUMS
tar -xzf opencode-linux-x64.tar.gz opencode
)
install_dir=${OPENCODE_INSTALL_DIR:-"$HOME/.opencode/bin"}
install -d "$install_dir"
install -m 755 "$download_dir/opencode" "$install_dir/opencode"
25 changes: 2 additions & 23 deletions packages/opencode/src/cli/cmd/github.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { Process } from "@/util/process"
import { parseGitHubRemote } from "@/util/repository"
import { Effect } from "effect"
import { extractResponseText, formatPromptTooLargeError } from "./github.shared"
import { assertPermissions } from "./github.permissions"

type GitHubAuthor = {
login: string
Expand Down Expand Up @@ -494,7 +495,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
}
// Skip permission check and reactions for repo events (no actor to check, no issue to react to)
if (isUserEvent) {
await assertPermissions()
await assertPermissions(context, octoRest, process.env["ALLOWED_BOTS"])
await addReaction(commentType)
}

Expand Down Expand Up @@ -1162,28 +1163,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
return parseInt(result.stdout.toString().trim()) > 0
}

async function assertPermissions() {
// Only called for non-schedule events, so actor is defined
console.log(`Asserting permissions for user ${actor}...`)

let permission
try {
const response = await octoRest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: actor!,
})

permission = response.data.permission
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}

if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
}

async function addReaction(commentType?: "issue" | "pr_review") {
// Only called for non-schedule events, so triggerCommentId is defined
console.log("Adding reaction...")
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/src/cli/cmd/github.permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Context } from "@actions/github/lib/context"
import type { Octokit } from "@octokit/rest"

export async function assertPermissions(
context: Pick<Context, "actor" | "eventName" | "repo" | "payload">,
octokit: Octokit,
allowedBots = "",
) {
const { actor } = context
console.log(`Asserting permissions for user ${actor}...`)

const sender = context.payload.sender
if (
["issue_comment", "pull_request", "pull_request_review_comment"].includes(context.eventName) &&
sender?.type === "Bot" &&
sender.login === actor &&
allowedBots.split(",").some((login) => login.trim() === actor)
) {
console.log(` allowed bot: ${actor}`)
return
}

let permission
try {
const response = await octokit.repos.getCollaboratorPermissionLevel({
...context.repo,
username: actor,
})

permission = response.data.permission
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${String(error)}`)
throw new Error(`Failed to check permissions for user ${actor}: ${String(error)}`, { cause: error })
}

if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
}
Loading
Loading