Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d51908a
Add dispatch workflow architecture with workflow_dispatch triggers
ggallen Apr 28, 2026
b70ed7e
Test github stuff.
ggallen Apr 3, 2026
12dcf69
Fix.
ggallen Apr 3, 2026
f4cced0
Add combined workflow.
ggallen Apr 3, 2026
afe646a
Fix paths.
ggallen Apr 3, 2026
bad2c2b
Fix asset name.
ggallen Apr 3, 2026
208ac56
Fix dispatch.yml issues identified in PR review
ggallen Apr 28, 2026
800872f
Address remaining review feedback
ggallen Apr 28, 2026
044f8c0
Remove fork-specific workflow files
ggallen Apr 28, 2026
6ce6d41
Address ralphbean's review feedback
ggallen Apr 28, 2026
23badd7
Add comment explaining AGENT_PREFIX vs STAGE_PREFIX naming
ggallen Apr 28, 2026
c2f0f65
Remove all .github/ changes from PR
ggallen Apr 28, 2026
0e531b6
Remove confusing comment from setup-agent-env.sh
ggallen Apr 28, 2026
39b82b5
Fix critical dispatch.yml issues from review
ggallen Apr 29, 2026
323a2b2
Address remaining review feedback
ggallen Apr 29, 2026
09dfded
Add behavioral test assertions for dispatch and agent workflows
ggallen Apr 29, 2026
5b41e35
Remove unnecessary DISPATCH_* env var indirection
ggallen Apr 29, 2026
0e7c30b
Address low-severity review feedback
ggallen Apr 29, 2026
5d52292
Update fix.yml workflow to work with dispatch architecture
ggallen Apr 29, 2026
b8e8f1c
Address dispatch.yml security and robustness feedback
ggallen Apr 29, 2026
46e7457
Pass actual GitHub usernames as trigger_source instead of "bot"/"human"
ggallen Apr 29, 2026
aaf0901
Fix trigger_source username handling in scripts and documentation
ggallen Apr 29, 2026
9fddb15
docs: update pre-fix.sh header to reflect username-based TRIGGER_SOURCE
ggallen Apr 29, 2026
120147d
fix: address should-fix review feedback on trigger_source handling
ggallen Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ __pycache__/
_site/
bin/
.playwright/
.claude/settings.local.json
5 changes: 3 additions & 2 deletions internal/appsetup/appsetup.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,10 @@ func (s *Setup) checkPermissions(inst *forge.Installation, org, role string) {
return
}
s.ui.StepWarn(fmt.Sprintf("app %s missing permissions: %s", inst.AppSlug, strings.Join(missing, ", ")))
permURL := fmt.Sprintf("https://github.com/organizations/%s/settings/apps/%s/permissions", org, inst.AppSlug)
s.permErrors = append(s.permErrors, fmt.Sprintf(
"%s — update at https://github.com/organizations/%s/settings/apps/%s/permissions",
inst.AppSlug, org, inst.AppSlug,
"%s — update at %s",
inst.AppSlug, permURL,
))
}

