fix(dispatch): address review feedback on workflow_call dispatch #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Reusable dispatch workflow for per-repo installation mode. | ||
| # Routes events to the appropriate stage reusable workflow via conditional | ||
| # workflow_call jobs. Same direct-dispatch pattern as per-org dispatch.yml | ||
| # (ADR 0041). | ||
| # | ||
| # Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml | ||
| # Nesting: 3 levels of workflow_call (within GitHub's 4-level limit) | ||
| # | ||
| # Security: all user-controlled inputs (comment body, labels, usernames) | ||
| # are passed via env: variables, not interpolated in run: blocks. | ||
| name: Dispatch | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| event_action: | ||
| description: "The event action (github.event.action) forwarded by the shim" | ||
| required: true | ||
| type: string | ||
| install_mode: | ||
| description: "Installation mode: per-repo (default) or per-org" | ||
| 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: "latest" | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: | ||
| required: false | ||
| FULLSEND_GCP_PROJECT_ID: | ||
| required: true | ||
| jobs: | ||
| route: | ||
| name: Route | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
| outputs: | ||
| stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }} | ||
| trigger_source: ${{ steps.route.outputs.trigger_source }} | ||
| event_payload: ${{ steps.payload.outputs.event_payload }} | ||
| steps: | ||
| - name: Checkout caller repository | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| persist-credentials: false | ||
| sparse-checkout: .fullsend/config.yaml | ||
| sparse-checkout-cone-mode: false | ||
| - name: Determine stage | ||
| id: 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 }} | ||
| COMMENT_AUTHOR_ASSOC: ${{ github.event.comment.author_association }} | ||
| ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }} | ||
| PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} | ||
| ISSUE_HAS_PR: ${{ github.event.issue.pull_request && 'true' || 'false' }} | ||
| ISSUE_USER_LOGIN: ${{ github.event.issue.user.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 }} | ||
| run: | | ||
| set -euo pipefail | ||
| STAGE="" | ||
| TRIGGER_SOURCE="" | ||
| is_authorized() { | ||
| case "${COMMENT_AUTHOR_ASSOC}" in | ||
| OWNER|MEMBER|COLLABORATOR) return 0 ;; | ||
| *) return 1 ;; | ||
| esac | ||
| } | ||
| is_issue_author() { | ||
| [[ "${COMMENT_USER_LOGIN}" == "${ISSUE_USER_LOGIN}" ]] | ||
| } | ||
| 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 | awk '{print $1}')" | ||
| fi | ||
| case "${EVENT_NAME}" in | ||
| issue_comment) | ||
| case "${COMMAND}" in | ||
| /fs-triage) | ||
| STAGE="triage" | ||
| ;; | ||
| /fs-code) | ||
| if [[ "${ISSUE_HAS_PR}" == "false" ]]; then | ||
| STAGE="code" | ||
| fi | ||
| ;; | ||
| /fs-review) | ||
| STAGE="review" | ||
| ;; | ||
| /fs-fix) | ||
| if [[ "${ISSUE_HAS_PR}" == "true" ]]; then | ||
| if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then | ||
| STAGE="fix" | ||
| TRIGGER_SOURCE="${COMMENT_USER_LOGIN}" | ||
| fi | ||
| fi | ||
| ;; | ||
| /fs-retro|/fullsend) | ||
| if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then | ||
| if [[ "${COMMAND}" == "/fullsend" ]]; then | ||
| SECOND_WORD="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | awk '{print $2}')" | ||
| if [[ "${SECOND_WORD}" == "retro" ]]; then | ||
| STAGE="retro" | ||
| fi | ||
| else | ||
| STAGE="retro" | ||
| fi | ||
| fi | ||
| ;; | ||
| /fs-prioritize) | ||
| if [[ "${COMMENT_USER_TYPE}" != "Bot" ]] && is_authorized; then | ||
| STAGE="prioritize" | ||
| fi | ||
| ;; | ||
| *) | ||
| if has_label "needs-info" && ! has_label "feature"; then | ||
| if [[ "${COMMENT_USER_TYPE}" != "Bot" ]]; then | ||
| if [[ "${COMMENT_AUTHOR_ASSOC}" != "NONE" ]] || is_issue_author; then | ||
| STAGE="triage" | ||
| fi | ||
| fi | ||
| fi | ||
| ;; | ||
| esac | ||
| ;; | ||
| issues) | ||
| if [[ "${EVENT_ACTION}" == "labeled" ]]; then | ||
| if [[ "${TRIGGERING_LABEL}" == "ready-to-code" ]]; then | ||
| STAGE="code" | ||
| elif [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]]; then | ||
| STAGE="review" | ||
| fi | ||
| fi | ||
| ;; | ||
| pull_request_target) | ||
| case "${EVENT_ACTION}" in | ||
| opened|synchronize|ready_for_review) | ||
| STAGE="review" | ||
| ;; | ||
| closed) | ||
| STAGE="retro" | ||
| ;; | ||
| esac | ||
| ;; | ||
| pull_request_review) | ||
| if [[ "${EVENT_ACTION}" == "submitted" && "${REVIEW_STATE}" == "changes_requested" ]]; then | ||
| REVIEW_BOT="${ORG_NAME}-review[bot]" | ||
| if [[ "${REVIEW_USER_LOGIN}" == "${REVIEW_BOT}" ]]; then | ||
| 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 | ||
| # Human PRs require the fullsend-fix label to auto-trigger | ||
| # the fix agent. The /fs-fix slash command (line ~129) | ||
| # intentionally bypasses this gate — authorized users can | ||
| # always trigger fix manually regardless of labels. | ||
| if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || has_label "fullsend-fix" "${PR_LABELS}"; 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}" | ||
| - 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 | ||
| if [[ -f .fullsend/config.yaml ]]; then | ||
| KILL_SWITCH=$(yq '.kill_switch // false' .fullsend/config.yaml) | ||
| if [[ "$KILL_SWITCH" == "true" ]]; then | ||
| echo "::error::Kill switch is active — all agent dispatch halted" | ||
| echo "::error::Set kill_switch: false in .fullsend/config.yaml to resume" | ||
| exit 1 | ||
| fi | ||
| fi | ||
| - name: Check role is enabled | ||
| id: role-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 | ||
| STAGE_ROLE="$STAGE" | ||
| case "$STAGE" in | ||
| code|fix) STAGE_ROLE="coder" ;; | ||
| retro|prioritize) STAGE_ROLE="fullsend" ;; | ||
| esac | ||
| ROLES=$(yq '.roles[]' .fullsend/config.yaml 2>/dev/null || echo "") | ||
| if [[ -n "$ROLES" ]] && ! echo "$ROLES" | grep -Fqx "$STAGE_ROLE"; then | ||
| echo "::notice::Stage '$STAGE' skipped — role '$STAGE_ROLE' not in configured roles" | ||
| echo "skipped=true" >> "${GITHUB_OUTPUT}" | ||
| exit 0 | ||
| fi | ||
| - name: Block fork PRs for fix stage | ||
| if: steps.route.outputs.stage == 'fix' && steps.role-check.outputs.skipped != 'true' && github.event.issue.pull_request | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| SOURCE_REPO: ${{ github.repository }} | ||
| PR_NUMBER: ${{ github.event.issue.number }} | ||
| run: | | ||
| set -euo pipefail | ||
| REPOS=$(gh api "repos/$SOURCE_REPO/pulls/$PR_NUMBER" \ | ||
| --jq '[.head.repo.full_name, .base.repo.full_name] | @tsv' 2>/dev/null) || { | ||
| echo "::error::Could not determine PR repos — blocking fix for safety" | ||
| exit 1 | ||
| } | ||
| HEAD_REPO=$(printf '%s' "$REPOS" | cut -f1) | ||
| BASE_REPO=$(printf '%s' "$REPOS" | cut -f2) | ||
| if [[ "$HEAD_REPO" != "$BASE_REPO" ]]; then | ||
| echo "::error::Fork PR detected (head=$HEAD_REPO, base=$BASE_REPO) — fix agent blocked" | ||
| exit 1 | ||
| fi | ||
| - name: Build event payload | ||
| id: payload | ||
| if: steps.route.outputs.stage != '' | ||
| 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, 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 {body: .body[:4096]} else null end) | ||
| }' "$GITHUB_EVENT_PATH") || { | ||
| echo "::error::Failed to extract event payload from GITHUB_EVENT_PATH" | ||
| exit 1 | ||
| } | ||
| 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' | ||
| uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0 | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| install_mode: ${{ inputs.install_mode }} | ||
| mint_url: ${{ inputs.mint_url }} | ||
| gcp_region: ${{ inputs.gcp_region }} | ||
| fullsend_version: ${{ inputs.fullsend_version }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||
| code: | ||
| name: Code | ||
| needs: route | ||
| if: needs.route.outputs.stage == 'code' | ||
| uses: fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0 | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| install_mode: ${{ inputs.install_mode }} | ||
| mint_url: ${{ inputs.mint_url }} | ||
| gcp_region: ${{ inputs.gcp_region }} | ||
| fullsend_version: ${{ inputs.fullsend_version }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||
| review: | ||
| name: Review | ||
| needs: route | ||
| if: needs.route.outputs.stage == 'review' | ||
| uses: fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0 | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| install_mode: ${{ inputs.install_mode }} | ||
| mint_url: ${{ inputs.mint_url }} | ||
| gcp_region: ${{ inputs.gcp_region }} | ||
| fullsend_version: ${{ inputs.fullsend_version }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||
| fix: | ||
| name: Fix | ||
| needs: route | ||
| if: needs.route.outputs.stage == 'fix' | ||
| uses: fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0 | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| trigger_source: ${{ needs.route.outputs.trigger_source }} | ||
| install_mode: ${{ inputs.install_mode }} | ||
| mint_url: ${{ inputs.mint_url }} | ||
| gcp_region: ${{ inputs.gcp_region }} | ||
| fullsend_version: ${{ inputs.fullsend_version }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||
| retro: | ||
| name: Retro | ||
| needs: route | ||
| if: needs.route.outputs.stage == 'retro' | ||
| uses: fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0 | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| install_mode: ${{ inputs.install_mode }} | ||
| mint_url: ${{ inputs.mint_url }} | ||
| gcp_region: ${{ inputs.gcp_region }} | ||
| fullsend_version: ${{ inputs.fullsend_version }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||
| prioritize: | ||
| name: Prioritize | ||
| needs: route | ||
| if: needs.route.outputs.stage == 'prioritize' | ||
| uses: ./.fullsend/.github/workflows/prioritize.yml | ||
| permissions: | ||
| contents: read | ||
| id-token: write | ||
| with: | ||
| event_type: ${{ github.event_name }} | ||
| source_repo: ${{ github.repository }} | ||
| event_payload: ${{ needs.route.outputs.event_payload }} | ||
| secrets: | ||
| FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | ||
| FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | ||