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
4 changes: 4 additions & 0 deletions github-run-opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Part of [`sun-praise/opencode-actions`](https://github.com/sun-praise/opencode-a
## What it does

- Installs and caches OpenCode (delegates to `setup-opencode`)
- Cleans up oversized `opencode.db` before running (prevents migration failures on self-hosted runners)
- Auto-recovers from SQLite migration errors by deleting the stale database and retrying
- Runs `opencode github run` with optional retry logic for flaky GitHub network failures
- Skips forked pull requests by default (no secrets exposed)
- English / Chinese output controlled by the `language` input
Expand Down Expand Up @@ -45,6 +47,8 @@ Part of [`sun-praise/opencode-actions`](https://github.com/sun-praise/opencode-a
| `retry-profile` | `github-network` | Built-in retry preset for common GitHub failures |
| `timeout-seconds` | `600` | Maximum execution time for `opencode github run`; `0` disables it |
| `working-directory` | empty | Optional working directory before running OpenCode |
| `cleanup-db` | `true` | Delete `opencode.db` if it exceeds size threshold before running; `"true"` (50MB default), a number for custom MB threshold, `"0"` or `"false"` to disable. Note: `db-path` is validated against system directories; on macOS `/var` resolves to `/private/var` and is allowed |
| `db-path` | empty | Custom path to `opencode.db`; defaults to `~/.local/share/opencode/opencode.db` |

All setup-related inputs from [`setup-opencode`](https://github.com/sun-praise/opencode-actions/tree/main/setup-opencode) (`install-url`, `install-dir`, `xdg-cache-home`, `cache`, `cache-key`, `install-attempts`, `allow-preinstalled`, `version`) are also accepted.

Expand Down
29 changes: 29 additions & 0 deletions github-run-opencode/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ inputs:
blocked regardless of this setting.
required: false
default: "false"
cleanup-db:
description: >-
Delete opencode.db before running if it exceeds a size threshold.
Prevents migration failures on self-hosted runners with large stale databases.
Set to "true" to enable with the default 50MB threshold, or a number
(e.g. "100") for a custom threshold in MB, or "false" to disable.
required: false
default: "true"
db-path:
description: >-
Path to opencode.db. Defaults to ~/.local/share/opencode/opencode.db.
Used by the cleanup-db step and migration recovery logic to locate the database.
required: false
default: ""

runs:
using: composite
Expand Down Expand Up @@ -234,6 +248,20 @@ runs:
OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }}
run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh

- if: ${{ inputs.cleanup-db != 'false' && inputs.cleanup-db != '0' }}
shell: bash
env:
INPUT_CLEANUP_DB: ${{ inputs.cleanup-db }}
OPENCODE_DB_PATH: ${{ inputs.db-path }}
run: |
set -uo pipefail
cleanup_val="$INPUT_CLEANUP_DB"
export OPENCODE_DB_MAX_SIZE_MB="50"
if [[ "$cleanup_val" != "true" ]] && [[ "$cleanup_val" =~ ^[0-9]+$ ]]; then
export OPENCODE_DB_MAX_SIZE_MB="$cleanup_val"
fi
${{ github.action_path }}/../shared/cleanup-db.sh

- shell: bash
env:
GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }}
Expand Down Expand Up @@ -261,6 +289,7 @@ runs:
GITHUB_RUN_OPENCODE_LANGUAGE: ${{ inputs.language }}
GITHUB_RUN_OPENCODE_EXTRA_ENV: ${{ inputs.extra-env }}
GITHUB_RUN_OPENCODE_EXTRA_ENV_ALLOW_SENSITIVE: ${{ inputs.extra-env-allow-sensitive }}
OPENCODE_DB_PATH: ${{ inputs.db-path }}
run: |
if ! command -v python3 >/dev/null 2>&1; then
printf 'python3 is required but not installed on this runner\n' >&2
Expand Down
1 change: 1 addition & 0 deletions run-opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ In the same-job case, `setup-opencode` already exports `opencode` to `PATH`, so
- Runs `opencode` with space-delimited `args` and an optional `working-directory`
- Built-in retry preset for common GitHub network failures (`retry-profile: github-network`)
- Optional `retry-on-regex` for custom retry conditions
- Auto-recovers from SQLite migration errors (`duplicate column name`) by deleting the stale database and retrying once
- Provider secrets and model selection are intentionally kept in workflow `env:` so the action stays generic
- Sets `reasoning-effort` and `enable-thinking` for the model agent