Expand Down
1 change: 1 addition & 0 deletions internal/appsetup/appsetup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ func TestSetup_CorrectPermissions_NoError(t *testing.T) {
{
ID: 100, AppID: 10, AppSlug: "myorg-fullsend",
Permissions: map[string]string{
"actions": "write",
"contents": "write",
"workflows": "write",
"issues": "read",
Expand Down
2 changes: 2 additions & 0 deletions internal/forge/github/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "fmt"

// AppPermissions defines the permissions for a GitHub App.
type AppPermissions struct {
Actions string `json:"actions,omitempty"`
Issues string `json:"issues,omitempty"`
PullRequests string `json:"pull_requests,omitempty"`
Checks string `json:"checks,omitempty"`
Expand Down Expand Up @@ -63,6 +64,7 @@ func AgentAppConfig(org, role string) AppConfig {
case "fullsend":
base.Description = fmt.Sprintf("Fullsend orchestrator for %s", org)
base.Permissions = AppPermissions{
Actions: "write",
Contents: "write",
Workflows: "write",
Issues: "read",
Expand Down
10 changes: 10 additions & 0 deletions internal/scaffold/fullsend-repo/.github/workflows/code.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# fullsend-stage: code
name: Code

on:
Expand Down Expand Up @@ -113,6 +114,15 @@ jobs:
with:
credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }}

# GCP_OIDC_TOKEN_FILE is expected by google-github-actions/auth when using
# WIF. For non-WIF (SA key), we set it to an empty file to avoid undefined
# variable errors in downstream steps that may reference it.
- name: Set GCP_OIDC_TOKEN_FILE for non-WIF
if: vars.FULLSEND_GCP_AUTH_MODE != 'wif'
run: |
touch "$RUNNER_TEMP/empty-oidc-token"
echo "GCP_OIDC_TOKEN_FILE=$RUNNER_TEMP/empty-oidc-token" >> "${GITHUB_ENV}"

- name: Mask GCP credential file paths
run: |
for var in GOOGLE_GHA_CREDS_PATH GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE; do
Expand Down
159 changes: 159 additions & 0 deletions internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Dispatcher workflow that fans out to agent workflows based on stage
name: Dispatch

on:
workflow_dispatch:
inputs:
stage:
description: 'Stage name (triage, code, review, fix)'
required: true
type: string
event_type:
description: 'Original GitHub event type'
required: true
type: string
source_repo:
description: 'Source repository (owner/repo)'
required: true
type: string
event_payload:
description: 'GitHub event payload as JSON'
required: true
type: string
trigger_source:
description: 'Trigger source username (for fix stage audit trail)'
required: false
type: string

permissions: {}

jobs:
dispatch:
runs-on: ubuntu-latest
Comment thread
ggallen marked this conversation as resolved.
permissions:
actions: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6

Comment thread
ggallen marked this conversation as resolved.
- name: Generate token for triggering workflows
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.FULLSEND_FULLSEND_CLIENT_ID }}
private-key: ${{ secrets.FULLSEND_FULLSEND_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: .fullsend

- name: Validate inputs
env:
STAGE: ${{ inputs.stage }}
SOURCE_REPO: ${{ inputs.source_repo }}
TRIGGER_SOURCE: ${{ inputs.trigger_source }}
run: |
set -euo pipefail

# Validate source_repo format (defense-in-depth before fan-out)
if [[ ! "$SOURCE_REPO" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then
echo "::error::Invalid source_repo format: must be owner/repo"
exit 1
fi

# Validate stage name format (alphanumeric, underscore, hyphen only)
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

# Validate trigger_source format if provided (GitHub username with optional [bot] suffix)
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: Find and trigger agent workflows for stage
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
STAGE: ${{ inputs.stage }}
EVENT_TYPE: ${{ inputs.event_type }}
SOURCE_REPO: ${{ inputs.source_repo }}
Comment thread
ggallen marked this conversation as resolved.
EVENT_PAYLOAD: ${{ inputs.event_payload }}
TRIGGER_SOURCE: ${{ inputs.trigger_source }}
Comment thread
ggallen marked this conversation as resolved.
run: |
set -euo pipefail

echo "Scanning for workflows with stage: $STAGE"

dispatched=0
dispatched_workflows=()
scanned=0
skipped=0

for workflow in .github/workflows/*.yml .github/workflows/*.yaml; do
# Skip if file doesn't exist (glob expansion when no matches)
Comment thread
ggallen marked this conversation as resolved.
[[ -f "$workflow" ]] || continue

scanned=$((scanned + 1))
workflow_name=$(basename "$workflow")

# Never dispatch ourselves (guard against accidental stage marker)
if [[ "$workflow_name" == "dispatch.yml" || "$workflow_name" == "dispatch.yaml" ]]; then
echo "Skipped $workflow_name (self-dispatch guard)"
skipped=$((skipped + 1))
continue
fi

# Extract stage from comment (first line with # fullsend-stage:)
# Format: # fullsend-stage: <stage-name>
# Stage names: lowercase letter + [a-z0-9_-]*
# || true prevents pipefail abort when grep finds no match
workflow_stage=$(grep -E '^# fullsend-stage:' "$workflow" | head -1 | sed -n 's/^# fullsend-stage: *\([a-z][a-z0-9_-]*\).*/\1/p' || true)

# Skip if no stage marker
if [[ -z "$workflow_stage" ]]; then
echo "Skipped $workflow_name (no stage marker)"
skipped=$((skipped + 1))
continue
fi

# Skip if stage doesn't match
if [[ "$workflow_stage" != "$STAGE" ]]; then
echo "Skipped $workflow_name (stage=$workflow_stage, wanted=$STAGE)"
skipped=$((skipped + 1))
continue
fi

Comment thread
ggallen marked this conversation as resolved.
echo "Triggering $workflow_name for stage $STAGE"

# Build gh workflow run arguments
DISPATCH_ARGS=(
--repo "$GITHUB_REPOSITORY"
-f event_type="$EVENT_TYPE"
-f source_repo="$SOURCE_REPO"
-f event_payload="$EVENT_PAYLOAD"
)

# Forward trigger_source if provided (fix stage only)
if [[ -n "${TRIGGER_SOURCE:-}" ]] && [[ "$STAGE" == "fix" ]]; then
DISPATCH_ARGS+=(-f trigger_source="$TRIGGER_SOURCE")
fi

if ! output=$(gh workflow run "$workflow_name" "${DISPATCH_ARGS[@]}" 2>&1); then
echo "::error::Failed to dispatch $workflow_name: $output"
exit 1
fi
dispatched=$((dispatched + 1))
dispatched_workflows+=("$workflow_name")
done
Comment thread
ggallen marked this conversation as resolved.

echo "Scanned $scanned workflow(s), skipped $skipped, dispatched $dispatched"

if [[ $dispatched -eq 0 ]]; then
echo "::error::No workflows found for stage: $STAGE"
exit 1
fi
Comment thread
ggallen marked this conversation as resolved.

echo "Successfully dispatched $dispatched workflow(s) for stage $STAGE: ${dispatched_workflows[*]}"
16 changes: 13 additions & 3 deletions internal/scaffold/fullsend-repo/.github/workflows/fix.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# fullsend-stage: fix
name: Fix

on:
Expand All @@ -15,7 +16,7 @@ on:
trigger_source:
required: true
type: string
description: '"bot" (review agent) or "human" (/fix command)'
description: 'GitHub username that triggered the fix; bot accounts end with [bot]'
pr_number:
required: false
type: string
Expand Down Expand Up @@ -169,7 +170,7 @@ jobs:
# binary rejects empty runner_env values). The agent checks
# TRIGGER_SOURCE before reading this value.
INSTRUCTION="none"
if [[ "${TRIGGER_SOURCE}" == "human" ]]; then
if [[ ! "${TRIGGER_SOURCE}" =~ \[bot\]$ ]]; then
COMMENT_BODY="$(echo "${EVENT_PAYLOAD}" | jq -r '.comment.body // empty')"
if [[ -n "${COMMENT_BODY}" ]]; then
# Strip the /fix prefix to get the instruction.
Expand Down Expand Up @@ -254,7 +255,7 @@ jobs:
echo "::error::Review body is ${BYTE_COUNT} bytes (max: ${MAX_REVIEW_BYTES})"
exit 1
fi
if [[ "${TRIGGER_SOURCE}" == "bot" && "${BYTE_COUNT}" -le 1 ]]; then
if [[ "${TRIGGER_SOURCE}" =~ \[bot\]$ && "${BYTE_COUNT}" -le 1 ]]; then
echo "::error::Bot-triggered run but review body is empty — nothing to fix"
exit 1
fi
Expand Down Expand Up @@ -282,6 +283,15 @@ jobs:
with:
credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }}

# GCP_OIDC_TOKEN_FILE is expected by google-github-actions/auth when using
# WIF. For non-WIF (SA key), we set it to an empty file to avoid undefined
# variable errors in downstream steps that may reference it.
- name: Set GCP_OIDC_TOKEN_FILE for non-WIF
if: vars.FULLSEND_GCP_AUTH_MODE != 'wif'
run: |
touch "$RUNNER_TEMP/empty-oidc-token"
echo "GCP_OIDC_TOKEN_FILE=$RUNNER_TEMP/empty-oidc-token" >> "${GITHUB_ENV}"

- name: Mask GCP credential file paths
run: |
for var in GOOGLE_GHA_CREDS_PATH GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE; do
Expand Down
10 changes: 10 additions & 0 deletions internal/scaffold/fullsend-repo/.github/workflows/review.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# fullsend-stage: review
name: Review

on:
Expand Down Expand Up @@ -104,6 +105,15 @@ jobs:
with:
credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }}

# GCP_OIDC_TOKEN_FILE is expected by google-github-actions/auth when using
# WIF. For non-WIF (SA key), we set it to an empty file to avoid undefined
# variable errors in downstream steps that may reference it.
- name: Set GCP_OIDC_TOKEN_FILE for non-WIF
if: vars.FULLSEND_GCP_AUTH_MODE != 'wif'
run: |
touch "$RUNNER_TEMP/empty-oidc-token"
echo "GCP_OIDC_TOKEN_FILE=$RUNNER_TEMP/empty-oidc-token" >> "${GITHUB_ENV}"

- name: Mask GCP credential file paths
run: |
for var in GOOGLE_GHA_CREDS_PATH GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE; do
Expand Down
10 changes: 10 additions & 0 deletions internal/scaffold/fullsend-repo/.github/workflows/triage.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# fullsend-stage: triage
name: Triage

on:
Expand Down Expand Up @@ -83,6 +84,15 @@ jobs:
with:
credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }}

