# Reusable dispatch workflow for per-repo installation mode. # Routes events to the appropriate stage and runs the agent inline. # This is the dispatch workflow for per-repo installations. # # ADR 62: Stage workflows are inlined into this file to avoid version skew. # The former per-repo mode referenced reusable-{stage}.yml@v0 via uses: lines, # which hardcoded @v0 and didn't pick up dev changes. Inlining eliminates the # second workflow_call hop. Standalone reusable-prioritize.yml remains for the # explicit prioritize caller. # # Pre-computed matrix: The optional `matrix` input allows custom pollers in # external repos to skip the dispatch step and directly invoke harness agents. # When provided, the harness-dispatch job is skipped, and harness-run uses the # caller-provided matrix. This enables reuse without extracting separate workflows # while preserving ADR 62's inlining decision. # # Concurrency: each stage job declares a per-role cancel-in-progress group # (fullsend-{stage}-…). Roles operate independently — review dispatches do # not cancel triage, code, fix, etc. # # Flow: shim (per-repo) → reusable-dispatch.yml (route + inlined stage logic) # Or: custom poller → reusable-dispatch.yml (pre-computed matrix → harness-run) # Nesting: 2 levels of workflow_call (previously 3 before inlining) # # Security: all user-controlled inputs (comment body, labels, usernames) # are passed via env: variables, not interpolated in run: blocks. # The route and harness-dispatch jobs need OIDC for mint-backed identity # lookup. Callers must grant id-token: write; keep the token within each job # and never write it to workflow outputs, logs, or matrix data. name: Dispatch on: workflow_call: inputs: event_action: description: "The event action (github.event.action) forwarded by the shim. Not required when matrix is provided." required: false type: string default: "" install_mode: description: "Legacy compatibility input; ignored because installations are always per-repo" required: false type: string default: "per-repo" mint_url: description: "Token mint URL for OIDC token exchange" required: true type: string gcp_region: description: "GCP region for Vertex AI" required: true type: string fullsend_version: description: "Fullsend CLI version to use" required: false type: string default: "" fullsend_ai_ref: description: "Deprecated: stage workflows now use job.workflow_sha. Kept so existing shims that pass this input do not break." type: string required: false default: v0 project_number: description: "GitHub Projects V2 project number for RICE scoring (prioritize agent)" required: false type: string default: "" runner_image: description: GitHub Actions runner image for agent jobs. type: string required: false default: "ubuntu-24.04" matrix: description: "Pre-computed harness matrix (skips harness-dispatch if provided). Enables custom pollers to invoke harness agents directly. Format matches fullsend dispatch --output-driver gha-matrix." required: false type: string default: "" jira_base_url: description: "Jira instance base URL (e.g., https://mysite.atlassian.net) for Jira-based agents" required: false type: string default: "" secrets: FULLSEND_GCP_WIF_PROVIDER: required: false FULLSEND_GCP_PROJECT_ID: required: false FULLSEND_OPENAI_API_KEY: required: false FULLSEND_INFERENCE_GATEWAY_API_KEY: required: false OTEL_EXPORTER_OTLP_TRACES_HEADERS: description: >- OTLP headers for ADR 0050 Level 2 trace export, forwarded to all agent stages (baggage-style k=v,k=v; may carry auth, hence a secret). Optional; unset leaves export disabled unless the endpoint variable alone suffices. required: false OTEL_EXPORTER_OTLP_HEADERS: required: false JIRA_TOKEN: description: "Jira Cloud API token for Jira-based agents" required: false JIRA_USER_EMAIL: description: "Jira account email for Basic auth" required: false FULLSEND_GITHUB_RUNNER_SECRETS: description: >- JSON object of name to value for host-side scripts (pre_script, post_script, validation_loop). fullsend run resolves the names the harness references in env.runner; the values reach host-side scripts, never the sandbox (ADR 0136). required: false jobs: route: name: Route if: ${{ inputs.matrix == '' }} runs-on: ${{ inputs.runner_image }} permissions: contents: read id-token: write issues: read pull-requests: read outputs: stage: ${{ steps.agent-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }} trigger_source: ${{ steps.route.outputs.trigger_source }} event_payload: ${{ steps.payload.outputs.event_payload }} steps: - name: Validate event_action if: ${{ inputs.event_action == '' }} run: | echo "::error::event_action is required when matrix is not provided" exit 1 - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # checkout@v7 blocks fork PR checkouts on pull_request_target by # default. Safe here: only .fullsend/ config is read, no fork code # is executed, and credentials are not persisted. Pin to base branch # SHA for PR-scoped events so kill-switch / role gating always reads # trusted config — PR authors cannot self-authorize. ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} sparse-checkout: | .fullsend/config.yaml .fullsend/config.base.yaml /OWNERS /OWNERS_ALIASES sparse-checkout-cone-mode: false - name: Determine stage id: bash-route env: EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ inputs.event_action }} COMMENT_BODY: ${{ github.event.comment.body }} COMMENT_USER_TYPE: ${{ github.event.comment.user.type }} COMMENT_USER_LOGIN: ${{ github.event.comment.user.login }} ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }} PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} ISSUE_IS_PR: ${{ github.event.issue.pull_request && 'true' || 'false' }} ISSUE_USER_LOGIN: ${{ github.event.issue.user.login }} EVENT_SENDER_LOGIN: ${{ github.event.sender.login }} REVIEW_STATE: ${{ github.event.review.state }} REVIEW_USER_LOGIN: ${{ github.event.review.user.login }} TRIGGERING_LABEL: ${{ github.event.label.name }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PR_BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }} PR_USER_LOGIN: ${{ github.event.pull_request.user.login }} ORG_NAME: ${{ github.repository_owner }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail STAGE="" TRIGGER_SOURCE="" # Report whether the lowercased user is in an OWNERS list. An entry # that names an OWNERS_ALIASES key matches only that alias's members, # never a login of the same name. A yq/jq failure means "no match", # so the caller falls through to the collaborator API; a malformed # OWNERS_ALIASES disables OWNERS matching (alias keys cannot be told # apart from logins without it). _owners_has_user() { local key="${1}" user="${2}" aliases="" entry if [[ -f "OWNERS_ALIASES" ]]; then aliases=$(yq -o=json -I=0 '.aliases // {}' OWNERS_ALIASES 2>/dev/null) || return 1 # Same shape the Go resolver accepts; case-folded duplicate keys are malformed. aliases=$(jq -ce 'select(type == "object" and all(.[]; . == null or (type == "array" and all(.[]; type != "array" and type != "object"))) and (keys | length) == (keys | map(ascii_downcase) | unique | length)) | with_entries(.key |= ascii_downcase)' <<<"${aliases}" 2>/dev/null) || return 1 fi # A login equal to any alias key never matches (nested aliases included). if [[ -n "${aliases}" ]] && jq -e --arg u "${user}" 'has($u)' <<<"${aliases}" >/dev/null 2>&1; then return 1 fi while IFS= read -r entry; do [[ ! "${entry}" =~ ^[a-zA-Z0-9_-]+$ ]] && continue entry="${entry,,}" if [[ -n "${aliases}" ]] && jq -e --arg a "${entry}" 'has($a)' <<<"${aliases}" >/dev/null 2>&1; then jq -e --arg a "${entry}" --arg u "${user}" '.[$a] // [] | any(.[]; . != null and (tostring | ascii_downcase) == $u)' <<<"${aliases}" >/dev/null 2>&1 && return 0 continue fi [[ "${entry}" == "${user}" ]] && return 0 done < <(yq ".${key}[]" OWNERS 2>/dev/null) return 1 } # Collaborator role vs min (write|triage). See #5223 / ADR 0054. # API resolves org membership regardless of visibility (gh-aw-mcpg#2862). # Custom role names resolve from GitHub's effective permission flags, # then the legacy permission field; anything else is none (#7834). has_repo_permission() { local username="${1:-}" min="${2:-write}" role api_err [[ -z "${username}" ]] && return 1 # OWNERS-file authorization (opt-in via authorization providers list in config.yaml). # Approvers get write-equivalent access; reviewers get triage-equivalent. # Safe: sparse-checkout pins to base branch SHA, so PR authors cannot # self-authorize by adding themselves to OWNERS. if [[ -f "OWNERS" && -f ".fullsend/config.yaml" ]]; then if [[ "$(yq '[.authorization[]? | select(.provider == "owners_file")] | length' .fullsend/config.yaml 2>/dev/null)" =~ ^[1-9] ]]; then if [[ ! "${username}" =~ ^[a-zA-Z0-9-]+$ ]]; then echo "::warning::OWNERS auth skipped: username contains characters outside [A-Za-z0-9-]" >&2 else local lc_user="${username,,}" case "${min}" in write|triage) if _owners_has_user approvers "${lc_user}"; then echo "::notice::OWNERS file resolved user '${username}' as approver (requested: ${min})" return 0 fi ;;& triage) if _owners_has_user reviewers "${lc_user}"; then echo "::notice::OWNERS file resolved user '${username}' as reviewer (requested: ${min})" return 0 fi ;; esac fi fi fi api_err=$(mktemp) || { echo "::warning::Failed to create temp file for permission check of ${username}" >&2 return 1 } role=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${username}/permission" \ --jq '.user.permissions as $p | if (.role_name | type) != "string" or .role_name == "" then "none" elif (.role_name | IN("admin", "maintain", "write", "triage", "read")) then .role_name elif $p == null then if (.permission | IN("admin", "write", "read")) then .permission else "none" end elif ($p | type) == "object" then if $p.admin == true then "admin" elif $p.maintain == true then "maintain" elif $p.push == true then "write" elif $p.triage == true then "triage" elif $p.pull == true then "read" else "none" end else "none" end' 2>"${api_err}") || { echo "::warning::Permission API call failed for ${username}: $(cat "${api_err}")" >&2 rm -f "${api_err}" return 1 } rm -f "${api_err}" case "${role}" in admin|maintain|write) return 0 ;; triage) [[ "${min}" == "triage" ]] && return 0 || return 1 ;; *) return 1 ;; esac } has_write_permission() { has_repo_permission "${1:-}" write; } # Slash-command auth; optional $1 = write|triage (default write). is_authorized() { has_repo_permission "${COMMENT_USER_LOGIN}" "${1:-write}" } # Event-actor auth; $1=user, optional $2 = write|triage (default write). is_event_actor_authorized() { has_repo_permission "${1:-}" "${2:-write}" } has_label() { local needle="$1" local csv="${2:-${ISSUE_LABELS}}" IFS=',' read -ra labels <<< "${csv}" for l in "${labels[@]}"; do [[ "$l" == "$needle" ]] && return 0 done return 1 } COMMAND="" if [[ -n "${COMMENT_BODY:-}" ]]; then COMMAND="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | tr -d '\r' | awk '{print $1}' | sed 's/[.,;:!?]*$//')" fi case "${EVENT_NAME}" in issue_comment) case "${COMMAND}" in /fs-triage) if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized triage; then STAGE="triage" fi ;; /fs-code) if [[ "${ISSUE_IS_PR}" == "false" ]]; then if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then STAGE="code" fi fi ;; /fs-review) if [[ "${ISSUE_IS_PR}" == "true" ]]; then if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized triage; then STAGE="review" fi fi ;; /fs-fix) if [[ "${ISSUE_IS_PR}" == "true" ]]; then if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then STAGE="fix" TRIGGER_SOURCE="${COMMENT_USER_LOGIN}" fi fi ;; /fs-retro) if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then STAGE="retro" fi ;; /fs-prioritize) if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then STAGE="prioritize" fi ;; *) ;; esac ;; issues) if [[ "${EVENT_ACTION}" == "opened" ]]; then if is_event_actor_authorized "${ISSUE_USER_LOGIN}" triage; then STAGE="triage" fi elif [[ "${EVENT_ACTION}" == "edited" ]]; then if is_event_actor_authorized "${EVENT_SENDER_LOGIN}" triage; then STAGE="triage" fi elif [[ "${EVENT_ACTION}" == "labeled" ]]; then if [[ "${TRIGGERING_LABEL}" == "ready-for-triage" ]]; then STAGE="triage" elif [[ "${TRIGGERING_LABEL}" == "ready-to-code" ]]; then # Mutation stage: write+ labeler, or bots (agent handoff). if [[ "${EVENT_SENDER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${EVENT_SENDER_LOGIN}"; then STAGE="code" fi elif [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]]; then if [[ "${ISSUE_IS_PR}" == "true" ]]; then STAGE="review" fi fi fi ;; pull_request_target) case "${EVENT_ACTION}" in opened|synchronize|ready_for_review) if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${PR_USER_LOGIN}" triage; then STAGE="review" fi ;; labeled) if [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]]; then STAGE="review" fi ;; closed) # Intentional ungated: any closer may trigger read-only retro # (lifecycle accounting). See ADR 0054 #5223 note. STAGE="retro" ;; esac ;; pull_request_review) if [[ "${EVENT_ACTION}" == "submitted" && "${REVIEW_STATE}" == "changes_requested" ]]; then REVIEW_BOT="${ORG_NAME}-review[bot]" SHARED_REVIEW_BOT="fullsend-ai-review[bot]" CUSTOM_REVIEW_BOT="" if [[ -n "${FULLSEND_APP_SET:-}" ]]; then CUSTOM_REVIEW_BOT="${FULLSEND_APP_SET}-review[bot]" fi if [[ "${REVIEW_USER_LOGIN}" == "${REVIEW_BOT}" || "${REVIEW_USER_LOGIN}" == "${SHARED_REVIEW_BOT}" || "${REVIEW_USER_LOGIN}" == "${CUSTOM_REVIEW_BOT}" ]]; then # Block fork PRs — fail closed if we cannot determine. # Human PRs need fullsend-fix + write+ author (not triage-only). if [[ -n "${PR_HEAD_REPO}" && -n "${PR_BASE_REPO}" ]]; then if [[ "${PR_HEAD_REPO}" == "${PR_BASE_REPO}" ]]; then if ! has_label "fullsend-no-fix" "${PR_LABELS}"; then if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]]; then STAGE="fix" TRIGGER_SOURCE="${REVIEW_USER_LOGIN}" elif has_label "fullsend-fix" "${PR_LABELS}" && has_repo_permission "${PR_USER_LOGIN}" write; then STAGE="fix" TRIGGER_SOURCE="${REVIEW_USER_LOGIN}" fi fi fi fi fi fi ;; esac if [[ -z "${STAGE}" ]]; then echo "No stage matched — skipping dispatch" echo "stage=" >> "${GITHUB_OUTPUT}" exit 0 fi echo "Routed to stage: ${STAGE}" echo "stage=${STAGE}" >> "${GITHUB_OUTPUT}" echo "trigger_source=${TRIGGER_SOURCE}" >> "${GITHUB_OUTPUT}" # CEL routing (#8726). A repository moves a built-in stage's routing # from the bash rules above to a CEL trigger by setting agents[].trigger # on the agent's entry in config.yaml; a harness trigger: never does. # The opt-in step picks the agent whose CEL route covers this event # (review, retro, triage or prioritize) and looks for that entry; only # then do the other cel-* steps install the CLI and let `fullsend # dispatch --route ` decide. Every cel-* step continues on error: # when a step or the config fails, "Finalize stage" keeps the bash # stage, so a CEL failure never stops another stage. A trigger that # compiles but fails to evaluate on the event is a CEL decision (skip), # not a failure. Without a candidate entry "Finalize stage" passes the # bash stage through unchanged. - name: Detect CEL route opt-in id: cel-opt-in if: ${{ github.event_name == 'pull_request_target' || github.event_name == 'issue_comment' || github.event_name == 'issues' }} continue-on-error: true shell: bash env: EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ inputs.event_action }} COMMENT_BODY: ${{ github.event.comment.body }} ISSUE_IS_PR: ${{ github.event.issue.pull_request && 'true' || 'false' }} BASH_STAGE: ${{ steps.bash-route.outputs.stage }} run: | set -euo pipefail # Pick the one agent whose CEL route covers this event, among those # `fullsend dispatch --route` supports (routableAgents in # internal/cli/dispatch_route.go), and only when bash chose that # stage or none. Adding an agent: its events in the selection below, # its routableAgents entry, and this step's if: widened to the # agent's events. # # The selection picks at most one agent. An event can fit more than # one (a comment on a pull request fits review, retro, triage and # prioritize): the stage bash chose wins when the event fits it. # When bash chose none, a comment's slash command picks retro, # triage or prioritize, and otherwise review takes pull request # comments and triage takes issue comments; an issue event goes to # triage, a closed pull request to retro and any other pull request # event to review. COMMAND="" if [[ -n "${COMMENT_BODY:-}" ]]; then # Same parsing as "Determine stage". COMMAND="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | tr -d '\r' | awk '{print $1}' | sed 's/[.,;:!?]*$//')" fi PR_CLOSED=false if [[ "${EVENT_NAME}" == "pull_request_target" && "${EVENT_ACTION:-}" == "closed" ]]; then PR_CLOSED=true fi fits="" # review: pull request events other than closed, and comments on a # pull request. if [[ ( "${EVENT_NAME}" == "pull_request_target" && "${PR_CLOSED}" == "false" ) || ( "${EVENT_NAME}" == "issue_comment" && "${ISSUE_IS_PR}" == "true" ) ]]; then fits+=" review" fi # retro: a closed pull request, and comments on an issue or a pull # request. if [[ "${PR_CLOSED}" == "true" || "${EVENT_NAME}" == "issue_comment" ]]; then fits+=" retro" fi # triage: issue events, and comments on an issue or a pull request. if [[ "${EVENT_NAME}" == "issues" || "${EVENT_NAME}" == "issue_comment" ]]; then fits+=" triage" fi # prioritize: comments on an issue or a pull request. if [[ "${EVENT_NAME}" == "issue_comment" ]]; then fits+=" prioritize" fi agent="" if [[ -n "${BASH_STAGE}" ]]; then if [[ "${fits} " == *" ${BASH_STAGE} "* ]]; then agent="${BASH_STAGE}" fi elif [[ "${EVENT_NAME}" == "issue_comment" ]]; then case "${COMMAND}" in /fs-retro) agent="retro" ;; /fs-triage) agent="triage" ;; /fs-prioritize) agent="prioritize" ;; *) if [[ "${ISSUE_IS_PR}" == "true" ]]; then agent="review" else agent="triage" fi ;; esac elif [[ "${EVENT_NAME}" == "issues" ]]; then agent="triage" elif [[ "${PR_CLOSED}" == "true" ]]; then agent="retro" elif [[ "${EVENT_NAME}" == "pull_request_target" ]]; then agent="review" fi # A candidate is the agent's entry with an agents[].trigger, the only # opt-in (a harness trigger: never routes a built-in), matched the # way config.AgentEntry.DerivedName names entries: the name, else # the source file name without its last #fragment and extension, # case-insensitive. `fullsend dispatch --route` makes the decision. # shellcheck disable=SC2016 query='[.agents[]? | select((tag == "!!map") and has("trigger") and ((.trigger | tostring) != "") and ((.trigger | tostring) != "null") and ((has("name") and ((.name | tostring | downcase) == strenv(AGENT))) or (((has("name") | not) or ((.name | tostring) == "") or ((.name | tostring) == "null")) and has("source") and (.source | tostring | sub("#[^#]*$"; "") | test("(?i)(^|/)" + strenv(AGENT) + "(\\.[^./]*)?$")))))] | length' # gha_text makes a value safe inside a ::notice::/::warning::/::error:: # message: it drops control characters (CR/LF included), encodes % # and breaks up ::. Copied verbatim into each CEL route step that # prints workflow commands, since steps share no shell state. gha_text() { local v v=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177') v=${v//\%/%25} while [[ "${v}" == *::* ]]; do v=${v//::/: :}; done printf '%s' "${v}" } opt_in=false if [[ -n "${agent}" && ! "${agent}" =~ ^[a-z][a-z0-9_-]*$ ]]; then echo "::error::CEL route: invalid agent name '$(gha_text "${agent}")' in the opt-in gate" exit 1 fi if [[ -n "${agent}" ]]; then yq_err=$(mktemp) for f in .fullsend/config.yaml .fullsend/config.base.yaml; do if [[ ! -f "${f}" ]]; then continue fi # An unreadable file lets the CLI decide and report it. if ! count=$(AGENT="${agent}" yq "${query}" "${f}" 2>"${yq_err}"); then echo "::warning::CEL route: could not read ${f} with yq ($(gha_text "$(head -c 300 "${yq_err}")")); letting fullsend dispatch decide" opt_in=true break fi if [[ "${count}" != "0" ]]; then opt_in=true break fi done rm -f "${yq_err}" fi { echo "agent=${agent}" echo "opt_in=${opt_in}" } >> "${GITHUB_OUTPUT}" - name: Checkout config for CEL route id: cel-config if: steps.cel-opt-in.outputs.opt_in == 'true' continue-on-error: true uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Same trusted ref as the checkout above: the base branch for PR events. ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} path: cel-route persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} sparse-checkout: | .fullsend/ /OWNERS /OWNERS_ALIASES .defaults/action.yml sparse-checkout-cone-mode: false - name: Detect CLI install mode for CEL route id: cel-cli-mode if: steps.cel-config.outcome == 'success' continue-on-error: true shell: bash run: | set -euo pipefail if [[ -f cel-route/.defaults/action.yml || -f cel-route/.fullsend/.defaults/action.yml ]]; then echo "mode=vendored" >> "${GITHUB_OUTPUT}" else echo "mode=upstream" >> "${GITHUB_OUTPUT}" fi # Later runs on the same workflow_sha reuse whichever of this job and # harness-dispatch saved the CLI first. - name: Restore fullsend CLI cache for CEL route id: cel-cli-cache if: steps.cel-config.outcome == 'success' continue-on-error: true uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/fullsend-cache key: fullsend-cli-${{ steps.cel-cli-mode.outputs.mode }}-${{ job.workflow_sha }} # Vendored installs copy the committed binary directly, so the step does # not depend on where the vendored defaults (.defaults/ or # .fullsend/.defaults/) live. - name: Install fullsend CLI for CEL route (vendored) if: steps.cel-config.outcome == 'success' && steps.cel-cli-mode.outputs.mode == 'vendored' && steps.cel-cli-cache.outputs.cache-hit != 'true' continue-on-error: true shell: bash run: | set -euo pipefail if [[ ! -f cel-route/.fullsend/bin/fullsend ]]; then echo "::error::broken --vendor install: vendored marker present but .fullsend/bin/fullsend is missing" exit 1 fi mkdir -p "${RUNNER_TEMP}/fullsend" cp cel-route/.fullsend/bin/fullsend "${RUNNER_TEMP}/fullsend/fullsend" chmod +x "${RUNNER_TEMP}/fullsend/fullsend" echo "${RUNNER_TEMP}/fullsend" >> "${GITHUB_PATH}" - name: Checkout upstream install action for CEL route if: steps.cel-config.outcome == 'success' && steps.cel-cli-mode.outputs.mode == 'upstream' && steps.cel-cli-cache.outputs.cache-hit != 'true' continue-on-error: true uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .workflow-actions fetch-depth: 1 sparse-checkout: | .github/actions/install-fullsend-cli/ sparse-checkout-cone-mode: false # Installs from job.workflow_sha, as harness-dispatch does. A caller that # pins fullsend_version runs the agent's job on that version instead, so # the trigger can come from a different agents ref than the one the # agent's job runs. - name: Install fullsend CLI for CEL route (upstream workflow) if: steps.cel-config.outcome == 'success' && steps.cel-cli-mode.outputs.mode == 'upstream' && steps.cel-cli-cache.outputs.cache-hit != 'true' continue-on-error: true uses: ./.workflow-actions/.github/actions/install-fullsend-cli with: mode: upstream workflow_repository: ${{ job.workflow_repository }} workflow_sha: ${{ job.workflow_sha }} github_token: ${{ github.token }} - name: Expose cached fullsend CLI for CEL route if: steps.cel-config.outcome == 'success' && steps.cel-cli-cache.outputs.cache-hit == 'true' continue-on-error: true shell: bash run: | set -euo pipefail install_dir="${RUNNER_TEMP}/fullsend" mkdir -p "${install_dir}" cp "${RUNNER_TEMP}/fullsend-cache/fullsend" "${install_dir}/fullsend" chmod +x "${install_dir}/fullsend" echo "${install_dir}" >> "${GITHUB_PATH}" - name: Prepare fullsend CLI cache artifact for CEL route if: steps.cel-config.outcome == 'success' && steps.cel-cli-cache.outputs.cache-hit != 'true' continue-on-error: true shell: bash run: | set -euo pipefail mkdir -p "${RUNNER_TEMP}/fullsend-cache" cp "${RUNNER_TEMP}/fullsend/fullsend" "${RUNNER_TEMP}/fullsend-cache/fullsend" - name: Save fullsend CLI cache for CEL route if: steps.cel-config.outcome == 'success' && steps.cel-cli-cache.outputs.cache-hit != 'true' continue-on-error: true uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/fullsend-cache key: fullsend-cli-${{ steps.cel-cli-mode.outputs.mode }}-${{ job.workflow_sha }} - name: Evaluate CEL route id: cel-route if: steps.cel-config.outcome == 'success' continue-on-error: true shell: bash env: GH_TOKEN: ${{ github.token }} GITHUB_EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ inputs.event_action }} ROUTE_AGENT: ${{ steps.cel-opt-in.outputs.agent }} FULLSEND_MINT_URL: ${{ inputs.mint_url }} run: | set -euo pipefail # gha_text makes a value safe inside a ::notice::/::warning::/::error:: # message: it drops control characters (CR/LF included), encodes % # and breaks up ::. Copied verbatim into each CEL route step that # prints workflow commands, since steps share no shell state. gha_text() { local v v=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177') v=${v//\%/%25} while [[ "${v}" == *::* ]]; do v=${v//::/: :}; done printf '%s' "${v}" } DECISION_JSON=$(fullsend dispatch \ --input-driver gha-event \ --config-dir cel-route/.fullsend \ --event-action "${EVENT_ACTION}" \ --route "${ROUTE_AGENT}") printf '%s\n' "${DECISION_JSON}" DECISION=$(jq -r '.decision // ""' <<<"${DECISION_JSON}") case "${DECISION}" in run|skip|bash) ;; *) echo "::error::Unexpected CEL route decision: $(gha_text "${DECISION}")" exit 1 ;; esac REASON=$(jq -r '.reason // ""' <<<"${DECISION_JSON}" | tr -d '\r\n') HARNESS=$(jq -r '.harness // ""' <<<"${DECISION_JSON}" | tr -d '\r\n') { echo "decision=${DECISION}" echo "reason=${REASON}" echo "harness=${HARNESS}" } >> "${GITHUB_OUTPUT}" - name: Finalize stage id: route shell: bash env: BASH_STAGE: ${{ steps.bash-route.outputs.stage }} BASH_TRIGGER_SOURCE: ${{ steps.bash-route.outputs.trigger_source }} ROUTE_AGENT: ${{ steps.cel-opt-in.outputs.agent }} CEL_OPT_IN_OUTCOME: ${{ steps.cel-opt-in.outcome }} CEL_CONFIG_OUTCOME: ${{ steps.cel-config.outcome }} CEL_ROUTE_OUTCOME: ${{ steps.cel-route.outcome }} CEL_ROUTE_DECISION: ${{ steps.cel-route.outputs.decision }} CEL_ROUTE_REASON: ${{ steps.cel-route.outputs.reason }} CEL_ROUTE_HARNESS: ${{ steps.cel-route.outputs.harness }} run: | set -euo pipefail # gha_text makes a value safe inside a ::notice::/::warning::/::error:: # message: it drops control characters (CR/LF included), encodes % # and breaks up ::. Copied verbatim into each CEL route step that # prints workflow commands, since steps share no shell state. gha_text() { local v v=$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177') v=${v//\%/%25} while [[ "${v}" == *::* ]]; do v=${v//::/: :}; done printf '%s' "${v}" } STAGE="${BASH_STAGE}" TRIGGER_SOURCE="${BASH_TRIGGER_SOURCE}" # The cel-* steps did not run (no routable agent for this event, # or no candidate entry): pass the bash stage through. if [[ "${CEL_OPT_IN_OUTCOME}" == "failure" ]]; then echo "::error::CEL route opt-in check failed; keeping the bash stage '$(gha_text "${STAGE}")'. See the CEL route steps." elif [[ -n "${CEL_CONFIG_OUTCOME}" && "${CEL_CONFIG_OUTCOME}" != "skipped" ]]; then if [[ ! "${ROUTE_AGENT}" =~ ^[a-z][a-z0-9_-]*$ || "${CEL_CONFIG_OUTCOME}" != "success" || "${CEL_ROUTE_OUTCOME}" != "success" ]]; then echo "::error::CEL route failed; keeping the bash stage '$(gha_text "${STAGE}")'. See the CEL route steps." else echo "::notice::CEL route (${ROUTE_AGENT}): bash=$(gha_text "${STAGE:-none}") cel=$(gha_text "${CEL_ROUTE_DECISION}") reason=$(gha_text "${CEL_ROUTE_REASON}") harness=$(gha_text "${CEL_ROUTE_HARNESS}")" ROUTE_SOURCE="cel" case "${CEL_ROUTE_DECISION}" in run) STAGE="${ROUTE_AGENT}" ;; skip) if [[ "${STAGE}" == "${ROUTE_AGENT}" ]]; then STAGE="" fi ;; *) ROUTE_SOURCE="bash" ;; esac echo "${ROUTE_AGENT}_route=${ROUTE_SOURCE}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" if [[ "${STAGE}" != "${BASH_STAGE}" ]]; then echo "CEL route changed the stage: '${BASH_STAGE}' -> '${STAGE}'" fi fi fi echo "stage=${STAGE}" >> "${GITHUB_OUTPUT}" echo "trigger_source=${TRIGGER_SOURCE}" >> "${GITHUB_OUTPUT}" - name: Validate routed stage if: steps.route.outputs.stage != '' env: STAGE: ${{ steps.route.outputs.stage }} TRIGGER_SOURCE: ${{ steps.route.outputs.trigger_source }} run: | set -euo pipefail if [[ ! "$STAGE" =~ ^[a-z][a-z0-9_-]*$ ]]; then echo "::error::Invalid stage name: must start with lowercase letter and contain only [a-z0-9_-]" exit 1 fi if [[ -n "${TRIGGER_SOURCE:-}" ]]; then if [[ ! "$TRIGGER_SOURCE" =~ ^[a-zA-Z0-9_-]+(\[bot\])?$ ]]; then echo "::error::Invalid trigger_source format: must be alphanumeric with optional [bot] suffix" exit 1 fi fi - name: Check kill switch if: steps.route.outputs.stage != '' run: | set -euo pipefail # Mirrors internal/config's IsKillSwitchActive(): if # config.yaml sets kill_switch at all, that value is final # and config.base.yaml is ignored; config.base.yaml is only # consulted when config.yaml is silent on the key. # -o=json normalizes every YAML null spelling (bare key, ~, # Null, NULL) to the literal "null", and the true/false # case variants (True, FALSE, ...) to lowercase. It does not # normalize the YAML 1.1 word booleans yq treats as plain # strings (yes/no/on/off print as quoted JSON strings, e.g. # "yes"), so yes is matched explicitly below, where # go-yaml's lenient *bool decoding also treats it as active. # on/off and the single-letter y/n forms are deliberately # not matched. -o=json also keeps surrounding quotes on any # scalar that was itself quoted in the source YAML (e.g. # kill_switch: "true" prints as the JSON string "true", not # the JSON boolean true), so those quotes are stripped below # before comparing, matching go-yaml's lenient *bool # decoding of a quoted "true"/"yes" scalar. KILL_SWITCH="false" OVERLAY_KS="null" if [[ -f .fullsend/config.yaml ]]; then OVERLAY_KS=$(yq -o=json '.kill_switch' .fullsend/config.yaml) fi if [[ "$OVERLAY_KS" != "null" ]]; then KILL_SWITCH="$OVERLAY_KS" elif [[ -f .fullsend/config.base.yaml ]]; then BASE_KS=$(yq -o=json '.kill_switch' .fullsend/config.base.yaml) if [[ "$BASE_KS" != "null" ]]; then KILL_SWITCH="$BASE_KS" fi fi KILL_SWITCH="${KILL_SWITCH,,}" KILL_SWITCH="${KILL_SWITCH#\"}" KILL_SWITCH="${KILL_SWITCH%\"}" if [[ "$KILL_SWITCH" == "true" || "$KILL_SWITCH" == "yes" ]]; then echo "::error::Kill switch is active — all agent dispatch halted" echo "::error::Set kill_switch: false in .fullsend/config.yaml or .fullsend/config.base.yaml to resume" exit 1 fi - name: Validate agents config if: steps.route.outputs.stage != '' run: | set -euo pipefail if [[ ! -f .fullsend/config.yaml ]]; then exit 0 fi AGENTS_TYPE=$(yq '.agents | type' .fullsend/config.yaml 2>/dev/null || echo "") case "$AGENTS_TYPE" in "!!null"|"") exit 0 ;; "!!seq") ;; *) echo "::error::config.yaml: 'agents' must be a list (got $AGENTS_TYPE)"; exit 1 ;; esac AGENT_COUNT=$(yq '.agents | length' .fullsend/config.yaml 2>/dev/null || echo "0") if [[ "$AGENT_COUNT" == "0" ]]; then exit 0 fi BAD_DISABLED=$(yq '.agents[] | select(.enabled == false and (.name == null or .name == "")) | line' .fullsend/config.yaml 2>/dev/null || echo "") if [[ -n "$BAD_DISABLED" ]]; then echo "::error::config.yaml: disabled agent entry without a name field — add 'name:' to identify which agent to disable" exit 1 fi # An enabled entry may omit source when it only tunes a built-in # agent by name (runtime/model/effort/subagents, ADR 0091; trigger, # #8726), the settings config.AgentEntry.HasSettings counts. A blank # trigger counts as unset, as on the Go side. trigger is checked for # null explicitly: `//` would also replace an unquoted false, which # Go reads as the CEL expression "false". BAD_SOURCE=$(yq '.agents[] | select(type == "!!map" and .enabled != false and (.source == null or .source == "") and (.runtime == null and .model == null and .effort == null and .subagents == null and ((.trigger == null) or (.trigger | tostring | test("^\\s*$"))))) | line' .fullsend/config.yaml 2>/dev/null || echo "") if [[ -n "$BAD_SOURCE" ]]; then echo "::error::config.yaml: enabled agent entry without a source field (an entry with only a name must set runtime, model, effort, subagents or trigger to tune a built-in agent)" exit 1 fi DUPES_ENABLED=$(yq '.agents[] | select(type == "!!map" and .enabled != false) | (.name // "") | select(. != "") | downcase' .fullsend/config.yaml 2>/dev/null | sort | uniq -d) DUPES_DISABLED=$(yq '.agents[] | select(type == "!!map" and .enabled == false) | (.name // "") | select(. != "") | downcase' .fullsend/config.yaml 2>/dev/null | sort | uniq -d) if [[ -n "$DUPES_ENABLED" ]]; then echo "::error::config.yaml: duplicate enabled agent name(s): ${DUPES_ENABLED}" exit 1 fi if [[ -n "$DUPES_DISABLED" ]]; then echo "::error::config.yaml: duplicate disabled agent name(s): ${DUPES_DISABLED}" exit 1 fi - name: Check agent is enabled id: agent-check if: steps.route.outputs.stage != '' env: STAGE: ${{ steps.route.outputs.stage }} run: | set -euo pipefail if [[ ! -f .fullsend/config.yaml ]]; then exit 0 fi YQ_ERR=$(mktemp) AGENT_ENABLED=$(yq " .agents[] | select((.name | downcase) == \"$STAGE\") | .enabled " .fullsend/config.yaml 2>"$YQ_ERR" | tail -1) || { echo "::warning::yq failed checking agents[].enabled — see logs" cat "$YQ_ERR" >&2 rm -f "$YQ_ERR" exit 0 } rm -f "$YQ_ERR" if [[ "$AGENT_ENABLED" == "false" ]]; then echo "::notice::Stage '$STAGE' skipped — agent '$STAGE' is disabled in config" echo "skipped=true" >> "${GITHUB_OUTPUT}" exit 0 fi - name: Resolve PR head for issue_comment events id: pr-head if: steps.route.outputs.stage != '' && steps.agent-check.outputs.skipped != 'true' && github.event_name == 'issue_comment' && github.event.issue.pull_request env: GH_TOKEN: ${{ github.token }} SOURCE_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} STAGE: ${{ steps.route.outputs.stage }} run: | set -euo pipefail PR_JSON=$(gh api "repos/${SOURCE_REPO}/pulls/${PR_NUMBER}" \ --jq '{number, html_url, head: {ref: .head.ref, sha: .head.sha, repo: {full_name: .head.repo.full_name}}, base: {ref: .base.ref, repo: {full_name: .base.repo.full_name}}}') || { if [[ "${STAGE}" =~ ^(fix|review)$ ]]; then echo "::error::Failed to fetch PR #${PR_NUMBER} head info" exit 1 fi echo "::warning::Failed to fetch PR #${PR_NUMBER} head info — continuing without PR context" exit 0 } if [[ "${STAGE}" == "fix" ]]; then HEAD_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.head.repo.full_name') BASE_REPO=$(printf '%s' "${PR_JSON}" | jq -r '.base.repo.full_name') if [[ "${HEAD_REPO}" != "${BASE_REPO}" ]]; then echo "::error::Fork PR detected (head=${HEAD_REPO}, base=${BASE_REPO}) — fix agent blocked" exit 1 fi fi DELIM="PR_$(openssl rand -hex 8)" { echo "pr_json<<${DELIM}" echo "${PR_JSON}" echo "${DELIM}" } >> "${GITHUB_OUTPUT}" - name: Build event payload id: payload if: steps.route.outputs.stage != '' env: PR_HEAD_JSON: ${{ steps.pr-head.outputs.pr_json }} run: | set -euo pipefail EVENT_PAYLOAD=$(jq -c '{ issue: (.issue // null | if . then {number, html_url} else null end), pull_request: (.pull_request // null | if . then {number, html_url, head: {ref: .head.ref, sha: .head.sha, repo: {full_name: .head.repo.full_name}}, base: {ref: .base.ref, repo: {full_name: .base.repo.full_name}}} else null end), comment: (.comment // null | if . then {id, body: .body[:4096]} else null end) }' "$GITHUB_EVENT_PATH") || { echo "::error::Failed to extract event payload from GITHUB_EVENT_PATH" exit 1 } # For issue_comment events on PRs, the raw event has no top-level # pull_request with head SHA. Merge the API-fetched PR info. if [[ -n "${PR_HEAD_JSON}" ]]; then EVENT_PAYLOAD=$(printf '%s' "${EVENT_PAYLOAD}" | jq -c --argjson pr "${PR_HEAD_JSON}" '.pull_request = $pr') fi if [[ -z "${EVENT_PAYLOAD}" || "${EVENT_PAYLOAD}" == "null" ]]; then echo "::error::Event payload is empty after extraction" exit 1 fi DELIM="PAYLOAD_$(openssl rand -hex 8)" { echo "event_payload<<${DELIM}" echo "${EVENT_PAYLOAD}" echo "${DELIM}" } >> "${GITHUB_OUTPUT}" triage: name: Triage needs: route if: needs.route.outputs.stage == 'triage' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-triage-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: true permissions: actions: write contents: read id-token: write issues: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Mint triage token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: triage repos: ${{ steps.workspace.outputs.repo_name }} mint_url: ${{ inputs.mint_url }} - name: Checkout target repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 1 persist-credentials: false - name: Setup agent environment env: AGENT_PREFIX: TRIAGE_ TRIAGE_TARGET_REPO_DIR: target-repo TRIAGE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} TRIAGE_CLOUD_ML_REGION: ${{ inputs.gcp_region }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, TRIAGE_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Run triage agent uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} FULLSEND_WORK_ITEM_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} REPO_FULL_NAME: ${{ github.repository }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: triage gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} status-comment-id: ${{ fromJSON(needs.route.outputs.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }} code: name: Code needs: route if: needs.route.outputs.stage == 'code' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-code-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: true permissions: actions: write contents: write id-token: write issues: write packages: read pull-requests: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Mint coder token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: coder repos: ${{ steps.workspace.outputs.repo_name }} mint_url: ${{ inputs.mint_url }} - name: Checkout target repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 0 persist-credentials: false - name: Resolve bot identity env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail read -r BOT_LOGIN BOT_USER_ID < <( gh api graphql -f query='{ viewer { login databaseId } }' \ | jq -r '.data.viewer | "\(.login) \(.databaseId)"' ) GIT_BOT_EMAIL="${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com" echo "Resolved bot identity: ${BOT_LOGIN} (${BOT_USER_ID})" echo "GIT_BOT_EMAIL=${GIT_BOT_EMAIL}" >> "${GITHUB_ENV}" git config --global user.email "${GIT_BOT_EMAIL}" git config --global user.name "${BOT_LOGIN}" - name: Setup agent environment env: AGENT_PREFIX: CODE_ CODE_TARGET_REPO_DIR: target-repo CODE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} CODE_CLOUD_ML_REGION: ${{ inputs.gcp_region }} CODE_ISSUE_NUMBER: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, CODE_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Run code agent uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} FULLSEND_WORK_ITEM_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} FULLSEND_WORK_ITEM_KEY: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} ISSUE_NUMBER: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} COMMENT_BODY: ${{ fromJSON(needs.route.outputs.event_payload).comment.body }} REPO_FULL_NAME: ${{ github.repository }} CODE_ALLOWED_TARGET_BRANCHES: '' TARGET_BRANCH: main OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: code gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} status-comment-id: ${{ fromJSON(needs.route.outputs.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }} review: name: Review needs: route if: needs.route.outputs.stage == 'review' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-review-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: true permissions: actions: write contents: read id-token: write issues: write pull-requests: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Mint review token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: review repos: ${{ steps.workspace.outputs.repo_name }} mint_url: ${{ inputs.mint_url }} - name: Checkout target repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 1 persist-credentials: false - name: Pre-fetch prior review context id: prior-review env: GH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_REPO: ${{ github.repository }} PR_NUM: >- ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }} ORG_NAME: ${{ github.repository_owner }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} REVIEW_APP_CLIENT_ID: ${{ vars.FULLSEND_REVIEW_CLIENT_ID }} FULLSEND_DIR: .fullsend run: bash "${FULLSEND_DIR:+$FULLSEND_DIR/}scripts/pre-fetch-prior-review.sh" - name: Setup agent environment env: AGENT_PREFIX: REVIEW_ REVIEW_TARGET_REPO_DIR: target-repo REVIEW_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} REVIEW_CLOUD_ML_REGION: ${{ inputs.gcp_region }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, REVIEW_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Run review agent uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} FULLSEND_WORK_ITEM_URL: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.html_url || fromJSON(needs.route.outputs.event_payload).issue.html_url }} GITHUB_PR_URL: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.html_url || fromJSON(needs.route.outputs.event_payload).issue.html_url }} REPO_FULL_NAME: ${{ github.repository }} PR_NUMBER: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }} PRIOR_REVIEW_SHA: ${{ steps.prior-review.outputs.prior_sha }} PRIOR_REVIEW_FILE: ${{ steps.prior-review.outputs.prior_review_file }} PRIOR_REVIEW_PROVENANCE: ${{ steps.prior-review.outputs.prior_review_provenance }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: review gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }} status-comment-id: ${{ fromJSON(needs.route.outputs.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }} fix: name: Fix needs: route if: needs.route.outputs.stage == 'fix' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-fix-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: true permissions: actions: write contents: write id-token: write issues: write packages: read pull-requests: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Mint coder token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: coder repos: ${{ steps.workspace.outputs.repo_name }} mint_url: ${{ inputs.mint_url }} - name: Resolve bot identity env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail read -r BOT_LOGIN BOT_USER_ID < <( gh api graphql -f query='{ viewer { login databaseId } }' \ | jq -r '.data.viewer | "\(.login) \(.databaseId)"' ) GIT_BOT_EMAIL="${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com" echo "Resolved bot identity: ${BOT_LOGIN} (${BOT_USER_ID})" echo "GIT_BOT_EMAIL=${GIT_BOT_EMAIL}" >> "${GITHUB_ENV}" git config --global user.email "${GIT_BOT_EMAIL}" git config --global user.name "${BOT_LOGIN}" - name: Block fork PRs env: GH_TOKEN: ${{ steps.app-token.outputs.token }} EVENT_PAYLOAD: ${{ needs.route.outputs.event_payload }} SOURCE_REPO: ${{ github.repository }} PR_NUM_INPUT: '' run: | HEAD_REPO="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.head.repo.full_name // empty')" BASE_REPO="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.base.repo.full_name // empty')" if [[ -n "${HEAD_REPO}" && -n "${BASE_REPO}" && "${HEAD_REPO}" != "${BASE_REPO}" ]]; then echo "::error::Fork PRs are not allowed to trigger the fix agent" exit 1 fi if [[ -z "${HEAD_REPO}" && -z "${BASE_REPO}" ]]; then PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.issue.number // empty')" PR_NUM="${PR_NUM:-${PR_NUM_INPUT:-}}" if [[ -n "${PR_NUM}" ]]; then IS_FORK="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" \ --json isCrossRepository --jq '.isCrossRepository' 2>/dev/null)" || true if [[ -z "${IS_FORK}" ]]; then echo "::error::Could not determine if PR is from a fork — failing closed" exit 1 fi if [[ "${IS_FORK}" == "true" ]]; then echo "::error::Fork PRs are not allowed to trigger the fix agent" exit 1 fi fi fi - name: Extract PR number and context id: context env: GH_TOKEN: ${{ steps.app-token.outputs.token }} EVENT_PAYLOAD: ${{ needs.route.outputs.event_payload }} INPUT_PR_NUMBER: '' INPUT_INSTRUCTION: '' TRIGGER_SOURCE: ${{ needs.route.outputs.trigger_source }} SOURCE_REPO: ${{ github.repository }} run: | PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.number // empty')" if [[ -z "${PR_NUM}" ]]; then PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.issue.number // empty')" fi if [[ -z "${PR_NUM}" && -n "${INPUT_PR_NUMBER}" ]]; then PR_NUM="${INPUT_PR_NUMBER}" fi if [[ -z "${PR_NUM}" || ! "${PR_NUM}" =~ ^[1-9][0-9]*$ ]]; then echo "::error::Could not extract valid PR number from event" exit 1 fi echo "pr_number=${PR_NUM}" >> "${GITHUB_OUTPUT}" HEAD_REF="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.head.ref // empty')" BASE_REF="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.base.ref // empty')" if [[ -z "${HEAD_REF}" ]]; then HEAD_REF="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" --json headRefName --jq '.headRefName' 2>/dev/null || true)" fi if [[ -z "${BASE_REF}" ]]; then BASE_REF="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo 'main')" fi if [[ -z "${HEAD_REF}" ]]; then echo "::error::Could not resolve PR head branch" exit 1 fi echo "head_ref=${HEAD_REF}" >> "${GITHUB_OUTPUT}" echo "base_ref=${BASE_REF}" >> "${GITHUB_OUTPUT}" echo "PR #${PR_NUM}: ${HEAD_REF} → ${BASE_REF}" INSTRUCTION="none" if [[ ! "${TRIGGER_SOURCE}" =~ \[bot\]$ ]]; then COMMENT_BODY="$(echo "${EVENT_PAYLOAD}" | jq -r '.comment.body // empty' | tr -d '\r')" if [[ -n "${COMMENT_BODY}" ]]; then INSTRUCTION="${COMMENT_BODY#/fs-fix}" INSTRUCTION="$(printf '%s\n' "${INSTRUCTION}" | sed 's/^[.,;:!?]*//')" INSTRUCTION="${INSTRUCTION#"${INSTRUCTION%%[![:space:]]*}"}" fi if [[ -z "${INSTRUCTION}" && -n "${INPUT_INSTRUCTION}" ]]; then INSTRUCTION="${INPUT_INSTRUCTION}" fi if [[ -z "${INSTRUCTION}" ]]; then INSTRUCTION="none" fi fi DELIM="INSTRUCTION_$(openssl rand -hex 8)" { echo "instruction<<${DELIM}" echo "${INSTRUCTION}" echo "${DELIM}" } >> "${GITHUB_OUTPUT}" FIX_COMMITS="$(gh api "repos/${SOURCE_REPO}/pulls/${PR_NUM}/commits" \ --paginate 2>/dev/null \ | jq -s --arg bot_email "${GIT_BOT_EMAIL}" -f .defaults/.github/scripts/count-fix-commits.jq)" \ || { echo "::warning::Could not count prior fix commits — defaulting to cap"; FIX_COMMITS="${ITERATION_CAP:-5}"; } ITERATION=$(( FIX_COMMITS + 1 )) echo "Fix iteration: ${ITERATION} (${FIX_COMMITS} previous fix commits)" >&2 echo "iteration=${ITERATION}" >> "${GITHUB_OUTPUT}" - name: Check fix eligibility env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TRIGGER_SOURCE: ${{ needs.route.outputs.trigger_source }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} PR_NUM: ${{ steps.context.outputs.pr_number }} SOURCE_REPO: ${{ github.repository }} run: bash .defaults/.github/scripts/check-fix-eligibility.sh - name: Checkout target repository at PR HEAD uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 0 persist-credentials: false ref: ${{ steps.context.outputs.head_ref }} - name: Record pre-agent HEAD id: pre-agent working-directory: target-repo run: | SHA="$(git rev-parse HEAD)" echo "Pre-agent HEAD: ${SHA}" echo "head=${SHA}" >> "${GITHUB_OUTPUT}" - name: Pre-fetch review body id: review-body env: GH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_REPO: ${{ github.repository }} PR_NUM: ${{ steps.context.outputs.pr_number }} TRIGGER_SOURCE: ${{ needs.route.outputs.trigger_source }} ORG_NAME: ${{ github.repository_owner }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} run: bash .defaults/.github/scripts/pre-fetch-review-body.sh - name: Setup agent environment env: AGENT_PREFIX: FIX_ FIX_TARGET_REPO_DIR: target-repo FIX_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} FIX_CLOUD_ML_REGION: ${{ inputs.gcp_region }} FIX_PR_NUMBER: ${{ steps.context.outputs.pr_number }} FIX_TRIGGER_SOURCE: ${{ needs.route.outputs.trigger_source }} FIX_HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} FIX_FIX_ITERATION: ${{ steps.context.outputs.iteration }} FIX_REPO_FULL_NAME: ${{ github.repository }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, FIX_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Run fix agent uses: ./.defaults/ env: PR_NUMBER: ${{ steps.context.outputs.pr_number }} REPO_FULL_NAME: ${{ github.repository }} TARGET_BRANCH: ${{ steps.context.outputs.base_ref }} TRIGGER_SOURCE: ${{ needs.route.outputs.trigger_source }} HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} FIX_ITERATION: ${{ steps.context.outputs.iteration }} REVIEW_BODY_FILE: ${{ steps.review-body.outputs.review_file }} PRE_AGENT_HEAD: ${{ steps.pre-agent.outputs.head }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: fix gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ steps.context.outputs.pr_number }} status-comment-id: ${{ fromJSON(needs.route.outputs.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }} retro: name: Retro needs: route if: needs.route.outputs.stage == 'retro' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-retro-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: true permissions: actions: write contents: read id-token: write issues: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Mint retro token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: retro repos: ${{ steps.workspace.outputs.repo_name }} mint_url: ${{ inputs.mint_url }} - name: Checkout target repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ github.repository }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 1 persist-credentials: false - name: Setup agent environment env: AGENT_PREFIX: RETRO_ RETRO_TARGET_REPO_DIR: target-repo RETRO_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} RETRO_CLOUD_ML_REGION: ${{ inputs.gcp_region }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, RETRO_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Run retro agent uses: ./.defaults/ env: ORIGINATING_URL: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.html_url || fromJSON(needs.route.outputs.event_payload).issue.html_url }} RETRO_COMMENT: ${{ fromJSON(needs.route.outputs.event_payload).comment.body || '' }} REPO_FULL_NAME: ${{ github.repository }} MINT_REPOS: ${{ steps.workspace.outputs.repo_name }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: retro gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.number || fromJSON(needs.route.outputs.event_payload).issue.number }} status-comment-id: ${{ fromJSON(needs.route.outputs.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(needs.route.outputs.event_payload).pull_request.head.sha || '' }} prioritize: name: Prioritize needs: route if: needs.route.outputs.stage == 'prioritize' runs-on: ${{ inputs.runner_image }} concurrency: group: fullsend-prioritize-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: true permissions: actions: write contents: read id-token: write issues: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace id: workspace uses: ./.defaults/.github/actions/prepare-workspace with: source_repo: ${{ github.repository }} - name: Setup agent environment env: AGENT_PREFIX: PRIORITIZE_ PRIORITIZE_ORG: ${{ github.repository_owner }} PRIORITIZE_PROJECT_NUMBER: ${{ inputs.project_number }} PRIORITIZE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} PRIORITIZE_CLOUD_ML_REGION: ${{ inputs.gcp_region }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, PRIORITIZE_FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: bash .github/scripts/setup-agent-env.sh - name: Create empty target-repo directory run: mkdir -p target-repo - name: Run prioritize agent uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} FULLSEND_WORK_ITEM_URL: ${{ fromJSON(needs.route.outputs.event_payload).issue.html_url }} REPO_FULL_NAME: ${{ github.repository }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} with: agent: prioritize gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} fullsend-dir: .fullsend version: ${{ inputs.fullsend_version || job.workflow_sha }} run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ github.repository }} status-number: ${{ fromJSON(needs.route.outputs.event_payload).issue.number }} mint-url: ${{ inputs.mint_url }} harness-dispatch: name: Harness dispatch if: ${{ inputs.matrix == '' }} runs-on: ${{ inputs.runner_image }} permissions: actions: write contents: read id-token: write pull-requests: read outputs: matrix: ${{ steps.dispatch.outputs.matrix }} steps: - name: Validate event_action if: ${{ inputs.event_action == '' }} run: | echo "::error::event_action is required when matrix is not provided" exit 1 - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} sparse-checkout: | .fullsend/ /OWNERS /OWNERS_ALIASES .defaults/action.yml .defaults/.github/actions/install-fullsend-cli/ .fullsend/.defaults/action.yml sparse-checkout-cone-mode: false - name: Detect registered harness triggers id: triggers shell: bash run: | set -euo pipefail has_triggers=false if [[ -f .fullsend/config.yaml ]]; then if grep -qE 'source:[[:space:]]*https?://' .fullsend/config.yaml; then has_triggers=true elif grep -qE '^[[:space:]]*agents:' .fullsend/config.yaml \ && grep -qE '^[[:space:]]*-[[:space:]]*name:|^[[:space:]]*name:' .fullsend/config.yaml; then # Agents are registered; let dispatch enumerate triggers. Skipping only # when the config has no agents avoids false negatives from harness path # layout while still skipping the CLI install on empty configs. has_triggers=true fi fi echo "has_triggers=${has_triggers}" >> "${GITHUB_OUTPUT}" - name: Detect dispatch CLI install mode id: mode if: steps.triggers.outputs.has_triggers == 'true' shell: bash run: | set -euo pipefail if [[ -f .defaults/action.yml || -f .fullsend/.defaults/action.yml ]]; then echo "mode=vendored" >> "${GITHUB_OUTPUT}" else echo "mode=upstream" >> "${GITHUB_OUTPUT}" fi - name: Guard vendored install if: steps.triggers.outputs.has_triggers == 'true' && steps.mode.outputs.mode == 'vendored' shell: bash run: | set -euo pipefail if [[ ! -f .fullsend/bin/fullsend ]]; then echo "::error::broken --vendor install: vendored marker present but .fullsend/bin/fullsend is missing" exit 1 fi - name: Restore fullsend CLI cache if: steps.triggers.outputs.has_triggers == 'true' id: cli-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/fullsend-cache key: fullsend-cli-${{ steps.mode.outputs.mode }}-${{ job.workflow_sha }} - name: Install fullsend CLI (vendored defaults) if: steps.triggers.outputs.has_triggers == 'true' && steps.mode.outputs.mode == 'vendored' && steps.cli-cache.outputs.cache-hit != 'true' uses: ./.defaults/.github/actions/install-fullsend-cli with: mode: vendored vendored_path: .fullsend/bin/fullsend github_token: ${{ github.token }} - name: Checkout upstream install action if: steps.triggers.outputs.has_triggers == 'true' && steps.mode.outputs.mode == 'upstream' && steps.cli-cache.outputs.cache-hit != 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .workflow-actions fetch-depth: 1 sparse-checkout: | .github/actions/install-fullsend-cli/ sparse-checkout-cone-mode: false - name: Install fullsend CLI (upstream workflow) if: steps.triggers.outputs.has_triggers == 'true' && steps.mode.outputs.mode == 'upstream' && steps.cli-cache.outputs.cache-hit != 'true' uses: ./.workflow-actions/.github/actions/install-fullsend-cli with: mode: upstream workflow_repository: ${{ job.workflow_repository }} workflow_sha: ${{ job.workflow_sha }} github_token: ${{ github.token }} - name: Expose cached fullsend CLI if: steps.triggers.outputs.has_triggers == 'true' && steps.cli-cache.outputs.cache-hit == 'true' shell: bash run: | set -euo pipefail install_dir="${RUNNER_TEMP}/fullsend" mkdir -p "${install_dir}" cp "${RUNNER_TEMP}/fullsend-cache/fullsend" "${install_dir}/fullsend" chmod +x "${install_dir}/fullsend" echo "${install_dir}" >> "${GITHUB_PATH}" - name: Prepare fullsend CLI cache artifact if: steps.triggers.outputs.has_triggers == 'true' && steps.cli-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail mkdir -p "${RUNNER_TEMP}/fullsend-cache" cp "${RUNNER_TEMP}/fullsend/fullsend" "${RUNNER_TEMP}/fullsend-cache/fullsend" - name: Save fullsend CLI cache if: steps.triggers.outputs.has_triggers == 'true' && steps.cli-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/fullsend-cache key: fullsend-cli-${{ steps.mode.outputs.mode }}-${{ job.workflow_sha }} - name: Run fullsend dispatch id: dispatch env: GH_TOKEN: ${{ github.token }} GITHUB_EVENT_NAME: ${{ github.event_name }} EVENT_ACTION: ${{ inputs.event_action }} FULLSEND_MINT_URL: ${{ inputs.mint_url }} run: | set -euo pipefail if [[ "${{ steps.triggers.outputs.has_triggers }}" != "true" ]]; then echo 'matrix={"include":[]}' >> "${GITHUB_OUTPUT}" exit 0 fi MATRIX=$(fullsend dispatch \ --input-driver gha-event \ --output-driver gha-matrix \ --config-dir .fullsend \ --event-action "${EVENT_ACTION}") DELIM="MATRIX_$(openssl rand -hex 8)" { echo "matrix<<${DELIM}" printf '%s' "${MATRIX}" echo echo "${DELIM}" } >> "${GITHUB_OUTPUT}" harness-run: name: Harness run (${{ matrix.agent }}) needs: [harness-dispatch] if: ${{ !cancelled() && ((inputs.matrix != '' && fromJSON(inputs.matrix).include[0] != null) || (needs.harness-dispatch.outputs.matrix != '' && fromJSON(needs.harness-dispatch.outputs.matrix).include[0] != null)) }} strategy: fail-fast: false matrix: ${{ fromJSON(inputs.matrix != '' && inputs.matrix || (needs.harness-dispatch.outputs.matrix != '' && needs.harness-dispatch.outputs.matrix || '{"include":[]}')) }} concurrency: group: fullsend-harness-${{ matrix.agent }}-${{ github.repository }}-${{ matrix.status_repo }}-${{ matrix.status_number }} cancel-in-progress: true runs-on: ${{ inputs.runner_image }} permissions: actions: write contents: read id-token: write issues: write pull-requests: write steps: - name: Checkout caller repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && github.event.pull_request.base.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Checkout upstream defaults if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .defaults fetch-depth: 1 sparse-checkout: | .github/actions/ .github/scripts/ internal/scaffold/fullsend-repo/ action.yml - name: Prepare workspace (upstream defaults + repo overrides) run: | set -euo pipefail SRC=".defaults/internal/scaffold/fullsend-repo" LAYERED_DIRS="agents skills schemas harness plugins scripts env" DEST=".fullsend/" for dir in ${LAYERED_DIRS}; do if [[ -d "${SRC}/${dir}" ]]; then mkdir -p "${DEST}${dir}" cp -r "${SRC}/${dir}/." "${DEST}${dir}/" fi done mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh - name: Extract repo metadata id: repo-parts shell: bash env: SOURCE_REPO: ${{ matrix.source_repo }} run: | set -euo pipefail NAME="${SOURCE_REPO#*/}" echo "name=${NAME}" >> "${GITHUB_OUTPUT}" - name: Write dispatch event payload shell: bash env: EVENT_PAYLOAD: ${{ matrix.event_payload }} run: | set -euo pipefail mkdir -p .fullsend/dispatch printf '%s' "${EVENT_PAYLOAD}" > .fullsend/dispatch/event-payload.json - name: Mint agent token id: app-token uses: ./.defaults/.github/actions/mint-token with: role: ${{ matrix.role }} repos: ${{ steps.repo-parts.outputs.name }} mint_url: ${{ inputs.mint_url }} - name: Checkout target repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: ${{ matrix.source_repo }} token: ${{ steps.app-token.outputs.token }} path: target-repo fetch-depth: 1 persist-credentials: false - name: Resolve bot identity env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail read -r BOT_LOGIN BOT_USER_ID < <( gh api graphql -f query='{ viewer { login databaseId } }' \ | jq -r '.data.viewer | "\(.login) \(.databaseId)"' ) GIT_BOT_EMAIL="${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com" echo "Resolved bot identity: ${BOT_LOGIN} (${BOT_USER_ID})" echo "GIT_BOT_EMAIL=${GIT_BOT_EMAIL}" >> "${GITHUB_ENV}" git config --global user.email "${GIT_BOT_EMAIL}" git config --global user.name "${BOT_LOGIN}" - name: Setup agent environment shell: bash env: MATRIX_ROLE: ${{ matrix.role }} GCP_PROJECT: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} GCP_REGION: ${{ inputs.gcp_region }} # Per-run runtime/model/effort overrides from repository variables # (FULLSEND_MODEL, _FULLSEND_MODEL, ...); see docs/runtimes.md. FULLSEND_REPO_VARS: ${{ toJSON(vars) }} run: | set -euo pipefail # GitHub Actions / bash identifiers cannot contain hyphens. # Map the role the same way mintcore.RoleIdentifier does: uppercase # and replace '-' with '_' so ci-check becomes CI_CHECK. ROLE_UPPER=$(echo "${MATRIX_ROLE}" | tr '[:lower:]' '[:upper:]' | tr '-' '_') export AGENT_PREFIX="${ROLE_UPPER}_" export "${ROLE_UPPER}_TARGET_REPO_DIR=target-repo" export "${ROLE_UPPER}_ANTHROPIC_VERTEX_PROJECT_ID=${GCP_PROJECT}" export "${ROLE_UPPER}_CLOUD_ML_REGION=${GCP_REGION}" bash .github/scripts/setup-agent-env.sh - name: Export dispatch context env id: dispatch-env shell: bash env: EVENT_PAYLOAD: ${{ matrix.event_payload }} run: | set -euo pipefail URL=$(printf '%s' "${EVENT_PAYLOAD}" | jq -r '.issue.html_url // .pull_request.html_url // empty') if [[ -z "${URL}" ]]; then echo "::error::event_payload missing issue or pull_request html_url" exit 1 fi WORK_ITEM_KEY=$(printf '%s' "${EVENT_PAYLOAD}" | jq -r '._normalized_event.entity.key // .issue.number // .pull_request.number // empty') if [[ -z "${WORK_ITEM_KEY}" ]]; then echo "::error::event_payload missing normalized entity key, issue number, or pull request number" exit 1 fi ISSUE_NUMBER=$(printf '%s' "${EVENT_PAYLOAD}" | jq -r 'if ._normalized_event.source.system == "jira" then empty else (.issue.number // .pull_request.number // empty) end') DELIM="ISSUE_URL_$(openssl rand -hex 8)" { echo "issue_url<<${DELIM}" printf '%s' "${URL}" echo echo "${DELIM}" echo "work_item_key<<${DELIM}" printf '%s' "${WORK_ITEM_KEY}" echo echo "${DELIM}" echo "issue_number=${ISSUE_NUMBER}" } >> "${GITHUB_OUTPUT}" - name: Run harness agent uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ steps.dispatch-env.outputs.issue_url }} FULLSEND_WORK_ITEM_URL: ${{ steps.dispatch-env.outputs.issue_url }} FULLSEND_WORK_ITEM_KEY: ${{ steps.dispatch-env.outputs.work_item_key }} ISSUE_NUMBER: ${{ steps.dispatch-env.outputs.issue_number }} REPO_FULL_NAME: ${{ matrix.source_repo }} FULLSEND_APP_SET: ${{ vars.FULLSEND_APP_SET || 'fullsend-ai' }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ vars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT }} OTEL_EXPORTER_OTLP_TRACES_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_TRACES_HEADERS }} OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }} OTEL_EXPORTER_OTLP_CERTIFICATE: ${{ vars.OTEL_EXPORTER_OTLP_CERTIFICATE }} OTEL_RESOURCE_ATTRIBUTES: ${{ vars.OTEL_RESOURCE_ATTRIBUTES }} OTEL_SDK_DISABLED: ${{ vars.OTEL_SDK_DISABLED }} OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: ${{ vars.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT }} # OpenAI Workload Identity Federation for the pi runtime (ADR 0092): # non-secret identifiers the runner exchanges the job's OIDC token # against. Unset = the openai provider is not usable in this repo. FULLSEND_OPENAI_AUDIENCE: ${{ vars.FULLSEND_OPENAI_AUDIENCE }} FULLSEND_OPENAI_IDENTITY_PROVIDER_ID: ${{ vars.FULLSEND_OPENAI_IDENTITY_PROVIDER_ID }} FULLSEND_OPENAI_SERVICE_ACCOUNT_ID: ${{ vars.FULLSEND_OPENAI_SERVICE_ACCOUNT_ID }} # Opt-in static key, used only when the three variables above are unset (ADR 0092). OPENAI_API_KEY: ${{ secrets.FULLSEND_OPENAI_API_KEY }} # Inference gateway API key for inference.gateway.auth api-key (ADR 0138), # read by the runner under its own name; unused in the oidc mode. FULLSEND_INFERENCE_GATEWAY_API_KEY: ${{ secrets.FULLSEND_INFERENCE_GATEWAY_API_KEY }} JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} JIRA_BASE_URL: ${{ inputs.jira_base_url || vars.JIRA_BASE_URL }} with: agent: ${{ matrix.agent }} gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} gcp_project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} role: ${{ matrix.role }} runner_secrets: ${{ secrets.FULLSEND_GITHUB_RUNNER_SECRETS }} version: ${{ inputs.fullsend_version || job.workflow_sha }} fullsend-dir: .fullsend run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} status-repo: ${{ matrix.status_repo }} status-number: ${{ matrix.status_number }} status-comment-id: ${{ fromJSON(matrix.event_payload).comment.id }} mint-url: ${{ inputs.mint_url }} pr-head-sha: ${{ fromJSON(matrix.event_payload).pull_request.head.sha || '' }}