Expand Down
48 changes: 48 additions & 0 deletions run-opencode/run-opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ if [[ -n "$OPENCODE_ARGS" ]]; then
fi

attempt=1
migration_recovery_done=false
while [[ "$attempt" -le "$OPENCODE_ATTEMPTS" ]]; do
log_file="$(mktemp)"

Expand All @@ -143,6 +144,53 @@ while [[ "$attempt" -le "$OPENCODE_ATTEMPTS" ]]; do
exit 0
fi

# Auto-recover from SQLite migration failures: delete the stale db and retry once.
# Recovery counts toward OPENCODE_ATTEMPTS — the attempt is incremented before
# continue so that recovery does not exceed the user-configured limit.
# Recovery skips OPENCODE_RETRY_DELAY_SECONDS: after deleting the stale db,
# retrying immediately is the correct behavior (no need to wait).
if [[ "$migration_recovery_done" == "false" ]] && grep -qim1 "duplicate column name" "$log_file" 2>/dev/null; then
_resolve_script="$(cd "$(dirname "$0")" && pwd)/../shared/resolve-db-path.sh"
db_path=""
if [[ -f "$_resolve_script" ]]; then
if ! source "$_resolve_script" 2>/dev/null || ! resolve_db_path 2>/dev/null; then
printf '::warning::migration recovery: resolve_db_path failed, attempting default path\n'
else
db_path="$RESOLVED_DB_PATH"
fi
else
printf '::warning::migration recovery: resolve-db-path.sh not found at %s\n' "$_resolve_script"
fi
if [[ -z "$db_path" ]]; then
# If user explicitly set OPENCODE_DB_PATH but resolve failed, don't guess — skip recovery.
if [[ -n "${OPENCODE_DB_PATH:-}" ]]; then
printf '::warning::migration recovery skipped: db-path validation failed for OPENCODE_DB_PATH=%s\n' "$OPENCODE_DB_PATH"
migration_recovery_done=true
rm -f "$log_file"
continue
fi
# Fallback: unset OPENCODE_DB_PATH so resolve_db_path uses its safe default,
# then re-run validation to get a sanitized path.
if [[ -f "$_resolve_script" ]]; then
OPENCODE_DB_PATH="" resolve_db_path 2>/dev/null && db_path="$RESOLVED_DB_PATH"
fi
if [[ -z "$db_path" ]]; then
migration_recovery_done=true
rm -f "$log_file"
continue
fi
fi
printf '::warning::opencode.db migration failed (duplicate column name), deleting %s and retrying\n' "$db_path"
rm -f -- "$db_path" "$db_path-wal" "$db_path-shm" "$db_path-journal"
migration_recovery_done=true
attempt="$((attempt + 1))"
rm -f "$log_file"
if [[ "$attempt" -gt "$OPENCODE_ATTEMPTS" ]]; then
exit "$status"
fi
continue
fi

if [[ -z "$OPENCODE_RETRY_ON_REGEX" ]] || ! grep -Eiq "$OPENCODE_RETRY_ON_REGEX" "$log_file"; then
rm -f "$log_file"
exit "$status"
Expand Down
12 changes: 12 additions & 0 deletions shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@ Plain-text prompt snippets used by multiple actions (e.g. `multi-review`, `githu
Each file contains the prompt text without surrounding whitespace. These are the **single source of truth** — both TS (`multi-review/src/reviewers.ts` via `actionPath/../shared/prompts/`) and Python (`run-github-opencode.py` via `Path(__file__).parent.parent/shared/prompts/`) load from here at runtime. The path layout relies on the action checkout including the repository root (which is true for both `uses: ./multi-review` and `uses: org/repo/multi-review@vN` on GitHub Actions).

If you add a new prompt file, update `tests/test_all.py` accordingly and run the Python test suite to verify consistency.

## cleanup-db.sh

A standalone shell script that checks the size of `~/.local/share/opencode/opencode.db` and deletes it if it exceeds a configurable threshold (default 50MB). Called from `github-run-opencode/action.yml` before the opencode run step. Controlled by the `cleanup-db` action input.

Environment variables:
- `OPENCODE_DB_PATH` — path to the database file (default: `~/.local/share/opencode/opencode.db`); validated to reject system directories (`/etc`, `/usr`, etc.)
- `OPENCODE_DB_MAX_SIZE_MB` — max allowed size in MB before cleanup (default: `50`); `0` or negative disables cleanup

## resolve-db-path.sh

Shared helper that resolves `OPENCODE_DB_PATH` to an absolute path and validates it's not pointing into system directories. Sourced by `cleanup-db.sh`. The same path resolution logic is used inline in `run-opencode.sh` for migration recovery.
67 changes: 67 additions & 0 deletions shared/cleanup-db.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash

# Cleanup opencode SQLite database to prevent migration failures.
# Called from github-run-opencode/action.yml before opencode runs.
#
# Behavior:
# 1. If the db file exceeds the size threshold → delete it.
# 2. Always: log what happened.
#
# Environment variables:
# OPENCODE_DB_PATH — path to opencode.db (default: ~/.local/share/opencode/opencode.db)
# OPENCODE_DB_MAX_SIZE_MB — max allowed size in MB before cleanup (required, set by action.yml)

# Intentionally no -e: stat falls back via || chains, and we handle all
# error paths explicitly. Adding -e would break the || fallback pattern.
set -uo pipefail

# Resolve and validate db path (shared with run-opencode.sh migration recovery)
# shellcheck source=resolve-db-path.sh
source "$(dirname "$0")/resolve-db-path.sh"
resolve_db_path || exit 1
db_path="$RESOLVED_DB_PATH"

max_mb="${OPENCODE_DB_MAX_SIZE_MB:-50}"
# Validate threshold is a non-negative integer
if ! [[ "$max_mb" =~ ^[0-9]+$ ]]; then
printf '::error::OPENCODE_DB_MAX_SIZE_MB must be a number, got: %s\n' "$max_mb" >&2
exit 1
fi
# Treat 0 as "disable cleanup" — user intent is likely to skip, not delete everything.
if [[ "$max_mb" -eq 0 ]]; then
printf 'cleanup-db: threshold is 0MB, treating as disabled\n'
exit 0
fi

if [[ ! -f "$db_path" ]]; then
exit 0
fi

# Capture size and inode atomically from a single stat call
# GNU stat: stat -c '%s %i'; BSD stat: stat -f '%z %i'
stat_line="$(stat -c '%s %i' -- "$db_path" 2>/dev/null || stat -f '%z %i' "$db_path" 2>/dev/null || true)"
if [[ -z "$stat_line" ]]; then
printf '::warning::cleanup-db: stat failed for %s, skipping\n' "$db_path"
exit 0
fi
size_bytes="${stat_line%% *}"
inode_at_stat="${stat_line##* }"
if [[ "$size_bytes" -eq 0 ]]; then
printf 'cleanup-db: %s is 0 bytes, within threshold (%dMB)\n' "$db_path" "$max_mb"
exit 0
fi
size_mb="$((size_bytes / 1024 / 1024))"

if [[ "$size_mb" -ge "$max_mb" ]]; then
printf '::warning::opencode.db is %dMB (threshold %dMB), deleting to prevent migration failures\n' "$size_mb" "$max_mb"
# TOCTOU defense: verify inode hasn't changed since stat (file replaced by symlink)
inode_now="$(stat -c%i -- "$db_path" 2>/dev/null || stat -f%i "$db_path" 2>/dev/null || echo 0)"
if [[ "$inode_now" != "$inode_at_stat" ]]; then
printf '::warning::cleanup-db: file inode changed before deletion (TOCTOU), skipping\n'
exit 0
fi
rm -f -- "$db_path" "$db_path-wal" "$db_path-shm" "$db_path-journal"
printf 'cleanup-db: deleted %s (%dMB)\n' "$db_path" "$size_mb"
else
printf 'cleanup-db: %s is %dMB, within threshold (%dMB)\n' "$db_path" "$size_mb" "$max_mb"
fi
88 changes: 88 additions & 0 deletions shared/resolve-db-path.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash

# Shared helper: resolve and validate the opencode database path.
# Sourced by cleanup-db.sh and run-opencode.sh migration recovery.
# This file is a source-only library, not meant to be executed directly.
#
# Public API:
# resolve_db_path() — resolves OPENCODE_DB_PATH, validates safety, exports result
# RESOLVED_DB_PATH — result variable set by resolve_db_path()
#
# Validation rules:
# - Must not be empty after resolution
# - Must not traverse into system or sensitive directories

# Prevent direct execution
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "error: resolve-db-path.sh is a source-only library" >&2
exit 1
fi

# Default database path — single source of truth for all callers.
OPENCODE_DB_DEFAULT_PATH="$HOME/.local/share/opencode/opencode.db"

resolve_db_path() {
local default_path="$OPENCODE_DB_DEFAULT_PATH"
local raw="${OPENCODE_DB_PATH:-$default_path}"
# Guard against empty string (e.g. action input unset → env var is "")
[[ -z "$raw" ]] && raw="$default_path"

# Step 1: Normalize path using python3 (already a hard dependency).
# Falls back to shell-level .. stripping if python3 is unavailable.
local resolved
local normalized
normalized="$(python3 -c "import os,sys
try:
p = os.path.realpath(sys.argv[1])
print(p if p else '')
except OSError:
pass" "$raw" 2>/dev/null || true)"

if [[ -n "$normalized" ]]; then
resolved="$normalized"
else
# python3 unavailable — strip .. components at shell level as defense-in-depth.
local parts
IFS='/' read -ra parts <<< "$raw"
local stack=()
local part
for part in "${parts[@]}"; do
if [[ "$part" == ".." ]] && [[ ${#stack[@]} -gt 0 ]]; then
unset 'stack[${#stack[@]}-1]'
elif [[ -n "$part" && "$part" != "." ]]; then
stack+=("$part")
fi
done
# Join with / using IFS (handles paths with spaces correctly)
local IFS='/'
resolved="/${stack[*]}"
fi

# Step 2: For non-python3 path, also resolve via cd if directory exists
if [[ -z "$normalized" ]] && [[ -d "$(dirname "$resolved")" ]]; then
local dir
dir="$(dirname "$resolved")"
resolved="$(cd "$dir" 2>/dev/null && pwd)/$(basename "$resolved")"
fi

# Step 3: Reject dangerous paths (system dirs and sensitive locations).
# macOS symlinks: /etc → /private/etc, /tmp → /private/tmp, /var → /private/var.
# /private/etc and /private/tmp blocked to catch symlink-resolved paths.
# /private/var allowed (macOS CI temp dirs under /var/folders → /private/var/folders).
# /var blocked on Linux (contains logs, databases; /var/tmp is world-writable).
# Note: bare "/" intentionally NOT in list — it would match all absolute paths.
local -a forbidden=(
/etc /usr /bin /sbin /lib /boot /proc /sys /dev /opt /root
/mnt /media /srv /run /var /tmp
/private/etc /private/tmp
)
local prefix
for prefix in "${forbidden[@]}"; do
if [[ "$resolved" == "$prefix"/* || "$resolved" == "$prefix" ]]; then
printf '::error::db-path must not point into %s, got: %s\n' "$prefix" "$resolved" >&2
return 1
fi
done

RESOLVED_DB_PATH="$resolved"
}
Loading
Loading