# GCP_OIDC_TOKEN_FILE is expected by google-github-actions/auth when using
# WIF. For non-WIF (SA key), we set it to an empty file to avoid undefined
# variable errors in downstream steps that may reference it.
- name: Set GCP_OIDC_TOKEN_FILE for non-WIF
if: vars.FULLSEND_GCP_AUTH_MODE != 'wif'
run: |
touch "$RUNNER_TEMP/empty-oidc-token"
echo "GCP_OIDC_TOKEN_FILE=$RUNNER_TEMP/empty-oidc-token" >> "${GITHUB_ENV}"

- name: Mask GCP credential file paths
run: |
for var in GOOGLE_GHA_CREDS_PATH GOOGLE_APPLICATION_CREDENTIALS CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE; do
Expand Down
8 changes: 5 additions & 3 deletions internal/scaffold/fullsend-repo/agents/fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ You operate in one of two modes depending on how you were triggered:
human's instruction conflicts with the review agent's feedback, follow the
human.

The `TRIGGER_SOURCE` environment variable tells you which mode you are in
(`bot` or `human`). When `human`, the `HUMAN_INSTRUCTION` environment variable
contains the instruction text.
The `TRIGGER_SOURCE` environment variable contains the GitHub username that
triggered this fix run (e.g., `"orgname-review[bot]"` for bot-triggered,
`"alice"` for human-triggered). Usernames ending in `[bot]` indicate bot
triggers. When triggered by a human (username doesn't end in `[bot]`), the
`HUMAN_INSTRUCTION` environment variable contains the instruction text.

## Zero-trust principle

Expand Down
17 changes: 12 additions & 5 deletions internal/scaffold/fullsend-repo/scripts/post-fix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
# REPO_FULL_NAME — owner/repo
# PR_NUMBER — PR number
# REPO_DIR — path to extracted repo (default: current directory)
# TRIGGER_SOURCE — "bot" or "human"
# TRIGGER_SOURCE — GitHub username that triggered the fix (usernames ending in [bot] are bot triggers)
#
# Optional environment variables:
# FIX_ITERATION — current iteration count
Expand All @@ -33,6 +33,13 @@
# 1 — validation failure or error (nothing pushed)
set -euo pipefail

# ---------------------------------------------------------------------------
# Helper: Bot user detection
# ---------------------------------------------------------------------------
is_bot_user() {
[[ "${1:-}" =~ \[bot\]$ ]]
}

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -249,7 +256,7 @@ WARN_THRESHOLD=$(( BOT_CAP - 1 ))
# The needs-human label is based on the bot cap — it signals that the
# autonomous review→fix loop needs human direction. Human-triggered /fix
# runs have a separate, higher cap (ITERATION_CAP_HUMAN).
if [ "${ITERATION}" -ge "${WARN_THRESHOLD}" ] && [ "${TRIGGER_SOURCE:-bot}" != "human" ]; then
if [ "${ITERATION}" -ge "${WARN_THRESHOLD}" ] && is_bot_user "${TRIGGER_SOURCE}"; then
echo "::warning::Fix iteration ${ITERATION} is approaching bot cap of ${BOT_CAP}"
gh label create "needs-human" --repo "${REPO_FULL_NAME}" \
--description "Agent loop needs human intervention" --color "D93F0B" \
Expand All @@ -267,8 +274,8 @@ echo " Branch: ${BRANCH:-none}"
echo " PR: #${PR_NUMBER}"
if [ "${NO_PUSH}" = "true" ]; then echo " Pushed: no"; else echo " Pushed: yes"; fi
echo " Trigger: ${TRIGGER_SOURCE}"
if [ "${TRIGGER_SOURCE:-bot}" = "human" ]; then
echo " Iteration: ${ITERATION} of ${ITERATION_CAP_HUMAN:-10} (human cap, total across bot+human)"
else
if is_bot_user "${TRIGGER_SOURCE}"; then
echo " Iteration: ${ITERATION} of ${BOT_CAP} (bot cap)"
else
echo " Iteration: ${ITERATION} of ${ITERATION_CAP_HUMAN:-10} (human cap, total across bot+human)"
fi
Loading
Loading