#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: Scripts/forgejo-dispatch.sh [--ref ] [--inputs ] [--inputs-file ] [--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