9670a5fda3
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m26s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m27s
- New --doc-map flag (DOC_MAP_FILE env var): path to YAML config mapping source path globs to governing design docs - New --doc-map-max-bytes flag (DOC_MAP_MAX_BYTES env var): cap on total injected doc content, default 100KB - review/docmap.go: DocMapConfig parsing, glob matching with ** support, doc loading via VCS with directory expansion and size guard - budget.Sections: new DesignDocs field, trimmed after conventions - budget.buildResult: injects DesignDocs under ## Design Documents heading - action.yml: doc-map and doc-map-max-bytes inputs wired to env vars - CHANGELOG.md: created with unreleased entry - Tests: ParseDocMapConfig, MatchDocs, globMatch, LoadMatchingDocs
520 lines
23 KiB
YAML
520 lines
23 KiB
YAML
# This composite action supports both Gitea Actions and GitHub Actions runners.
|
|
# It detects the VCS host type by checking whether github.api_url is set
|
|
# (present on GitHub.com and GHES runners, absent on Gitea runners) and uses
|
|
# the appropriate releases API for version resolution and binary download
|
|
# (REST API on GitHub, direct URLs on Gitea).
|
|
#
|
|
# Security notes:
|
|
# - On GitHub/GHES (VCS_TYPE=github), inputs.vcs-url is IGNORED to prevent
|
|
# token exfiltration. API calls use github.api_url; downloads use
|
|
# github.server_url. Tokens are never sent to user-supplied URLs.
|
|
# - On Gitea (VCS_TYPE=gitea), inputs.vcs-url is validated (https scheme,
|
|
# no whitespace/newlines, and DNS resolution to a public IP) before use.
|
|
# Python3 resolves the hostname and rejects RFC1918, RFC6598 (carrier-grade
|
|
# NAT), loopback, link-local, and other reserved addresses to prevent SSRF attacks.
|
|
# The installed review-bot binary additionally uses a safe HTTP transport
|
|
# (DialContext-level IP check) for all Gitea API calls at runtime.
|
|
# The binary also exposes a `validate-url` subcommand for use in any future
|
|
# shell steps that need to validate a URL before passing it to curl.
|
|
# - action-repo is validated against owner/repo pattern.
|
|
# - Tokens are passed via masked environment variables, not step outputs.
|
|
#
|
|
# Requirements: python3, sha256sum, curl (all present on ubuntu-* runners).
|
|
name: 'AI Code Review'
|
|
description: 'Run AI-powered code review on a pull request using review-bot'
|
|
|
|
inputs:
|
|
vcs-url:
|
|
description: 'VCS server URL (only used on Gitea runners; ignored on GitHub/GHES). Defaults to server_url.'
|
|
required: false
|
|
default: ''
|
|
repo:
|
|
description: 'Repository to review (owner/name, defaults to current)'
|
|
required: false
|
|
default: ''
|
|
action-repo:
|
|
description: 'Repository hosting review-bot releases (owner/name). Defaults to github.action_repository or rodin/review-bot.'
|
|
required: false
|
|
default: ''
|
|
action-repo-token:
|
|
description: 'Token for downloading release assets from action-repo (defaults to github.token on GitHub, reviewer-token on Gitea). Required for private repos.'
|
|
required: false
|
|
default: ''
|
|
pr-number:
|
|
description: 'Pull request number (defaults to current PR)'
|
|
required: false
|
|
default: ''
|
|
reviewer-token:
|
|
description: 'Token for posting the review'
|
|
required: true
|
|
reviewer-name:
|
|
description: 'Display name for the reviewer'
|
|
required: false
|
|
default: ''
|
|
llm-base-url:
|
|
description: 'OpenAI-compatible LLM API base URL (not required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
llm-api-key:
|
|
description: 'LLM API key (not required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
llm-model:
|
|
description: 'LLM model name'
|
|
required: true
|
|
llm-provider:
|
|
description: 'LLM API provider: openai, anthropic, or aicore (default openai)'
|
|
required: false
|
|
default: 'openai'
|
|
aicore-client-id:
|
|
description: 'SAP AI Core client ID (required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
aicore-client-secret:
|
|
description: 'SAP AI Core client secret (required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
aicore-auth-url:
|
|
description: 'SAP AI Core authentication URL (required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
aicore-api-url:
|
|
description: 'SAP AI Core API URL (required for aicore provider)'
|
|
required: false
|
|
default: ''
|
|
aicore-resource-group:
|
|
description: 'SAP AI Core resource group (default: default)'
|
|
required: false
|
|
default: 'default'
|
|
conventions-file:
|
|
description: 'Path to conventions file in the repo (e.g. CLAUDE.md)'
|
|
required: false
|
|
default: ''
|
|
patterns-repo:
|
|
description: 'Comma-separated repos with language patterns (e.g. rodin/elixir-patterns,rodin/phoenix-conventions)'
|
|
required: false
|
|
default: ''
|
|
patterns-files:
|
|
description: 'Comma-separated file paths or directories to fetch from patterns repos'
|
|
required: false
|
|
default: 'README.md'
|
|
temperature:
|
|
description: 'LLM temperature (0 = server default)'
|
|
required: false
|
|
default: '0'
|
|
timeout:
|
|
description: 'LLM request timeout in seconds (default 300)'
|
|
required: false
|
|
default: '300'
|
|
version:
|
|
description: 'review-bot version to install (e.g. v0.1.0, defaults to latest)'
|
|
required: false
|
|
default: 'latest'
|
|
dry-run:
|
|
description: 'Print review to stdout instead of posting'
|
|
required: false
|
|
default: 'false'
|
|
update-existing:
|
|
description: 'Delete previous review from same bot after posting new one. Accepts: true/1/yes or false/0/no (default true)'
|
|
required: false
|
|
default: 'true'
|
|
system-prompt-file:
|
|
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
|
|
required: false
|
|
default: ''
|
|
persona:
|
|
description: 'Built-in persona name (security, architect, docs)'
|
|
required: false
|
|
default: ''
|
|
persona-file:
|
|
description: 'Path to custom persona JSON file'
|
|
required: false
|
|
default: ''
|
|
doc-map:
|
|
description: >-
|
|
Path to a YAML file mapping source path globs to governing design docs.
|
|
review-bot intersects the map with changed PR paths and injects matching
|
|
docs as context alongside the diff.
|
|
required: false
|
|
default: ''
|
|
doc-map-max-bytes:
|
|
description: 'Maximum bytes of injected doc content from doc-map (default 102400 = 100KB)'
|
|
required: false
|
|
default: '102400'
|
|
|
|
runs:
|
|
using: 'composite'
|
|
steps:
|
|
- name: Determine version
|
|
id: version
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# --- Input Validation ---
|
|
|
|
# Determine the repo hosting review-bot releases (not the repo being reviewed)
|
|
ACTION_REPO="${{ inputs.action-repo }}"
|
|
if [ -z "$ACTION_REPO" ]; then
|
|
# github.action_repository is the repo containing the running action
|
|
ACTION_REPO="${{ github.action_repository }}"
|
|
fi
|
|
if [ -z "$ACTION_REPO" ]; then
|
|
# Final fallback for Gitea (which may not set action_repository)
|
|
ACTION_REPO="rodin/review-bot"
|
|
echo "::notice::action-repo not specified and github.action_repository is empty; falling back to rodin/review-bot"
|
|
fi
|
|
|
|
# Validate ACTION_REPO matches owner/repo pattern (prevent path traversal)
|
|
if ! printf '%s' "$ACTION_REPO" | grep -qE '^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$'; then
|
|
echo "Error: action-repo '${ACTION_REPO}' does not match expected owner/repo format" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Detect VCS host type using github.api_url context.
|
|
# github.api_url is set on GitHub.com (https://api.github.com) and GHES
|
|
# (https://<host>/api/v3). It is empty/unset on Gitea Actions runners.
|
|
GITHUB_API_URL="${{ github.api_url }}"
|
|
if [ -n "$GITHUB_API_URL" ]; then
|
|
VCS_TYPE="github"
|
|
else
|
|
VCS_TYPE="gitea"
|
|
fi
|
|
|
|
# Determine SERVER_URL based on VCS type.
|
|
# SECURITY: On GitHub/GHES, ALWAYS use github.server_url — never trust
|
|
# inputs.vcs-url to prevent token exfiltration to attacker-controlled hosts.
|
|
if [ "$VCS_TYPE" = "github" ]; then
|
|
SERVER_URL="${{ github.server_url }}"
|
|
if [ -n "${{ inputs.vcs-url }}" ]; then
|
|
echo "::warning::inputs.vcs-url is ignored on GitHub/GHES runners (VCS_TYPE=github). Using github.server_url instead."
|
|
fi
|
|
else
|
|
SERVER_URL="${{ inputs.vcs-url || github.server_url }}"
|
|
fi
|
|
# Strip trailing slash if present
|
|
SERVER_URL="${SERVER_URL%/}"
|
|
|
|
# Validate SERVER_URL for Gitea path: must be https, no whitespace/newlines.
|
|
# The [^[:space:]] class already rejects newlines, so no separate newline check needed.
|
|
if [ "$VCS_TYPE" = "gitea" ]; then
|
|
if ! printf '%s' "$SERVER_URL" | grep -qE '^https://[^[:space:]]+$'; then
|
|
echo "Error: SERVER_URL '${SERVER_URL}' must be an https:// URL with no whitespace" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Additional IP-level SSRF defense: resolve the hostname and reject
|
|
# requests to RFC1918, RFC6598 (carrier-grade NAT), loopback, link-local,
|
|
# and other reserved addresses.
|
|
# python3 is required on ubuntu-* runners (see requirements comment above).
|
|
# Use printf to write the script to a temp file so the python lines are valid
|
|
# YAML (each indented line becomes a printf argument — no unindented code).
|
|
# SERVER_URL is passed via CHECK_URL env var, never interpolated into python code.
|
|
printf '%s\n' \
|
|
'import socket,ipaddress,sys,os' \
|
|
'from urllib.parse import urlparse' \
|
|
'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \
|
|
'if parsed.username or parsed.password:' \
|
|
' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \
|
|
'h=parsed.hostname' \
|
|
'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \
|
|
'try: rs=socket.getaddrinfo(h,None)' \
|
|
'except socket.gaierror as e: print(f"DNS error: {e}",file=sys.stderr); sys.exit(1)' \
|
|
'if not rs: print("Error: no addresses",file=sys.stderr); sys.exit(1)' \
|
|
'for _,_,_,_,(a,*_) in rs:' \
|
|
' ip=ipaddress.ip_address(a)' \
|
|
' if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped' \
|
|
' cgn=ipaddress.ip_network("100.64.0.0/10")' \
|
|
' if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip in cgn:' \
|
|
' print(f"blocked: {a}",file=sys.stderr); sys.exit(1)' \
|
|
> /tmp/_ssrf_check.py
|
|
CHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check.py || {
|
|
echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
# Determine auth token for release API requests
|
|
ACTION_TOKEN="${{ inputs.action-repo-token }}"
|
|
if [ -z "$ACTION_TOKEN" ]; then
|
|
if [ "$VCS_TYPE" = "github" ]; then
|
|
ACTION_TOKEN="${{ github.token }}"
|
|
else
|
|
ACTION_TOKEN="${{ inputs.reviewer-token }}"
|
|
fi
|
|
fi
|
|
|
|
# Validate token contains no control characters (defense-in-depth against header injection)
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
if printf '%s' "$ACTION_TOKEN" | LC_ALL=C grep -q '[^[:print:]]'; then
|
|
echo "Error: ACTION_TOKEN contains control characters" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if [ "${{ inputs.version }}" = "latest" ]; then
|
|
if [ "$VCS_TYPE" = "github" ]; then
|
|
# SECURITY: Use github.api_url which is a trusted platform-provided value.
|
|
# Never construct API URLs from user-supplied inputs on GitHub.
|
|
API_URL="${GITHUB_API_URL}/repos/${ACTION_REPO}/releases?per_page=1"
|
|
else
|
|
# Gitea API — SERVER_URL was validated above
|
|
API_URL="${SERVER_URL}/api/v1/repos/${ACTION_REPO}/releases?limit=1"
|
|
fi
|
|
|
|
# Fetch latest version with inline auth header (no intermediate variable)
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
if [ "$VCS_TYPE" = "github" ]; then
|
|
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
|
-H "Authorization: Bearer ${ACTION_TOKEN}" "$API_URL" \
|
|
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
|
else
|
|
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
|
-H "Authorization: token ${ACTION_TOKEN}" "$API_URL" \
|
|
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
|
fi
|
|
else
|
|
VERSION=$(curl -sSf --connect-timeout 10 --max-time 30 "$API_URL" \
|
|
| python3 -c "import sys, json; releases = json.load(sys.stdin); print(releases[0]['tag_name'] if releases else '')")
|
|
fi
|
|
|
|
if [ -z "$VERSION" ]; then
|
|
echo "Failed to determine latest version from ${API_URL}" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
VERSION="${{ inputs.version }}"
|
|
fi
|
|
|
|
# Validate VERSION: no slashes or whitespace (prevent path traversal).
|
|
# [:space:] includes newlines and carriage returns in POSIX.
|
|
if printf '%s' "$VERSION" | grep -qE '[/[:space:]]'; then
|
|
echo "Error: VERSION '${VERSION}' contains invalid characters (newline, slash, or whitespace)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Detect OS and architecture for platform-specific binary download
|
|
OS_RAW=$(uname -s | tr '[:upper:]' '[:lower:]')
|
|
case "$OS_RAW" in
|
|
linux) OS="linux" ;;
|
|
darwin) OS="darwin" ;;
|
|
*)
|
|
echo "Error: unsupported OS: $(uname -s)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
RAW_ARCH=$(uname -m)
|
|
case "$RAW_ARCH" in
|
|
x86_64) ARCH="amd64" ;;
|
|
aarch64 | arm64) ARCH="arm64" ;;
|
|
*)
|
|
echo "Error: unsupported architecture: $RAW_ARCH" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
echo "os=${OS}" >> "$GITHUB_OUTPUT"
|
|
echo "arch=${ARCH}" >> "$GITHUB_OUTPUT"
|
|
echo "action_repo=${ACTION_REPO}" >> "$GITHUB_OUTPUT"
|
|
echo "server_url=${SERVER_URL}" >> "$GITHUB_OUTPUT"
|
|
echo "vcs_type=${VCS_TYPE}" >> "$GITHUB_OUTPUT"
|
|
|
|
# SECURITY: Pass token via masked environment variable instead of step output.
|
|
# Step outputs can leak in debug logs; GITHUB_ENV with masking is safer.
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
echo "::add-mask::${ACTION_TOKEN}"
|
|
echo "ACTION_TOKEN=${ACTION_TOKEN}" >> "$GITHUB_ENV"
|
|
fi
|
|
|
|
- name: Cache review-bot binary
|
|
id: cache
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: ${{ runner.temp }}/review-bot
|
|
key: review-bot-${{ steps.version.outputs.os }}-${{ steps.version.outputs.arch }}-${{ steps.version.outputs.version }}
|
|
|
|
- name: Install review-bot
|
|
if: steps.cache.outputs.cache-hit != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
SERVER_URL="${{ steps.version.outputs.server_url }}"
|
|
ACTION_REPO="${{ steps.version.outputs.action_repo }}"
|
|
VERSION="${{ steps.version.outputs.version }}"
|
|
VCS_TYPE="${{ steps.version.outputs.vcs_type }}"
|
|
OS="${{ steps.version.outputs.os }}"
|
|
ARCH="${{ steps.version.outputs.arch }}"
|
|
# Read token from masked environment variable (set in Determine version step)
|
|
# Falls back to empty if not set (public repos don't need auth)
|
|
ACTION_TOKEN="${ACTION_TOKEN:-}"
|
|
BINARY="review-bot-${OS}-${ARCH}"
|
|
|
|
# SECURITY: Re-validate SERVER_URL at the start of this step to mitigate DNS
|
|
# rebinding attacks. A DNS TTL expiry between "Determine version" and here
|
|
# could allow an attacker to change the resolved IP to a private/reserved
|
|
# address, causing curl to send ACTION_TOKEN to an internal host.
|
|
# Only needed on Gitea path (VCS_TYPE=gitea); GitHub/GHES uses platform-controlled URLs.
|
|
if [ "$VCS_TYPE" = "gitea" ]; then
|
|
printf '%s\n' \
|
|
'import socket,ipaddress,sys,os' \
|
|
'from urllib.parse import urlparse' \
|
|
'u=os.environ["CHECK_URL"]; parsed=urlparse(u)' \
|
|
'if parsed.username or parsed.password:' \
|
|
' print("Error: URL contains user-info — not allowed",file=sys.stderr); sys.exit(2)' \
|
|
'h=parsed.hostname' \
|
|
'(print("Error: no hostname",file=sys.stderr) or sys.exit(2)) if not h else None' \
|
|
'try: rs=socket.getaddrinfo(h,None)' \
|
|
'except socket.gaierror as e: print(f"DNS error: {e}",file=sys.stderr); sys.exit(1)' \
|
|
'if not rs: print("Error: no addresses",file=sys.stderr); sys.exit(1)' \
|
|
'for _,_,_,_,(a,*_) in rs:' \
|
|
' ip=ipaddress.ip_address(a)' \
|
|
' if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped' \
|
|
' cgn=ipaddress.ip_network("100.64.0.0/10")' \
|
|
' if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip in cgn:' \
|
|
' print(f"blocked: {a}",file=sys.stderr); sys.exit(1)' \
|
|
> /tmp/_ssrf_check_install.py
|
|
CHECK_URL="${SERVER_URL}" python3 /tmp/_ssrf_check_install.py || {
|
|
echo "Error: SERVER_URL '${SERVER_URL}' resolves to a private/reserved IP address" >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
if [ "$VCS_TYPE" = "github" ]; then
|
|
# GitHub/GHES: Use REST API for release asset downloads.
|
|
# Web release URLs ({server}/.../releases/download/{tag}/{asset}) redirect
|
|
# to S3 and don't reliably support Authorization headers for private repos.
|
|
# The REST API endpoint with Accept: application/octet-stream is required.
|
|
# GITHUB_API_URL: trusted platform value, same as detected in "Determine version" step.
|
|
GITHUB_API_URL="${{ github.api_url }}"
|
|
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
RELEASE_JSON=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
|
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/tags/${VERSION}")
|
|
else
|
|
RELEASE_JSON=$(curl -sSf --connect-timeout 10 --max-time 30 \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/tags/${VERSION}")
|
|
fi
|
|
|
|
# Extract asset IDs for binary and checksums
|
|
BINARY_ASSET_ID=$(printf '%s' "$RELEASE_JSON" | python3 -c "import sys, json; assets = json.load(sys.stdin).get('assets', []); matches = [a['id'] for a in assets if a['name'] == '${BINARY}']; print(matches[0] if matches else '')")
|
|
if [ -z "$BINARY_ASSET_ID" ]; then
|
|
echo "Error: could not find asset '${BINARY}' in release ${VERSION}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
CHECKSUMS_ASSET_ID=$(printf '%s' "$RELEASE_JSON" | python3 -c "import sys, json; assets = json.load(sys.stdin).get('assets', []); matches = [a['id'] for a in assets if a['name'] == 'checksums.txt']; print(matches[0] if matches else '')")
|
|
if [ -z "$CHECKSUMS_ASSET_ID" ]; then
|
|
echo "Error: could not find asset 'checksums.txt' in release ${VERSION}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Download assets via REST API with Accept: application/octet-stream
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
curl -sSfL --connect-timeout 10 --max-time 120 \
|
|
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
|
-H "Accept: application/octet-stream" \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${BINARY_ASSET_ID}" \
|
|
-o "${{ runner.temp }}/review-bot"
|
|
curl -sSfL --connect-timeout 10 --max-time 30 \
|
|
-H "Authorization: Bearer ${ACTION_TOKEN}" \
|
|
-H "Accept: application/octet-stream" \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${CHECKSUMS_ASSET_ID}" \
|
|
-o "${{ runner.temp }}/checksums.txt"
|
|
else
|
|
curl -sSfL --connect-timeout 10 --max-time 120 \
|
|
-H "Accept: application/octet-stream" \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${BINARY_ASSET_ID}" \
|
|
-o "${{ runner.temp }}/review-bot"
|
|
curl -sSfL --connect-timeout 10 --max-time 30 \
|
|
-H "Accept: application/octet-stream" \
|
|
"${GITHUB_API_URL}/repos/${ACTION_REPO}/releases/assets/${CHECKSUMS_ASSET_ID}" \
|
|
-o "${{ runner.temp }}/checksums.txt"
|
|
fi
|
|
else
|
|
# Gitea: Direct download via web release URLs (Gitea serves assets
|
|
# directly without redirects — no -L needed).
|
|
# SECURITY: Omitting -L prevents forwarding Authorization header to
|
|
# unexpected hosts if Gitea ever introduces CDN redirects.
|
|
DOWNLOAD_URL="${SERVER_URL}/${ACTION_REPO}/releases/download/${VERSION}"
|
|
|
|
if [ -n "$ACTION_TOKEN" ]; then
|
|
curl -sSf --connect-timeout 10 --max-time 120 \
|
|
-H "Authorization: token ${ACTION_TOKEN}" \
|
|
"${DOWNLOAD_URL}/${BINARY}" -o "${{ runner.temp }}/review-bot"
|
|
curl -sSf --connect-timeout 10 --max-time 30 \
|
|
-H "Authorization: token ${ACTION_TOKEN}" \
|
|
"${DOWNLOAD_URL}/checksums.txt" -o "${{ runner.temp }}/checksums.txt"
|
|
else
|
|
curl -sSf --connect-timeout 10 --max-time 120 \
|
|
"${DOWNLOAD_URL}/${BINARY}" -o "${{ runner.temp }}/review-bot"
|
|
curl -sSf --connect-timeout 10 --max-time 30 \
|
|
"${DOWNLOAD_URL}/checksums.txt" -o "${{ runner.temp }}/checksums.txt"
|
|
fi
|
|
fi
|
|
|
|
# Verify SHA-256 checksum
|
|
# NOTE: This verifies integrity (download wasn't corrupted) but not
|
|
# authenticity — both binary and checksums come from the same server.
|
|
# For stronger guarantees, consider GPG signature verification.
|
|
cd "${{ runner.temp }}"
|
|
EXPECTED=$(grep -E "^[0-9a-f]+[[:space:]]+\*?${BINARY}$" checksums.txt | awk '{print $1}')
|
|
# sha256sum (GNU) is not available on macOS; use shasum -a 256 on darwin.
|
|
if [ "${OS}" = "darwin" ]; then
|
|
ACTUAL=$(shasum -a 256 review-bot | awk '{print $1}')
|
|
else
|
|
ACTUAL=$(sha256sum review-bot | awk '{print $1}')
|
|
fi
|
|
|
|
if [ -z "$EXPECTED" ]; then
|
|
echo "Error: no checksum found for ${BINARY}" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
|
echo "Error: checksum mismatch!" >&2
|
|
echo " Expected: $EXPECTED" >&2
|
|
echo " Actual: $ACTUAL" >&2
|
|
exit 1
|
|
fi
|
|
|
|
chmod +x "${{ runner.temp }}/review-bot"
|
|
echo "Installed review-bot-${OS}-${ARCH} ${VERSION} (checksum verified)"
|
|
|
|
- name: Run review
|
|
shell: bash
|
|
env:
|
|
VCS_URL: ${{ steps.version.outputs.server_url }}
|
|
GITEA_REPO: ${{ inputs.repo || github.repository }}
|
|
PR_NUMBER: ${{ inputs.pr-number || github.event.pull_request.number }}
|
|
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
|
REVIEWER_NAME: ${{ inputs.reviewer-name }}
|
|
LLM_BASE_URL: ${{ inputs.llm-base-url }}
|
|
LLM_API_KEY: ${{ inputs.llm-api-key }}
|
|
LLM_MODEL: ${{ inputs.llm-model }}
|
|
CONVENTIONS_FILE: ${{ inputs.conventions-file }}
|
|
PATTERNS_REPO: ${{ inputs.patterns-repo }}
|
|
PATTERNS_FILES: ${{ inputs.patterns-files }}
|
|
LLM_TEMPERATURE: ${{ inputs.temperature }}
|
|
LLM_TIMEOUT: ${{ inputs.timeout }}
|
|
LLM_PROVIDER: ${{ inputs.llm-provider }}
|
|
UPDATE_EXISTING: ${{ inputs.update-existing }}
|
|
SYSTEM_PROMPT_FILE: ${{ inputs.system-prompt-file }}
|
|
PERSONA: ${{ inputs.persona }}
|
|
PERSONA_FILE: ${{ inputs.persona-file }}
|
|
DOC_MAP_FILE: ${{ inputs.doc-map }}
|
|
DOC_MAP_MAX_BYTES: ${{ inputs.doc-map-max-bytes }}
|
|
AICORE_CLIENT_ID: ${{ inputs.aicore-client-id }}
|
|
AICORE_CLIENT_SECRET: ${{ inputs.aicore-client-secret }}
|
|
AICORE_AUTH_URL: ${{ inputs.aicore-auth-url }}
|
|
AICORE_API_URL: ${{ inputs.aicore-api-url }}
|
|
AICORE_RESOURCE_GROUP: ${{ inputs.aicore-resource-group }}
|
|
run: |
|
|
ARGS=""
|
|
if [ "${{ inputs.dry-run }}" = "true" ]; then
|
|
ARGS="--dry-run"
|
|
fi
|
|
${{ runner.temp }}/review-bot $ARGS
|