Skip to content

fix(dispatch): address review feedback on workflow_call dispatch #1

fix(dispatch): address review feedback on workflow_call dispatch

fix(dispatch): address review feedback on workflow_call dispatch #1

# 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

Check failure on line 421 in .github/workflows/reusable-dispatch.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/reusable-dispatch.yml

Invalid workflow file

invalid value workflow reference: no version specified
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 }}