Skip to content

feat: add per-repo installation mode (ADR 0033)#799

Merged
waynesun09 merged 7 commits into
mainfrom
feat-per-repo-installation
May 14, 2026
Merged

feat: add per-repo installation mode (ADR 0033)#799
waynesun09 merged 7 commits into
mainfrom
feat-per-repo-installation

Conversation

@waynesun09
Copy link
Copy Markdown
Contributor

@waynesun09 waynesun09 commented May 10, 2026

Summary

  • Add reusable-dispatch.yml for centralized per-repo event routing — combines routing logic with conditional workflow_call jobs, including kill switch check, role filtering, and fork PR detection
  • Parameterize all 5 stage reusable workflows with install_mode input (per-org default; per-repo switches customized/ overlay path to .fullsend/customized/)
  • Add install_mode validation to workspace prep steps and validate-enrollment action; fix yq command injection using env() binding
  • Add PerRepoConfig struct with parse/validate/marshal, per-repo shim template, fullsend admin install --repo CLI command, and scaffold helpers
  • Add 12 unit tests for PerRepoConfig covering parsing, validation, marshaling, and round-trip
  • Auto-provision WIF infrastructure when --gcp-auth-mode=wif is used without explicit --gcp-wif-provider: creates WIF pool, OIDC provider, and service account, then binds the principal for the target org
  • Multi-org WIF support: on re-install into a project with an existing provider, fetches current attribute condition via GetWIFProvider, merges org sets, and PATCHes the provider via UpdateWIFProvider
  • Fix WIF provider audience: add IAM audience alongside fullsend-mint so google-github-actions/auth WIF mode works out of the box; on re-install, PATCH existing providers with stale audiences
  • Fix repo secrets ordering: build repoSecrets map after ProvisionWIF so WIF provider and SA email values are populated before writing to GitHub

Per-repo flow

shim (target repo) → reusable-dispatch.yml → reusable-{stage}.yml

