burrow/Scripts/forgejo-dispatch.sh
Conrad Kramer 002bd382e9
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s
Wire Forge-native release infrastructure
2026-06-07 05:51:12 -07:00

240 lines
6 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/forgejo-dispatch.sh <workflow> [--ref <ref>] [--inputs <json>] [--inputs-file <path>] [--return-run-info]
Dispatch a Burrow Forgejo workflow through the Forgejo API.
Environment:
FORGEJO_BASE_URL Default: https://git.burrow.net
FORGEJO_REPO Default: hackclub/burrow
FORGEJO_PAT Token string
FORGEJO_PAT_FILE Path to plaintext token
FORGEJO_DISPATCH_HOST_FALLBACK auto, always, or never. Default: auto
EOF
}
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}"
repo_slug="${FORGEJO_REPO:-hackclub/burrow}"
host_fallback="${FORGEJO_DISPATCH_HOST_FALLBACK:-auto}"
original_args=("$@")
workflow=""
ref="main"
inputs_json=""
return_run_info="false"
case "$host_fallback" in
auto|always|never) ;;
*)
echo "FORGEJO_DISPATCH_HOST_FALLBACK must be auto, always, or never." >&2
exit 64
;;
esac
host_dispatch() {
local reason="$1"
local fallback="${repo_root}/Scripts/forgejo-dispatch-via-host.sh"
if [[ ! -x "$fallback" ]]; then
return 1
fi
echo "Forgejo API dispatch ${reason}; retrying through the forge host." >&2
exec "$fallback" "${original_args[@]}"
}
if [[ "$host_fallback" == "always" ]]; then
if ! host_dispatch "forced by FORGEJO_DISPATCH_HOST_FALLBACK=always"; then
echo "Forge host dispatcher is not available." >&2
exit 1
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--ref)
ref="${2:-}"
shift 2
;;
--inputs)
inputs_json="${2:-}"
shift 2
;;
--inputs-file)
inputs_json="$(cat "${2:-}")"
shift 2
;;
--return-run-info)
return_run_info="true"
shift
;;
*)
if [[ -z "$workflow" ]]; then
workflow="$1"
shift
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 64
fi
;;
esac
done
if [[ -z "$workflow" ]]; then
echo "Missing workflow name or file, for example build-release.yml." >&2
usage >&2
exit 64
fi
extract_dispatcher_token() {
python3 - "$1" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
in_forgejo = False
for line in path.read_text(encoding="utf-8").splitlines():
if re.match(r"^[A-Za-z0-9_-]+:", line):
in_forgejo = line.startswith("forgejo:")
continue
if not in_forgejo:
continue
match = re.match(r"\s+token:\s*['\"]?([^'\"]+)['\"]?\s*$", line)
if match:
print(match.group(1))
break
PY
}
decrypt_age_secret() {
local path="$1"
if [[ ! -f "$path" ]]; then
return 1
fi
if command -v nix >/dev/null 2>&1; then
nix run "${repo_root}#agenix" -- -d "$path" 2>/dev/null && return 0
fi
if command -v agenix >/dev/null 2>&1; then
agenix -d "$path" 2>/dev/null && return 0
fi
return 1
}
pat="${FORGEJO_PAT:-}"
pat_source="none"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT"
fi
if [[ -z "$pat" && -n "${FORGEJO_PAT_FILE:-}" && -f "$FORGEJO_PAT_FILE" ]]; then
pat="$(tr -d '\r\n' < "$FORGEJO_PAT_FILE")"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT_FILE"
fi
fi
if [[ -z "$pat" ]]; then
for default_file in \
"${repo_root}/intake/forgejo_dispatch_pat.txt" \
"${repo_root}/intake/forgejo_nsc_dispatcher.yaml"
do
if [[ -s "$default_file" ]]; then
if [[ "$default_file" == *.yaml ]]; then
pat="$(extract_dispatcher_token "$default_file" | tr -d '\r\n')"
else
pat="$(tr -d '\r\n' < "$default_file")"
fi
fi
if [[ -n "$pat" ]]; then
pat_source="$default_file"
break
fi
done
fi
if [[ -z "$pat" ]]; then
decrypted_config="$(mktemp)"
trap 'rm -f "$decrypted_config"' EXIT
if decrypt_age_secret "${repo_root}/secrets/infra/forgejo-nsc-dispatcher-config.age" > "$decrypted_config"; then
pat="$(extract_dispatcher_token "$decrypted_config" | tr -d '\r\n')"
if [[ -n "$pat" ]]; then
pat_source="secrets/infra/forgejo-nsc-dispatcher-config.age"
fi
fi
fi
if [[ -z "$pat" ]]; then
if [[ "$host_fallback" == "auto" ]]; then
if host_dispatch "has no local token"; then
exit 0
fi
fi
echo "Forgejo token not found. Set FORGEJO_PAT or FORGEJO_PAT_FILE." >&2
exit 1
fi
if [[ -n "$inputs_json" ]]; then
if ! printf '%s\n' "$inputs_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
echo "--inputs must be a JSON object." >&2
exit 64
fi
inputs_json="$(printf '%s\n' "$inputs_json" | jq -c 'with_entries(.value |= tostring)')"
payload="$(jq -n --arg ref "$ref" --argjson inputs "$inputs_json" --argjson return_run_info "$return_run_info" \
'{ref:$ref, inputs:$inputs} + (if $return_run_info then {return_run_info:true} else {} end)')"
else
payload="$(jq -n --arg ref "$ref" --argjson return_run_info "$return_run_info" \
'{ref:$ref} + (if $return_run_info then {return_run_info:true} else {} end)')"
fi
workflow_path="$(python3 - "$workflow" <<'PY'
import sys
import urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
)"
url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches"
response_file="$(mktemp)"
trap 'rm -f "$response_file" "${decrypted_config:-}"' EXIT
curl_status=0
http_code="$(
curl -sS -o "$response_file" -w '%{http_code}' -X POST \
-H "Authorization: token ${pat}" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"$url"
)" || curl_status="$?"
if [[ "$curl_status" != "0" || "$http_code" != 2* ]]; then
if [[ "$host_fallback" == "auto" && "$pat_source" != "FORGEJO_PAT" && ( "$http_code" == "401" || "$http_code" == "403" ) ]]; then
if host_dispatch "returned HTTP ${http_code} with ${pat_source}"; then
exit 0
fi
fi
cat "$response_file" >&2 || true
if [[ "$curl_status" != "0" ]]; then
exit "$curl_status"
fi
exit 22
fi
cat "$response_file"
if [[ "$return_run_info" != "true" ]]; then
echo "Dispatched ${workflow} on ${ref}."
fi