3 levels of workflow_call nesting (within GitHub's 4-level limit). No config repo needed — .fullsend/config.yaml and .fullsend/customized/ live in the target repo.

WIF auto-provisioning

When --gcp-auth-mode=wif is specified without --gcp-wif-provider/--gcp-wif-sa-email, the install command auto-provisions:

  1. Service account fullsend-dispatch@{project}.iam.gserviceaccount.com
  2. WIF pool fullsend-pool with GitHub OIDC issuer
  3. WIF provider github-oidc with assertion.repository_owner == '{org}' condition and dual audiences (fullsend-mint + IAM)
  4. IAM binding for the org's attribute.repository_owner principal

On re-install, existing resources are detected (409 → fetch + merge + PATCH) — idempotent and safe for multi-org projects.

Test plan

  • All Go unit tests pass (go test ./...) — config 96.2%, gcf 75.9%, scaffold 73.3%
  • go vet clean
  • Manual test: fullsend admin install --repo --dry-run against nonflux/integration-service — prints correct shim, config, vars, and secrets
  • Manual test: fullsend admin install --repo writes shim + .fullsend/config.yaml + customized/ gitkeeps to target repo
  • Repo variables (FULLSEND_MINT_URL, FULLSEND_GCP_REGION, FULLSEND_GCP_AUTH_MODE) and secrets (FULLSEND_GCP_WIF_PROVIDER, FULLSEND_GCP_WIF_SA_EMAIL, FULLSEND_GCP_PROJECT_ID) set on target repo
  • /triage — full chain passes: shim → reusable-dispatch → reusable-triage → workspace layering → enrollment skip → mint → GCP auth → agent setup (fails at agent run, Investigate standalone openshell-gateway with Docker/Podman drivers across target environments #780)
  • /code — routes to code stage, mint issues coder token, all steps pass through agent setup (Investigate standalone openshell-gateway with Docker/Podman drivers across target environments #780)
  • /review — routes to review stage, mint issues review token, all steps pass (Investigate standalone openshell-gateway with Docker/Podman drivers across target environments #780)
  • /fix — routes to fix stage, fork PR check passes, no-fix label check passes, all steps pass (Investigate standalone openshell-gateway with Docker/Podman drivers across target environments #780)
  • /stop-fix — runs in shim (not dispatched), fullsend-no-fix label applied, workflow succeeds
  • install_mode=per-repo correctly skips enrollment check (logs: "Per-repo mode — skipping config.yaml enrollment check")
  • Workspace prep uses .fullsend/customized/ path in per-repo mode
  • Kill switch read from .fullsend/config.yaml in target repo checkout
  • Role check gates dispatch per configured roles in .fullsend/config.yaml
  • 3-level workflow_call nesting works (shim → reusable-dispatch → reusable-stage)
  • Secrets pass through all 3 levels (GCP auth succeeds at final stage)
  • WIF auto-provisioning creates pool/provider/SA and sets secrets correctly
  • Re-install with existing WIF provider merges org conditions via PATCH
  • repoSecrets map populated after provisioning (not before)

Issues found during testing

  1. WIF provider audience mismatch — the WIF provider was created with only fullsend-mint as an allowed audience. google-github-actions/auth@v3 uses the default IAM audience (https://iam.googleapis.com/projects/{number}/...), which didn't match. Fixed: AllowedAudiences now includes both fullsend-mint (for token mint) and the IAM audience (for google-github-actions/auth). On re-install, existing providers with stale audiences are automatically PATCHed.

  2. Repo secrets written as empty stringsrepoSecrets map was built before ProvisionWIF ran, so WIF provider and SA email were empty. Fixed: map construction moved to after provisioning.

Test environment

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 10, 2026

Site preview

Preview: https://c5a259cf-site.fullsend-ai.workers.dev

Commit: 07a7d2a9b5bcbb884c869dbfa8941eb5c3ca1f52

@fullsend-ai-review
Copy link
Copy Markdown

fullsend-ai-review Bot commented May 10, 2026

Review: #799

Head SHA: 07a7d2a
Timestamp: 2026-05-13T00:00:00Z
Outcome: comment-only

Summary

This PR adds per-repo installation mode (ADR 0033) with substantial changes across CLI, GCP provisioning, workflow dispatch, and scaffold templates. The implementation is well-structured with strong security practices: all user-controlled workflow inputs flow through env: variables (preventing expression injection), the yq command injection vulnerability is properly fixed using env() binding, WIF providers are scoped to individual repositories with tighter attribute conditions, and per-repo mode correctly uses reduced token scopes. The 12 unit tests for PerRepoConfig and comprehensive provisioner tests provide good coverage. No critical or high findings were identified; the findings below are medium and low severity items worth noting.

Findings

Medium

  • [correctness] internal/dispatch/gcf/gcp.go:~208-211 — In the CreateWIFProvider 409-conflict handler, the new undeleteWIFProvider call is best-effort, but both the success and failure branches call UpdateWIFProvider with identical arguments. While functionally correct (undelete restores soft-deleted providers, then update applies config regardless), the branching structure is misleading — it appears each branch does something different. Consider simplifying to _ = c.undeleteWIFProvider(...) followed by a single return c.UpdateWIFProvider(...) to make the best-effort intent explicit.
    Remediation: Collapse the two identical return c.UpdateWIFProvider(...) calls into one after the undelete attempt.

  • [correctness] .github/workflows/reusable-retro.yml:~100 — The GitHub Actions ternary ${{ inputs.install_mode == 'per-repo' && steps.repo-parts.outputs.name || format('{0},.fullsend', steps.repo-parts.outputs.name) }} uses JavaScript-style short-circuit evaluation. If steps.repo-parts.outputs.name were ever empty, the && would evaluate to falsy and fall through to the format() branch even in per-repo mode, incorrectly appending ,.fullsend. The repo-parts step should always produce a name from source_repo, but the expression is fragile.
    Remediation: Consider using an if/else in a shell step instead of inline ternary for clarity and safety.

  • [correctness] .github/workflows/reusable-dispatch.yml:~166 — The FULLSEND_GCP_WIF_PROVIDER secret is declared required: false in the dispatch workflow, but the downstream stage workflows (reusable-code, reusable-fix, reusable-review, reusable-triage) declare it required: true. This asymmetry could cause confusing failures if the secret is ever not provided. Currently safe because the install always sets it, but a latent issue if the install flow evolves.
    Remediation: Either make the dispatch declaration required: true or make the stage workflow declarations required: false to match.

Low

  • [style] internal/dispatch/gcf/gcp.go:~208-211 — As noted above, the redundant branching should be simplified for readability.

  • [correctness] .github/workflows/reusable-dispatch.yml:~358-366 — The kill switch check reads .fullsend/config.yaml but does not validate the install_mode input before checking the config file. If install_mode is invalid, the workflow would fail later at the stage level rather than at the routing level. The stage-level install_mode validation catches this, so this is defense-in-depth only.

  • [style] .github/workflows/reusable-dispatch.yml:~450-453 — Several stage job secrets: blocks have extra blank lines between FULLSEND_GCP_WIF_PROVIDER and FULLSEND_GCP_PROJECT_ID. These appear to be formatting artifacts.

Info

  • [security-positive] .github/actions/validate-enrollment/action.yml:~55 — The yq command injection fix using env() binding (REPO_NAME="${REPO_NAME}" yq 'env(REPO_NAME) as $name | .repos[$name].enabled') is a genuine security improvement over the previous shell-interpolated approach.

  • [security-positive] .github/workflows/reusable-dispatch.yml — All user-controlled inputs (comment body, labels, usernames, review state) are passed through env: variables rather than inline ${{ }} expressions in run: blocks, correctly preventing GitHub Actions expression injection.

  • [security-positive] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — Uses pull_request_target to run base branch code, preventing PR authors from modifying the workflow to exfiltrate credentials. The security comments in the template header are thorough and accurate.

  • [security-positive] internal/cli/admin.go — Per-repo mode correctly uses reduced scopes (repo, workflow) compared to per-org mode (repo, workflow, admin:org), following least-privilege principles.

  • [testing] internal/dispatch/gcf/provisioner_test.go — Comprehensive test coverage for ProvisionWIF, BuildRepoProviderID, CopyAgentPEM, and GetExistingRoleAppIDs with both happy-path and error-path cases.

  • [testing] .pre-commit-config.yaml — Exclusion of provisioner_test.go from detect-private-key is appropriate since the test file contains PEM key material for test fixtures.

Footer

Outcome: comment-only
This review applies to SHA 07a7d2a9b5bcbb884c869dbfa8941eb5c3ca1f52. Any push to the PR head clears this review and requires a new evaluation.

Previous run

Review: #799

Head SHA: 255699f
Timestamp: 2026-05-13T00:00:00Z
Outcome: approve

Summary

This PR adds per-repo installation mode across the full stack: CLI (fullsend admin install owner/repo), reusable dispatch workflow, workspace preparation, enrollment validation, WIF auto-provisioning, config scaffolding, and shared app PEM copying. The implementation is well-structured with clean separation between per-org and per-repo code paths, strong input validation (owner/repo format, role names, HTTPS-only mint URL, mode-exclusive flag enforcement), and comprehensive test coverage (12+ new unit tests covering config, provisioning, CLI validation, and scope checks). All user-controlled inputs in workflows are passed via env: variables — not interpolated in run: blocks. The yq command injection fix using env() binding is a good security hardening. The WIF attribute condition for per-repo mode (assertion.repository == 'owner/repo') is tighter than the org-scoped condition, and the shim correctly uses pull_request_target to prevent pwn-request attacks. No critical or high findings.

Findings

Medium

None

Low

  • [correctness] internal/dispatch/gcf/gcp.go:208-211 — The 409 conflict handler for CreateWIFProvider calls undeleteWIFProvider and then calls UpdateWIFProvider regardless of the undelete result. Both the success and failure branches execute identical code (return c.UpdateWIFProvider(...)), making the if err == nil early return redundant. The logic is correct but could be simplified to a single UpdateWIFProvider call after the undelete attempt.
    Remediation: Simplify to c.undeleteWIFProvider(ctx, ...); return c.UpdateWIFProvider(ctx, ...)

  • [style] internal/cli/admin.go (copySharedAppPEMs) — The error from prov.SecretExists(ctx, org, role) is discarded with _. While CopyAgentPEM has its own existence check (making this safe in practice), silently ignoring errors could mask transient failures in diagnostics.
    Remediation: Consider logging the error at debug level if non-nil.

Info

  • [style] .github/workflows/reusable-dispatch.yml — Several blank lines appear between secret declarations in the job definitions (e.g., between FULLSEND_GCP_WIF_PROVIDER and FULLSEND_GCP_PROJECT_ID), likely artifacts from removing FULLSEND_GCP_WIF_SA_EMAIL. Minor formatting cleanup opportunity.

  • [correctness] internal/config/config.goPerRepoConfig.Validate() accepts empty roles ([]string{}), and NewPerRepoConfig(nil) falls back to DefaultAgentRoles(), but there's no path that creates an empty-roles config in production. The empty-roles-is-valid semantic should be intentional (allows a config that disables all agents).

  • [correctness] .github/workflows/reusable-retro.yml:100 — The mint-token repos expression uses a GHA ternary: ${{ inputs.install_mode == 'per-repo' && steps.repo-parts.outputs.name || format('{0},.fullsend', steps.repo-parts.outputs.name) }}. This is correct but relies on repo-parts.outputs.name being non-empty (which it always will be given the upstream validation).

Footer

Outcome: approve
This review applies to SHA 255699f08ddd898fba462c1b4001f398e3a13030. Any push to the PR head clears this review and requires a new evaluation.


Protected paths detected — this PR modifies files under one or more
protected paths. The review agent cannot approve PRs that touch these paths.
A human reviewer must approve this PR.

Protected files in this PR:

  • .github/actions/validate-enrollment/action.yml
  • .github/workflows/reusable-code.yml
  • .github/workflows/reusable-dispatch.yml
  • .github/workflows/reusable-fix.yml
  • .github/workflows/reusable-retro.yml
  • .github/workflows/reusable-review.yml
  • .github/workflows/reusable-triage.yml
  • .pre-commit-config.yaml
Previous run (2)

Review: #799

Head SHA: b213c6b
Timestamp: 2026-05-13T00:00:00Z
Outcome: request-changes

Summary

This PR adds per-repo installation mode — a significant and well-structured feature spanning workflow routing, CLI commands, WIF provisioning, and config management. The Go code is well-tested (12 unit tests for config, comprehensive provisioner tests, CLI flag validation tests) and the security-sensitive workflow code properly uses env: bindings instead of expression interpolation for user-controlled inputs. However, two high-severity findings block merge: (1) all 27 uses: directives across 7 workflow files reference the @feat-per-repo-installation feature branch instead of a stable ref, which would break all per-repo installations if the branch is deleted post-merge; (2) the stop-fix job in the shim template grants CONTRIBUTOR-level authorization, which is inconsistent with the stricter OWNER|MEMBER|COLLABORATOR check used in reusable-dispatch.yml.

Findings

High

  • [release-blocker] reusable-dispatch.yml, reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, shim-per-repo.yaml — All 27 uses: directives reference @feat-per-repo-installation instead of a stable ref (@v0 or commit SHA). If this branch is deleted after merge, every per-repo installation and all modified reusable workflows will fail immediately.
    Remediation: Replace all @feat-per-repo-installation references with @v0 (or the appropriate release tag) before merging.

  • [auth-bypass] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — The stop-fix job allows author_association == 'CONTRIBUTOR' to use /stop-fix, but reusable-dispatch.yml's is_authorized() function limits to OWNER|MEMBER|COLLABORATOR. A past contributor without current repo access could disable the fix agent on any PR by adding the fullsend-no-fix label.
    Remediation: Remove CONTRIBUTOR from the stop-fix authorization check to align with is_authorized(), or document the intentional difference.

Medium

  • [content-security] .github/workflows/reusable-dispatch.yml — The event_payload output includes comment.body (truncated to 4096 chars), which is user-controlled content that flows through the entire dispatch chain into agent prompts. While handled safely via env: variables (never interpolated in ${{ }} within run: blocks), the generous 4096-char budget increases prompt injection surface area for downstream agents.
    Remediation: Consider reducing the truncation limit or fetching comment body directly in stage workflows via API to reduce the injection surface.

  • [correctness] .github/workflows/reusable-dispatch.ymlFULLSEND_GCP_WIF_PROVIDER is declared required: false in the dispatch workflow's secrets, but downstream stage workflows (e.g., reusable-code.yml, reusable-fix.yml) declare it required: true. If a per-repo installation is configured without WIF provider for any reason, the dispatch workflow will accept the call but the stage workflow will fail with a confusing error.
    Remediation: Either make FULLSEND_GCP_WIF_PROVIDER required in the dispatch workflow, or make it optional in stage workflows with a clear error message.

Low

  • [correctness] internal/dispatch/gcf/gcp.go — In CreateWIFProvider, the 409 handler calls UpdateWIFProvider in both branches (after successful undelete and after failed undelete). While functionally correct (both paths need an update), the redundancy is slightly confusing. The failed-undelete path means the provider exists but wasn't soft-deleted, so the update is appropriate.

  • [style] .github/workflows/reusable-dispatch.yml — Several blank lines between FULLSEND_GCP_WIF_PROVIDER and FULLSEND_GCP_PROJECT_ID in the secrets pass-through sections of each stage job (e.g., lines 477-479, 496-498). Minor formatting inconsistency.

Info

  • [defense-in-depth] .github/workflows/reusable-dispatch.yml — The "Validate routed stage" step properly validates STAGE and TRIGGER_SOURCE outputs against strict regex patterns before they flow into downstream workflow calls. Good practice.

  • [defense-in-depth] .github/actions/validate-enrollment/action.yml — The yq command injection fix (env(REPO_NAME) binding instead of string interpolation) is a solid security improvement.

  • [correctness] internal/cli/admin.go — The checkTokenScopes refactoring correctly preserves the original checkInstallScopes behavior while making it reusable for the reduced perRepoRequiredScopes set (repo, workflow — no admin:org). The test TestPerRepoRequiredScopes_SubsetOfInstallScopes ensures the invariant holds.

  • [correctness] internal/cli/admin.gorepoSecrets map is correctly built after ProvisionWIF completes, fixing the empty-secrets bug described in the PR body.

  • [correctness] internal/dispatch/gcf/provisioner.goProvisionWIF makes a copy of the orgs slice for case normalization, avoiding mutation of the caller's input (verified by TestProvisionWIF_DoesNotMutateInput).

Footer

Outcome: request-changes
This review applies to SHA b213c6ba4dab3a519210d965d6ceb24900bed2f1. Any push to the PR head clears this review and requires a new evaluation.

Previous run (3)

Review: #799

Head SHA: 11e9436
Timestamp: 2026-05-13T00:00:00Z
Outcome: request-changes

Summary

This PR adds per-repo installation mode (ADR 0033) with a well-structured design: a new reusable-dispatch.yml for centralized routing, parameterized stage workflows with install_mode, PerRepoConfig type with validation, ProvisionWIF for auto-provisioning WIF infrastructure, and a CLI install --repo command. The code demonstrates strong security practices throughout — all user-controlled inputs in workflow run: blocks are passed via env: variables (no script injection), event payloads are extracted from $GITHUB_EVENT_PATH with body truncation, and the yq command injection vulnerability is fixed using env() binding. However, 26 workflow uses: directives reference @feat-per-repo-installation instead of a stable tag, which will break after the branch is deleted post-merge. This includes the shim template committed to target repos, making new per-repo installations reference a nonexistent branch.

Findings

High

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-review.yml, reusable-triage.yml, reusable-retro.yml, reusable-dispatch.yml, internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — 26 uses: directives reference @feat-per-repo-installation instead of @v0 (or the appropriate stable ref). After merge, when the feature branch is deleted: (1) all reusable workflows cross-referencing actions/workflows at this branch will fail for any installation on the new v0 tag, and (2) new per-repo installations will receive a shim template pointing to a nonexistent branch, breaking their workflows immediately.
    Remediation: Replace all @feat-per-repo-installation references with @v0 (or the intended stable tag) before merging. The shim template in internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml is especially critical since it is committed directly to target repositories.

Medium

None.

Low

  • [Correctness] .github/workflows/reusable-dispatch.ymlFULLSEND_GCP_WIF_PROVIDER is declared required: false in the dispatch workflow inputs, but all downstream stage workflows (reusable-code.yml, reusable-fix.yml, etc.) declare it required: true. If a caller omits this secret, the error will surface in the nested stage workflow rather than at the dispatch level, making failures harder to diagnose. Consider either making it required: true in dispatch or adding a comment explaining why it is intentionally optional.

  • [Style] .github/workflows/reusable-dispatch.yml — Several job secrets: blocks have extra blank lines between entries (e.g., between FULLSEND_GCP_WIF_PROVIDER and FULLSEND_GCP_PROJECT_ID in the triage, code, review, fix, and retro jobs). Minor formatting inconsistency.

Info

  • [Injection defense] .github/actions/validate-enrollment/action.yml — The yq invocation was changed from shell-interpolated yq ".repos.\"$REPO_NAME\".enabled" to env(REPO_NAME) as $name | .repos[$name].enabled, eliminating a command injection vector via crafted repo names. Good security improvement.

  • [Platform security] .github/workflows/reusable-dispatch.yml — All user-controlled GitHub context values (COMMENT_BODY, COMMENT_USER_LOGIN, ISSUE_LABELS, PR_LABELS, etc.) are passed exclusively via env: mappings, never interpolated in run: blocks. The event payload is constructed from $GITHUB_EVENT_PATH with comment body truncated to 4096 characters. Strong injection defense.

  • [Platform security] internal/dispatch/gcf/provisioner.go — Per-repo WIF provider uses assertion.repository == 'owner/repo' (exact match) rather than the org-level assertion.repository_owner pattern, providing tighter least-privilege scoping.

  • [Correctness] internal/dispatch/gcf/gcp.go — The 409 conflict handler for CreateWIFProvider now tries undeleteWIFProvider before falling through to UpdateWIFProvider. This correctly handles both soft-deleted providers (undelete + update) and active providers (undelete fails, update proceeds). Well-designed idempotency.

  • [Correctness] internal/cli/admin.go — The repoSecrets map is constructed after ProvisionWIF completes, fixing the bug where WIF provider path was empty. The code also validates owner/repo format, HTTPS-only mint URLs, and role names with strict regex patterns.

  • [Correctness] Test coverage is thorough: 12 unit tests for PerRepoConfig (parse, validate, marshal, round-trip), comprehensive ProvisionWIF tests (happy path, error cases, repo-scoped vs org-scoped, input mutation), CLI validation tests (format rejection, flag mode conflicts, scope checks), and BuildRepoProviderID tests with boundary cases.

  • [Intent alignment] No prompt injection patterns or non-rendering Unicode detected in the PR description, commit messages, or code comments. PR body content is descriptive and factual.

Footer

Outcome: request-changes
This review applies to SHA 11e9436228900b72738106cf06b8e32a115aac07. Any push to the PR head clears this review and requires a new evaluation.

Previous run (4)

Review: #799

Head SHA: 4cae81e
Timestamp: 2026-05-13T00:00:00Z
Outcome: request-changes

Summary

This PR adds a well-structured per-repo installation mode with solid input validation, comprehensive test coverage (12+ new tests), a good yq injection fix, and clean refactoring of token scope checks. However, all workflow uses: references are pinned to @feat-per-repo-installation instead of the release tag @v0. This is a critical finding: once this branch is deleted after merge, every workflow — both existing per-org installations and any newly created per-repo installations — will break. The shim template (shim-per-repo.yaml) is especially dangerous because it gets committed to target repos, meaning each affected repo would need a manual fix.

Findings

Critical

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, reusable-dispatch.yml, internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — All uses: references to fullsend actions and reusable workflows are pinned to @feat-per-repo-installation instead of @v0. This includes:

    • Action references: validate-enrollment@feat-per-repo-installation, mint-token@feat-per-repo-installation, setup-gcp@feat-per-repo-installation, fullsend-ai/fullsend@feat-per-repo-installation
    • Reusable workflow references in reusable-dispatch.yml: all 5 stage workflow calls
    • The shim template committed to target repos: reusable-dispatch.yml@feat-per-repo-installation

    When the feature branch is deleted after merge, all workflows will fail with "reference not found". The shim template is the highest-risk item because it persists in target repositories and would require per-repo manual fixes.

    Remediation: Revert all @feat-per-repo-installation references back to @v0 before merging.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:209-213 — In CreateWIFProvider, on HTTP 409, the code tries undeleteWIFProvider and then calls UpdateWIFProvider regardless of whether undelete succeeded or failed. Both branches of the conditional execute the same call. While functionally correct (the provider exists either way), this is confusing. If undelete fails for a reason other than "not actually deleted" (e.g., permissions error), silently falling through to update may mask the real error.

    Remediation: Consider logging or distinguishing the undelete failure reason, or simplify to a single UpdateWIFProvider call after the undelete attempt.

Info

  • [Correctness] validate-enrollment/action.yml:55 — Good fix: replaced yq ".repos.\"$REPO_NAME\".enabled" with env(REPO_NAME) binding, eliminating a potential command injection vector through crafted repository names.

  • [Style/conventions] internal/cli/admin.go — Clean refactoring of checkInstallScopes into the generic checkTokenScopes helper, reducing duplication and enabling the new checkPerRepoScopes with a smaller required scope set (repo, workflow — no admin:org needed).

  • [Correctness] Test coverage is comprehensive: PerRepoConfig parse/validate/marshal/round-trip, ProvisionWIF happy path and error cases, BuildRepoProviderID edge cases (truncation, normalization), CopyAgentPEM with source/destination handling, CLI validation for owner/repo format, URL rejection, flag cross-validation, and scope checks.

Footer

Outcome: request-changes
This review applies to SHA 4cae81e109c915d5e732f083d05023cdc2fe7278. Any push to the PR head clears this review and requires a new evaluation.

Previous run (5)

Review: #799

Head SHA: fb2aa05
Timestamp: 2026-05-13T00:00:00Z
Outcome: request-changes

Summary

This is a large, well-structured feature PR that adds per-repo installation mode alongside the existing per-org mode. The Go code is solid — good input validation, comprehensive tests (12+ new unit tests for config, 15+ for provisioner/WIF, 10+ for CLI), and clean separation of concerns. The workflow security posture is strong: all user-controlled inputs flow through env: mappings (not inline ${{ }} interpolation), jq --arg is used for JSON construction, and the new reusable-dispatch.yml properly validates stage names and trigger sources against strict regexes. However, all workflow and action uses: references have been changed from @v0 to @feat-per-repo-installation (the feature branch name), which will break all dispatched workflows once this branch is deleted after merge. This must be reverted before merge.

Findings

High

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-review.yml, reusable-retro.yml, reusable-triage.yml, reusable-dispatch.yml, internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — All uses: references changed from @v0 to @feat-per-repo-installation (22+ occurrences across 7 files). These are feature branch refs that will break when the branch is deleted after merge. The shim template (shim-per-repo.yaml) is particularly critical because it's scaffolded into target repos — once written, those repos will reference a deleted branch.
    Remediation: Revert all @feat-per-repo-installation references back to @v0 (or whichever release tag is appropriate) before merging.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:208-217 — The 409 Conflict handling in CreateWIFProvider now attempts undeleteWIFProvider first, then calls UpdateWIFProvider regardless of whether undelete succeeded or failed. While functionally correct (both paths converge on Update), when undelete fails the error is silently discarded. If undelete returns a transient error (e.g., network timeout), the subsequent UpdateWIFProvider may also fail with a confusing error since the provider is still in a soft-deleted state.
    Remediation: Log or wrap the undelete error for debuggability. Consider: if undelete fails with a non-404 error, propagate it rather than falling through to Update.

  • [Style/conventions] .github/workflows/reusable-dispatch.yml — The workspace prep logic (install_mode validation + CUSTOM_BASE path selection) is duplicated identically across all 5 reusable stage workflows (code, fix, retro, review, triage). Any future change to this logic must be replicated in 5 places.
    Remediation: Consider extracting the workspace prep into a composite action (e.g., .github/actions/prepare-workspace/action.yml) to centralize this logic.

  • [Correctness] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:59 — The stop-fix job's if: condition checks github.event.comment.body == '/stop-fix', which requires an exact match. A comment like /stop-fix please or /stop-fix\n (with trailing newline) will not match. The per-org shim in fullsend.yaml uses the same exact-match pattern, so this is consistent, but could surprise users.
    Remediation: Consider using startsWith(github.event.comment.body, '/stop-fix') for robustness, or document the exact-match requirement.

Low

  • [Correctness] internal/dispatch/gcf/provisioner.go:140-150SecretExists does not validate org and role inputs with githubOrgPattern/rolePattern before constructing the secret ID, unlike its sibling methods StoreAgentPEM and CopyAgentPEM which both validate. Currently callers pass pre-validated values, but the public API lacks self-defense.
    Remediation: Add input validation consistent with StoreAgentPEM.

  • [Style/conventions] internal/cli/admin.goperOrgOnlyFlags and perRepoOnlyFlags are validated by iterating cmd.Flags().Changed(name) against string arrays. If a flag name is misspelled in these arrays, the check silently passes. No test asserts that these flag names actually exist on the command.
    Remediation: Add a test that verifies each name in perOrgOnlyFlags and perRepoOnlyFlags corresponds to a registered flag.

  • [Correctness] internal/dispatch/gcf/provisioner.go (BuildRepoProviderID) — When the sanitized provider ID is exactly 32 characters and the last character is a hyphen, TrimRight shortens it further, which is correct. However, if the input is pathological (e.g., owner and repo are all special characters), the result could be shorter than 4 characters (GCP's minimum). No minimum-length check exists.
    Remediation: Add a minimum-length guard after trimming.

Info

  • [Injection defense] PR body and commit messages were inspected for prompt injection patterns and non-rendering Unicode. No injection attempts detected. The PR description is descriptive and consistent with the actual diff.

  • [Platform security] The reusable-dispatch.yml workflow properly scopes the route job permissions to contents: read and pull-requests: read — minimal for its purpose. User-controlled inputs (COMMENT_BODY, COMMENT_USER_LOGIN, ISSUE_LABELS, etc.) are all passed via env: bindings, never interpolated in run: blocks. Stage name and trigger source are validated against strict regexes before being written to GITHUB_OUTPUT.

  • [Platform security] The validate-enrollment action's yq command injection fix (using env(REPO_NAME) binding instead of shell interpolation "$REPO_NAME") is a genuine security improvement that eliminates a shell injection vector through crafted repository names.

  • [Correctness] The per-repo WIF provisioning correctly scopes the attribute condition to assertion.repository == 'owner/repo' (not org-wide repository_owner), and creates a dedicated provider ID per repo (gh-owner-repo), preventing cross-repo credential theft.

  • [Correctness] repoSecrets map is correctly built after ProvisionWIF completes, fixing the empty-secrets bug described in the PR body.

Footer

Outcome: request-changes
This review applies to SHA fb2aa05c82cd6d61937ad0dec89c151c0c9f7f39. Any push to the PR head clears this review and requires a new evaluation.

Previous run (6)

Review: #799

Head SHA: 0fcbbdc
Timestamp: 2026-05-13T00:00:00Z
Outcome: request-changes

Summary

This is a substantial, well-structured PR that adds per-repo installation mode alongside the existing per-org mode. The Go code is solid with thorough validation, good test coverage (12+ unit tests for config, extensive provisioner tests, CLI flag tests), and clean separation of concerns. The WIF auto-provisioning logic is idempotent and handles re-install gracefully. However, all reusable workflow and action references are hardcoded to the feature branch (@feat-per-repo-installation) instead of a stable tag — this is a critical issue that must be resolved before merge, as it violates the project's own ADR 0031 pinning convention and creates a fragile dependency on a mutable branch for all downstream consumers.

Findings

Critical

  • [Correctness] .github/workflows/reusable-dispatch.yml and all 5 reusable stage workflows — All uses: references point to @feat-per-repo-installation (e.g., fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@feat-per-repo-installation, fullsend-ai/fullsend@feat-per-repo-installation). This is the feature branch name, not a release tag. Once this branch is merged and deleted, every per-repo installation will break because GitHub Actions cannot resolve the ref. ADR 0031 explicitly requires tag or SHA pinning for upstream workflow references. The same issue affects the per-repo shim template at internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml.
    Remediation: Replace all @feat-per-repo-installation references with the target release tag (e.g., @v0 or the next version tag). If the tag doesn't exist yet, use a placeholder that the release process will update, or coordinate with the release so the tag exists before merge.

High

  • [Correctness] internal/dispatch/gcf/gcp.go:209-212 — When CreateWIFProvider gets a 409 Conflict, it attempts undeleteWIFProvider, and regardless of whether undelete succeeds or fails, it always falls through to call UpdateWIFProvider. If undelete succeeds, UpdateWIFProvider is called twice (once from the return on line 211, and the fallthrough on line 212 is unreachable in that case — but if undelete returns an error, the error is silently swallowed and UpdateWIFProvider is called anyway). The logic is confusing: the if err == nil branch returns the same call as the fallthrough, making the undelete attempt's error irrelevant. Consider restructuring to: attempt undelete, then unconditionally call UpdateWIFProvider (which is the current effective behavior but should be explicit).
    Remediation: Simplify to: attempt undelete (ignore error since it may not be soft-deleted), then call UpdateWIFProvider once. Remove the redundant conditional return.

  • [Correctness] internal/cli/admin.go (runPerRepoInstall) — The FULLSEND_GCP_AUTH_MODE variable referenced in ADR 0033 (line 261: "Sets repository variables (FULLSEND_MINT_URL, FULLSEND_GCP_REGION, FULLSEND_GCP_AUTH_MODE)") is not set by the per-repo install code. The repoVars map only contains FULLSEND_MINT_URL and FULLSEND_GCP_REGION. If downstream workflows or docs reference this variable, per-repo installations will have it unset.
    Remediation: Either add FULLSEND_GCP_AUTH_MODE: "wif" to the repoVars map, or update the ADR to reflect that this variable is not used in per-repo mode.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:210 — The undeleteWIFProvider call does not log or surface information about the undelete attempt. In operational debugging, it would be useful to know whether a 409 was caused by a soft-deleted provider or a live one. Consider adding structured logging.
    Remediation: Add a log line indicating whether undelete succeeded or failed before proceeding to UpdateWIFProvider.

  • [Style/conventions] .github/workflows/reusable-dispatch.yml — Several job definitions have inconsistent blank lines in the secrets: block (e.g., lines 483-485 have a blank line between FULLSEND_GCP_WIF_PROVIDER and FULLSEND_GCP_PROJECT_ID). This pattern repeats in all 5 stage job definitions in the dispatch workflow.
    Remediation: Remove the extraneous blank lines in the secrets blocks for consistency.

  • [Intent alignment] internal/cli/admin.go — The --gcp-wif-sa-email flag has been removed (confirmed by test assertion), but the PR description still references "SA email" in the secrets list ("WIF provider and SA email values are populated before writing to GitHub"). The repoSecrets map in runPerRepoInstall only contains FULLSEND_GCP_PROJECT_ID and FULLSEND_GCP_WIF_PROVIDER — no SA email. This is correct behavior (direct WIF without intermediate SA), but the PR description is misleading.
    Remediation: No code change needed; this is a documentation/description accuracy note.

Low

  • [Style/conventions] internal/cli/admin.goperOrgOnlyFlags and perRepoOnlyFlags are package-level vars but are only used within newInstallCmd. Consider making them local to the function or documenting why they need package scope.

Info

  • [Correctness] The per-repo shim template correctly uses pull_request_target (not pull_request) for the security properties described in the comments. The stop-fix job correctly runs inline rather than dispatching, avoiding unnecessary workflow nesting.

  • [Platform security] The WIF attribute condition for per-repo mode (assertion.repository == 'owner/repo') is correctly scoped to the specific repository, tighter than the per-org mode which scopes to repository_owner. This is good security practice.

  • [Correctness] The yq command injection fix in validate-enrollment/action.yml (using env() binding instead of string interpolation) is a genuine security improvement.

Footer

Outcome: request-changes
This review applies to SHA 0fcbbdc5c0df476db70dc19f8576a7f0b691c06d. Any push to the PR head clears this review and requires a new evaluation.

Previous run (7)

Review: #799

Head SHA: a068690
Timestamp: 2026-05-12T00:00:00Z
Outcome: request-changes

Summary

This PR adds per-repo installation mode alongside the existing per-org mode — a substantial and well-structured feature with good test coverage (12 config tests, comprehensive provisioner tests, CLI validation tests). The Go code is solid: input validation is thorough, WIF auto-provisioning is idempotent, the yq command injection fix is a genuine security improvement, and secrets are zeroed after use. However, all 31 reusable workflow @ references point to the feature branch feat-per-repo-installation instead of a stable release ref (v0). If merged as-is, every workflow dispatch in production will reference a feature branch that may be deleted post-merge, breaking all agent runs. This must be resolved before merge.

Findings

Critical

  • [Correctness] .github/workflows/reusable-dispatch.yml, reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — All 31 uses: references to fullsend-ai/fullsend/.github/workflows/... and fullsend-ai/fullsend/.github/actions/... are pinned to @feat-per-repo-installation instead of a stable ref like @v0. If this branch is deleted after merge (standard practice), every workflow run will fail with "ref not found". The scaffolded shim template (shim-per-repo.yaml) is especially dangerous because it gets committed to target repos — those repos will have a permanently broken workflow file.
    Remediation: Replace all @feat-per-repo-installation refs with the stable release ref (@v0 or the appropriate tag) before merging. Consider adding a CI check that blocks merging workflow files containing non-release branch refs.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:206-210 — The 409-conflict handler in CreateWIFProvider has redundant logic: both the success and failure branches of the undelete attempt call UpdateWIFProvider. The if err == nil branch returns UpdateWIFProvider, and the fallthrough also calls UpdateWIFProvider. While functionally correct, this obscures intent and means an undelete error is silently swallowed before attempting an update that may produce a confusing secondary error.
    Remediation: Simplify to _ = c.undeleteWIFProvider(...) followed by a single return c.UpdateWIFProvider(...), or log the undelete error for debuggability.

  • [Style/conventions] .github/workflows/reusable-dispatch.yml:467-468 — The yq usage in the kill switch and role check steps (yq '.kill_switch // false', yq '.roles[]') does not use the env() binding pattern that was correctly applied in the validate-enrollment action to fix command injection. While these particular values (boolean and role names) are lower risk since they come from the repo's own config file, using the safe pattern consistently would be better defense-in-depth.
    Remediation: Use env() binding for yq expressions where applicable, matching the pattern in validate-enrollment.

Low

  • [Correctness] internal/cli/admin.go (around copySharedAppPEMs) — Errors from client.ListOrgInstallations and prov.GetExistingRoleAppIDs are silently swallowed (returning nil, nil). This is likely intentional for graceful degradation when the mint doesn't exist yet, but it means genuine API failures (network errors, auth issues) are invisible to the operator.
    Remediation: Consider logging a warning when these calls fail, to aid debugging.

  • [Style/conventions] internal/dispatch/gcf/provisioner.go (ProvisionWIF) — The method mutates p.cfg.WIFProvider in-place when in per-repo mode (line p.cfg.WIFProvider = BuildRepoProviderID(...)). Mutating config state as a side effect of a method call is surprising. The DoesNotMutateInput test covers the GitHubOrgs slice but not this config field mutation.
    Remediation: Use a local variable for the provider ID instead of mutating the config struct.

Info

  • [Intent alignment] No linked issue. This is a large feature PR (~2500 lines across 19 files). The scope is appropriate for the stated goal (per-repo installation mode per ADR 0033). Test coverage is strong.

  • [Injection defense] The PR body and commit messages contain no prompt injection patterns or non-rendering Unicode. The yq injection fix in validate-enrollment (switching from string interpolation to env() binding) is a positive security improvement.

Footer

Outcome: request-changes
This review applies to SHA a0686909f9775cda64be385eb966f3e5b0c49588. Any push to the PR head clears this review and requires a new evaluation.

Previous run (8)

Review: #799

Head SHA: 5e8b76c
Timestamp: 2026-05-12T00:00:00Z
Outcome: request-changes

Summary

This is a well-structured, large feature PR adding per-repo installation mode with solid test coverage and good security practices in the workflow design (input sanitization via env: bindings, fork PR detection, yq command injection fix). However, every reusable workflow and action reference — including the shim template installed to customer repositories — is hardcoded to @feat-per-repo-installation. Once this feature branch is deleted post-merge, all workflows break. This must be resolved before merge.

Findings

Critical

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, reusable-dispatch.yml — All uses: references to fullsend-ai/fullsend/.github/actions/* and fullsend-ai/fullsend/.github/workflows/* are pinned to @feat-per-repo-installation (changed from @v0). After merge, this feature branch will be deleted. Every reusable workflow call and action reference will resolve to a non-existent ref, breaking all agent dispatch for both per-org and per-repo modes.
    Remediation: Revert these refs to @v0 (or the appropriate stable release tag) before merge. If a new tag is needed to include the new install_mode input and reusable-dispatch.yml, cut it first and update refs to that tag.

  • [Correctness] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:43 — The per-repo shim template hardcodes uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@feat-per-repo-installation. This template is committed to customer repositories via fullsend admin install --repo. When the feature branch is deleted, every installed customer repository's CI breaks with no automatic recovery path.
    Remediation: Pin to a stable release tag (e.g., @v0 or a new tag that includes reusable-dispatch.yml).

High

(none)

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:206-209 — The 409 conflict handler in CreateWIFProvider calls UpdateWIFProvider in both branches: if undeleteWIFProvider succeeds, it returns the update result; if undelete fails, it falls through to the same UpdateWIFProvider call. The error from undeleteWIFProvider is silently discarded. While functionally correct (both soft-deleted and existing providers end up updated), the redundant structure obscures intent and hides potentially useful diagnostic information.
    Remediation: Simplify to: attempt undelete (log error if any), then always call UpdateWIFProvider once. Or add a comment explaining the intentional fallthrough pattern.

  • [Style/conventions] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:56 — The stop-fix job allows CONTRIBUTOR author association, but the dispatch routing's is_authorized() function in reusable-dispatch.yml:271-275 only allows OWNER|MEMBER|COLLABORATOR. This asymmetry means a contributor can stop the fix agent but cannot trigger it. If intentional (lower privilege to disable than enable), it should be documented in a comment.

Low

  • [Content security] PR body — Contains real GCP project name (it-gcp-konflux-dev-fullsend), real org name (nonflux), and real repo (nonflux/integration-service). CLAUDE.md states: "Never commit secrets... or sensitive data (GCP project names...)." While PR body text is not committed code, these values are now permanently visible in the PR history.

Info

  • [Style/conventions] internal/cli/admin.go — The sortedStringMapKeys helper is a generic utility. Go 1.21+ provides slices.Sorted(maps.Keys(m)) which could replace it. Low priority, cosmetic only.

  • [Correctness] .github/actions/validate-enrollment/action.yml:68 — The yq command injection fix (env(REPO_NAME) binding instead of "$REPO_NAME" interpolation) is a good security improvement. Worth calling out positively.

  • [Correctness] internal/dispatch/gcf/provisioner.goProvisionWIF copies the input GitHubOrgs slice before normalizing to lowercase, preserving the original. Good defensive practice confirmed by TestProvisionWIF_DoesNotMutateInput.

Footer

Outcome: request-changes
This review applies to SHA 5e8b76c2eb8d0e69ad2891c8a4f5e89dbfae5f49. Any push to the PR head clears this review and requires a new evaluation.

Previous run (9)

Review: #799

Head SHA: 4e7e074
Timestamp: 2026-05-12T00:00:00Z
Outcome: request-changes

Summary

This PR adds a comprehensive per-repo installation mode alongside the existing per-org mode, including a new reusable dispatch workflow, CLI command routing, WIF auto-provisioning, shared app PEM handling, and a PerRepoConfig struct with thorough unit tests. The code quality is generally high — input validation is strong, user-controlled inputs in workflows use env: bindings (not string interpolation), and the yq command injection fix is a genuine security improvement. However, all workflow and action refs have been changed from @v0 to @feat-per-repo-installation, which is the feature branch name. If merged as-is, all existing per-org workflows and the per-repo shim template will reference a branch that will likely be deleted post-merge, breaking all agent dispatch.

Findings

Critical

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, reusable-dispatch.yml, internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — All uses: refs and ref: values have been changed from @v0 to @feat-per-repo-installation (the feature branch). This affects ~20 references across 7 files. If merged and the branch is deleted, all per-org workflows break immediately, and every per-repo shim installed via the CLI will reference a nonexistent branch.
    Remediation: Revert all @feat-per-repo-installation refs back to @v0 (or the intended stable tag) before merging. These were likely set for testing during development.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:205-208 — The WIF provider conflict (409) handler now attempts undeleteWIFProvider before falling through to UpdateWIFProvider. Both success and failure paths of undelete call UpdateWIFProvider, which is correct, but if undelete succeeds, UpdateWIFProvider is called via return, while on failure it falls through to the same call. The logic works but the duplicated call is confusing. Consider an early return pattern or adding a comment explaining the intentional fallthrough.
    Remediation: Add a comment clarifying the intent, e.g., // Undelete failed (provider exists but not soft-deleted) — fall through to update.

  • [Style/conventions] internal/cli/admin.go — The copySharedAppPEMs function silently swallows the error from client.ListOrgInstallations (returns nil, nil). While the fallback behavior is acceptable (shared app detection is best-effort), a debug/info log would help diagnose issues when shared app detection silently fails.
    Remediation: Consider logging the error at debug level before returning nil.

Low

  • [Correctness] internal/dispatch/gcf/provisioner.goBuildRepoProviderID truncates to 32 chars and trims trailing hyphens, which could theoretically produce a very short or empty ID for pathological inputs (e.g., owner="a", repo="-"). In practice, the upstream githubRepoPattern regex prevents this, but BuildRepoProviderID itself has no minimum-length guard.
    Remediation: No immediate action needed — upstream validation covers this. Consider adding a defensive check if the function is used more broadly in the future.

Info

  • [Intent alignment] No linked issue. This is a large feature PR (17 files, ~2300 additions) adding a new installation mode, WIF auto-provisioning, and multi-org shared app PEM support. The scope is broad but the changes are cohesive around the per-repo installation story.

  • [Platform security] The yq command injection fix in validate-enrollment/action.yml (switching from yq ".repos.\"$REPO_NAME\".enabled" to env(REPO_NAME) as $name | .repos[$name].enabled) is a genuine security improvement. The old pattern could be exploited via crafted repo names.

  • [Platform security] The new reusable-dispatch.yml properly validates stage names (^[a-z][a-z0-9_-]*$), trigger sources, and passes all user-controlled inputs via env: bindings rather than expression interpolation in run: blocks. Kill switch and role gating are correctly implemented.

  • [Injection defense] PR body, commit messages, and code comments inspected — no prompt injection patterns, non-rendering Unicode, or bidirectional overrides detected.

Footer

Outcome: request-changes
This review applies to SHA 4e7e0746095ea6788269698a7e71a6c34cec5301. Any push to the PR head clears this review and requires a new evaluation.

Previous run (10)

Review: #799

Head SHA: 31ed3d6
Timestamp: 2026-05-12T00:00:00Z
Outcome: request-changes

Summary

This PR adds a per-repo installation mode (ADR 0033) spanning CLI commands, WIF auto-provisioning, config structures, workflow dispatch, and scaffold templates. The Go code is well-structured with thorough input validation, proper secret zeroing, and comprehensive test coverage (12 PerRepoConfig tests, extensive provisioner tests, CLI flag validation tests). However, every reusable workflow and action reference has been changed from @v0 to @feat-per-repo-installation, which will break all existing per-org workflows on merge and leave per-repo workflows pointing at a deleted branch. This must be reverted before merge. Additionally, the yq command injection fix is a valuable security improvement.

Findings

High

  • [Correctness] .github/workflows/reusable-code.yml, reusable-fix.yml, reusable-retro.yml, reusable-review.yml, reusable-triage.yml, reusable-dispatch.yml, shim-per-repo.yaml — All ref: and @ references to the fullsend repo have been changed from v0 to feat-per-repo-installation (a feature branch). This affects checkout refs, action refs (validate-enrollment@, mint-token@, setup-gcp@, fullsend@), and reusable workflow refs (reusable-triage.yml@, reusable-code.yml@, etc.). On merge: (1) all existing per-org workflows break because they'll try to reference feat-per-repo-installation which is typically deleted after merge, (2) the per-repo shim template is scaffolded into target repos with this branch ref baked in. This appears to be a testing artifact that must be reverted to @v0 (or appropriate release tag) before merge.
    Remediation: Revert all ref: values back to v0 and all @feat-per-repo-installation suffixes back to @v0 across all 7 affected files.

Medium

  • [Correctness] internal/dispatch/gcf/gcp.go:208-211 — The undelete+update logic calls UpdateWIFProvider in both the success and failure branches of undeleteWIFProvider, making the conditional structure misleading. While functionally correct (update works on both existing and just-undeleted providers), the code reads as if the undelete result matters when it doesn't.
    Remediation: Simplify to _ = c.undeleteWIFProvider(...) followed by a single return c.UpdateWIFProvider(...) call.

Info

  • [Platform security] .github/actions/validate-enrollment/action.yml:55 — The yq command injection fix (changing from yq ".repos.\"$REPO_NAME\".enabled" to yq 'env(REPO_NAME) as $name | .repos[$name].enabled') properly prevents shell injection through crafted repository names. Good security improvement.

  • [Correctness] internal/cli/admin.go — Input validation is thorough: owner/repo format, GitHub naming patterns, HTTPS-only mint URL, GCP auth mode allowlist, mutual exclusivity of WIF/SA-key flags, and role name validation. SA key JSON is properly zeroed via defer.

  • [Correctness] internal/dispatch/gcf/provisioner.goProvisionWIF creates a local orgs copy for normalization, preserving the original input slice — verified by TestProvisionWIF_DoesNotMutateInput. BuildRepoProviderID correctly enforces GCP's 4-32 character constraint.

  • [Style/conventions] internal/cli/admin.gocheckInstallScopes was refactored to delegate to checkTokenScopes, and sortedStringMapKeys was extracted as a helper. Both improve code reuse.

  • [Correctness] internal/dispatch/gcf/provisioner.go — Per-repo WIF uses assertion.repository == 'owner/repo' (exact repo match) rather than assertion.repository_owner (org-wide). This correctly narrows the WIF scope for per-repo mode, preventing cross-repo token exchange.

Footer

Outcome: request-changes
This review applies to SHA 31ed3d6e90d7d681e5a40a62cb5fde4df4f67e86. Any push to the PR head clears this review and requires a new evaluation.

Previous run (11)

Review: #799

Head SHA: fde4033
Timestamp: 2026-05-12T00:00:00Z
Outcome: request-changes

Summary

This PR adds per-repo installation mode (ADR 0033) with substantial Go CLI changes, WIF auto-provisioning, per-repo config, scaffold templates, and workflow routing. The Go code is well-structured with thorough input validation, good test coverage (12+ new unit tests for config, 15+ for provisioner, comprehensive CLI flag tests), and proper security practices (env-var-based input passing in workflows, yq injection fix, fork PR detection). However, all workflow refs and action pins have been changed from stable tags to the feature branch @feat-per-repo-installation, which will break all workflows once the branch is merged and deleted. The embedded shim template also contains this ref, meaning customer repositories will receive permanently broken workflows. This is a critical blocker.

Findings

Critical

  • [correctness] .github/workflows/reusable-code.yml:81 (and all other reusable-*.yml files + shim-per-repo.yaml:42) — All workflow uses: refs changed from stable tags (@v0, @v1) to @feat-per-repo-installation. Once this branch is merged and deleted, every dispatched workflow will fail. The shim template is embedded in the Go binary and written to customer repos, making this especially damaging.
    Remediation: Replace all @feat-per-repo-installation refs with a stable tag or SHA pin before merging. For the shim template, use the release tag that will be created from this merge.

High

  • [correctness] internal/dispatch/gcf/gcp.go:209 — The 409-conflict handler in CreateWIFProvider has a confusing double-call pattern: it attempts undeleteWIFProvider, and on success returns UpdateWIFProvider(...), but on failure falls through to another UpdateWIFProvider(...). While functionally correct (the success path returns before reaching the second call), the structure is fragile and misleading. If undelete behavior changes, this could silently double-update.
    Remediation: Simplify to: _ = c.undeleteWIFProvider(ctx, ...); return c.UpdateWIFProvider(ctx, ...) — attempt undelete best-effort, then always update once.

Medium

  • [correctness] internal/dispatch/gcf/provisioner.go:638BuildRepoProviderID documents a 4-32 char GCP requirement but doesn't enforce the minimum length after truncation/trimming.
    Remediation: Add a minimum-length check or return an error if the result is under 4 characters.

  • [style/conventions] .github/workflows/reusable-dispatch.yml — Workspace prep logic (install_mode validation, CUSTOM_BASE selection, layered overlay) is duplicated verbatim across all 5 reusable stage workflows.
    Remediation: Extract into a composite action (e.g. .github/actions/prepare-workspace/action.yml).

  • [intent alignment] internal/cli/admin.go:831copySharedAppPEMs silently swallows errors from ListOrgInstallations and GetExistingRoleAppIDs, returning nil instead of reporting failures. This can mask network or permission errors, causing the install to proceed without shared-app detection.
    Remediation: Log warnings on failure; consider returning errors for non-404 failures.

Low

  • [correctness] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:47 — Uses undocumented ${{ vars.X || 'default' }} fallback pattern in GitHub Actions expressions.
    Remediation: Use the explicit ternary pattern for clarity.

Info

  • [style/conventions] internal/cli/admin.go:378 — Per-repo and per-org flags are mixed in a flat list on the install command. Consider cobra flag groups or a subcommand for better UX in a future iteration.

PR-specific checks

  • PR body injection defense: No prompt injection patterns or non-rendering Unicode detected in the PR description, commit messages, or code comments.
  • Scope authorization: No linked issue. The PR is labeled fullsend-no-fix only. The scope (new per-repo installation mode + WIF provisioning + multi-org support) is substantial but internally consistent with the stated ADR 0033 goal.

Footer

Outcome: request-changes
This review applies to SHA fde4033c3117aa4f8d968c89b71382193605899a. Any push to the PR head clears this review and requires a new evaluation.

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@waynesun09
Copy link
Copy Markdown
Contributor Author

waynesun09 commented May 11, 2026

Per-repo mode e2e test results (nonflux/integration-service)

Tested the full per-repo installation and dispatch chain against nonflux/integration-service using a temporary v1 tag on feat-per-repo-installation.

Setup

  1. Built binary from PR branch, all unit tests pass (cli, gcf, forge, config, scaffold)
  2. Ran fullsend admin install nonflux/integration-service with WIF auto-provision — scaffolded shim, config, auto-set 3 repo variables and 3 repo secrets, provisioned WIF infrastructure (SA, pool, OIDC provider, principal binding)
  3. Disabled existing per-org shim to avoid duplicate triggers

CLI tests

Test Result
admin install owner/repo mode detection pass
--gcp-project required for per-repo pass
--gcp-credentials-file required for sa_key mode pass
--gcp-wif-provider/--gcp-wif-sa-email must be paired pass
WIF auto-provision (omit provider/email) pass
WIF explicit (provide provider+email) pass
Cross-mode flag validation (per-org flags rejected in per-repo) pass
--scaffold-customized generates customized/ dirs pass
Dry-run shows vars/secrets/WIF provision plan pass
Auto-set repo variables via GitHub API pass
Auto-set repo secrets via GitHub API pass
SA key bytes zeroed after use pass (unit test)
Role name validation pass

End-to-end dispatch chain

Stage Shim Routing Enrollment skip Workspace layering Mint token GCP auth Agent setup Agent run
/triage pass pass pass pass pass pass pass fail (#780)
/code pass pass pass pass pass pass pass fail (#780)
/review pass pass pass pass pass pass pass fail (#780)
/fix pass pass pass pass pass pass pass fail (#780)
/stop-fix pass N/A N/A N/A N/A N/A N/A pass (label applied)

All agent run failures are the openshell gateway incompatibility (#780), not related to per-repo mode.

Issues found and fixed

  1. WIF provider audience mismatchgoogle-github-actions/auth@v3 uses the default IAM audience, but the WIF provider only had fullsend-mint. Fixed: fresh installs create providers with both audiences; re-installs PATCH stale audiences automatically.

  2. IAM SA getIamPolicy requires POSTSetServiceAccountIAMBinding used GET (works for Secret Manager but not IAM SA API), causing 404. Fixed: added trySetIAMBindingWithMethod to support both HTTP methods.

  3. SA email URL encodingurl.PathEscape encoded @ as %40 in the IAM URL, causing 404. Fixed: pass SA email unescaped.

Workflow runs

Run ID Trigger Result Notes
25643381741 /triage GCP auth fail WIF audience mismatch (pre-fix)
25643420726 /triage pass → #780 All stages pass through agent setup
25643440506 /code pass → #780
25643440586 /review pass → #780
25643459320 /fix pass → #780 Fork check pass
25643480376 /stop-fix success Label applied
25644330678 /triage (retag) pass → #780 After CLI refactor
25646199188 /triage (retag) pass → #780 After admin init→install merge
25647237108 /triage pass → #780 After auto repo secrets/vars
25647752683 /triage pass → #780 After WIF auto-provision fix
25647933416 /triage pass → #780 After ProvisionWIF hardening

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

@waynesun09 waynesun09 force-pushed the feat-per-repo-installation branch from fb2aa05 to 4cae81e Compare May 13, 2026 19:00
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

@waynesun09 waynesun09 force-pushed the feat-per-repo-installation branch from 4cae81e to 11e9436 Compare May 13, 2026 19:14
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Copy link
Copy Markdown
Contributor

@ralphbean ralphbean left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

This PR successfully implements ADR 0033 (per-repo installation mode) with comprehensive test coverage and proper security considerations. The implementation is well-structured with clean separation between per-org and per-repo modes.

Strategic Assessment: Good idea, excellent scope fit, directly implements #727 and accepted ADR 0033.

One actionable request: The scaffold copy of the fullsend action has diverged from the live copy (missing OPENSHELL_VERSION fix).

Seven deferred notes for follow-up (not blocking):

  1. CopyAgentPEM does not zero PEM key data after reading from Secret Manager
  2. copySharedAppPEMs silently swallows API errors from GetExistingRoleAppIDs and ListOrgInstallations
  3. undeleteWIFProvider error silently swallowed with redundant duplicate UpdateWIFProvider call — simplify to _ = c.undeleteWIFProvider(...); return c.UpdateWIFProvider(...)
  4. Concurrency key in shim template may be empty for pull_request_review events, serializing unrelated PR reviews
  5. Workspace prep logic (~20 lines) is copy-pasted across 5 reusable workflows — consider extracting into a composite action
  6. ProvisionWIF injects Repo field into CEL expression without validation (CLI validates upstream, but the method is public)
  7. Note: PR #742 (/fs- prefix rename) is still open — when it merges, this dispatch workflow will need updating

Already flagged by prior review (ralphbean): Branch refs @feat-per-repo-installation must be changed to stable tag before merge. Not re-raised here.

Comment thread internal/scaffold/fullsend-repo/.github/actions/fullsend/action.yml Outdated
pemData, err := p.gcpAPI.AccessSecretVersion(ctx, p.cfg.ProjectID, srcID)
if err != nil {
return fmt.Errorf("reading source secret %s: %w", srcID, err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[critical] (deferred) pemData read from AccessSecretVersion is never zeroed after use. The existing Provision method has zeroPEMs() for exactly this purpose. Consider:

defer func() {
    for i := range pemData {
        pemData[i] = 0
    }
}()

Comment thread internal/cli/admin.go
}

roleSet := make(map[string]bool, len(roles))
for _, r := range roles {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] (deferred) Both GetExistingRoleAppIDs and ListOrgInstallations errors are silently swallowed here (returning nil, nil). If these fail due to permissions or transient issues, the install proceeds without copying shared PEMs, which could cause a confusing failure later when the PEM secret is not found during app setup. Consider at minimum logging a warning via printer.StepWarn().


if resp.StatusCode == http.StatusConflict {
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
if err := c.undeleteWIFProvider(ctx, projectNumber, poolID, providerID); err == nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] (deferred) Both branches call UpdateWIFProvider, and the undelete error is discarded. Consider simplifying to:

_ = c.undeleteWIFProvider(ctx, projectNumber, poolID, providerID)
return c.UpdateWIFProvider(ctx, projectNumber, poolID, providerID, cfg)

group: fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
if: >-
github.event_name != 'issue_comment'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] (deferred) For pull_request_review events, github.event.issue is not populated and github.event.pull_request.number needs to be the fallback. If both are empty, all review-triggered runs share the same concurrency group. Consider adding github.event.number as an additional fallback:

group: fullsend-dispatch-${{ github.event.issue.number || github.event.pull_request.number || github.event.number }}

- name: Prepare workspace (upstream defaults + org overrides)
- name: Prepare workspace (upstream defaults + org/repo overrides)
env:
INSTALL_MODE: ${{ inputs.install_mode }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] (deferred) The install_mode validation + CUSTOM_BASE path switching block (~20 lines) is duplicated identically across all 5 reusable workflow files. Consider extracting into a composite action (e.g., .github/actions/prepare-workspace/) to reduce maintenance burden and divergence risk.

}
seen[lower] = true
orgs[i] = lower
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] (deferred) The Repo field is injected directly into a CEL expression (assertion.repository == '%s'). While the CLI validates format upstream, ProvisionWIF is a public method — consider adding a format check here for defense in depth.

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

@waynesun09
Copy link
Copy Markdown
Contributor Author

Re: scaffold action.yml missing OPENSHELL_VERSION=v0.0.36 — this file no longer exists. It was consolidated into the root action.yml in commit 4f0e6a1 ("refactor: consolidate fullsend action to root action.yml"), which is already in this branch. The root action.yml (line 140) has the fix:

if curl -LsSf .../install.sh | OPENSHELL_VERSION=v0.0.36 sh; then

The remaining 6 comments are all marked (deferred) — no action needed on those this round.

@waynesun09 waynesun09 force-pushed the feat-per-repo-installation branch from 11e9436 to b213c6b Compare May 13, 2026 20:44
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Copy link
Copy Markdown

@fullsend-ai-review fullsend-ai-review Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

Base automatically changed from adr-0030-reusable-workflows to main May 13, 2026 21:40
Add `fullsend admin install --repo owner/repo` for per-repo agent
enrollment. Generates a shim workflow that routes events through
reusable-dispatch.yml, configures repo variables/secrets, and
validates enrollment via composite actions.

Includes mode detection (per-org vs per-repo), cross-mode flag
validation, agent role parsing, SA key zeroing, deterministic
scaffold output, and per-repo config model with YAML round-trip.

Signed-off-by: Wayne Sun <gsun@redhat.com>
When --gcp-auth-mode=wif is used without explicit --gcp-wif-provider,
auto-provision a WIF pool, provider, and service account in the
target GCP project. Supports multi-org binding with condition
merging via GetWIFProvider + UpdateWIFProvider (PATCH-based).

Build repoSecrets map after provisioning so WIF provider and SA
email values are populated. Use printf over echo for untrusted
COMMENT_BODY in reusable-fix.yml.

Signed-off-by: Wayne Sun <gsun@redhat.com>
When a second org installs the same public GitHub Apps, the system now
automatically detects the shared apps by matching app IDs against the
existing mint function's ROLE_APP_IDS, and copies PEM secrets from the
source org's naming convention to the target org's. This eliminates the
need for --skip-app-setup or manual PEM provisioning when enrolling
additional orgs with shared apps.

Also adds per-repo WIF provider isolation: each per-repo install creates
its own WIF provider (gh-{owner}-{repo}) with a repo-scoped condition
instead of reusing the shared github-oidc provider.

Signed-off-by: Wayne Sun <gsun@redhat.com>
The releases API call to resolve the latest tag was unauthenticated,
hitting the 60 req/hour rate limit on shared runner IPs. Add a
github_token input (defaults to github.token) and pass it as a Bearer
token on the API call.

Signed-off-by: Wayne Sun <gsun@redhat.com>
Adapt per-repo installation to the direct-WIF model: remove SA key
auth path (WIF is the only GCP auth mode), remove stale SA email
secret and dead gcp-service-account flag, use project-level IAM
bindings with roles/aiplatform.user, and scope per-org WIF bindings
to org/.fullsend instead of org-wide.

Ref: #884

Signed-off-by: Wayne Sun <gsun@redhat.com>
NEWMSG "$@"
Signed-off-by: Wayne Sun <gsun@redhat.com>
@waynesun09 waynesun09 force-pushed the feat-per-repo-installation branch from b213c6b to 5e36b45 Compare May 13, 2026 22:56
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Add provisioner_test.go to detect-private-key exclusion and quote
CUSTOM_BASE expansion inside ${f#...} across all reusable workflows.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

The per-org WorkflowsLayer.Install() never created customized/
subdirectories, so orgs installed before env/ was added to layeredDirs
are missing customized/env/. Add CustomizedDirs() helper and use it
in both the install and analyze paths so reinstalls are self-healing.

Signed-off-by: Wayne Sun <gsun@redhat.com>
@github-actions
Copy link
Copy Markdown

fullsend review is working on this — view logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants