From 436a7f86d50e37165312fd4e04bd6e147a2bdf63 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 10 Jun 2026 15:01:50 +0300 Subject: [PATCH 01/30] feat(install): add --vendor for self-contained workflow and agent assets Introduce --vendor to install vendored binaries, reusable workflows, actions, and agent content. Vendored upstream mirror content is committed under .defaults/ (same layout as runtime sparse checkout); layered installs fetch fullsend-ai/fullsend@v0 into .defaults when the marker file is absent. Reusable workflows use inline workspace preparation and reference infra from ./.defaults/, matching the pre-vendor layered design. Thin callers render local reusable paths when --vendor is set. --fullsend-source pins the source tree for both content and binary cross-compile; --fullsend-binary remains an explicit ELF override. Signed-off-by: Barak Korren Co-authored-by: Cursor Co-authored-by: Cursor Co-authored-by: Cursor Co-authored-by: Cursor Co-authored-by: Cursor --- .github/workflows/reusable-code.yml | 2 + .github/workflows/reusable-fix.yml | 2 + .github/workflows/reusable-prioritize.yml | 2 + .github/workflows/reusable-retro.yml | 2 + .github/workflows/reusable-review.yml | 1 + .github/workflows/reusable-triage.yml | 2 + .pre-commit-config.yaml | 2 + action.yml | 2 +- docs/ADRs/0035-layered-content-resolution.md | 4 +- ...0046-vendored-installs-with-vendor-flag.md | 83 +++++++ docs/architecture.md | 10 +- docs/guides/dev/cli-internals.md | 8 +- docs/guides/dev/testing-workflows.md | 71 +++--- docs/guides/getting-started/github-setup.md | 9 +- docs/guides/getting-started/installation.md | 32 ++- e2e/admin/admin_test.go | 21 +- internal/binary/acquire.go | 55 +++-- internal/binary/crosscompile.go | 13 +- internal/binary/download.go | 136 +++++++++++ internal/binary/download_test.go | 6 +- internal/binary/vendorroot.go | 79 ++++++ internal/cli/admin.go | 79 +++--- internal/cli/admin_test.go | 10 +- internal/cli/github.go | 80 +++--- internal/cli/github_test.go | 4 +- internal/cli/vendor.go | 150 ++++++++++-- internal/cli/vendor_test.go | 27 ++- internal/config/config.go | 7 + internal/layers/vendor.go | 26 +- internal/layers/vendor_test.go | 2 +- internal/layers/vendorbinary.go | 138 +++++++---- internal/layers/vendorbinary_test.go | 16 +- internal/layers/workflows.go | 82 +++---- internal/layers/workflows_test.go | 117 ++++----- .../fullsend-repo/.github/workflows/code.yml | 3 +- .../fullsend-repo/.github/workflows/fix.yml | 3 +- .../.github/workflows/prioritize.yml | 3 +- .../fullsend-repo/.github/workflows/retro.yml | 3 +- .../.github/workflows/review.yml | 3 +- .../.github/workflows/triage.yml | 3 +- .../templates/shim-per-repo.yaml | 2 +- internal/scaffold/installfiles.go | 109 +++++++++ internal/scaffold/render.go | 86 +++++++ internal/scaffold/render_test.go | 120 +++++++++ internal/scaffold/scaffold.go | 40 +++ internal/scaffold/scaffold_test.go | 20 +- internal/scaffold/vendorcontent.go | 228 ++++++++++++++++++ internal/scaffold/vendorcontent_test.go | 33 +++ .../scaffold/workflow_call_alignment_test.go | 23 +- 49 files changed, 1572 insertions(+), 387 deletions(-) create mode 100644 docs/ADRs/0046-vendored-installs-with-vendor-flag.md create mode 100644 internal/binary/vendorroot.go create mode 100644 internal/scaffold/installfiles.go create mode 100644 internal/scaffold/render.go create mode 100644 internal/scaffold/render_test.go create mode 100644 internal/scaffold/vendorcontent.go create mode 100644 internal/scaffold/vendorcontent_test.go diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index fe494854b1..4c38f65817 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -56,6 +56,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend @@ -102,6 +103,7 @@ jobs: mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + - name: Validate enrollment and extract repo metadata id: repo-parts uses: ./.defaults/.github/actions/validate-enrollment diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index 5968c784ef..2da6630929 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -68,6 +68,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend @@ -114,6 +115,7 @@ jobs: mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + - name: Validate enrollment and extract repo metadata id: repo-parts uses: ./.defaults/.github/actions/validate-enrollment diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index 31bb2df582..19fe39c37e 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -58,6 +58,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend @@ -104,6 +105,7 @@ jobs: mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + - name: Validate enrollment and extract repo metadata id: repo-parts uses: ./.defaults/.github/actions/validate-enrollment diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 8ddeb3589e..9e76086005 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -54,6 +54,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend @@ -100,6 +101,7 @@ jobs: mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + - name: Validate enrollment and extract repo metadata id: repo-parts uses: ./.defaults/.github/actions/validate-enrollment diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index 863681129f..c1f86195ef 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -55,6 +55,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index ac9dd6aa05..aa51989b37 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -54,6 +54,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults + if: hashFiles('.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend @@ -100,6 +101,7 @@ jobs: mkdir -p .github/scripts cp "${SRC}/.github/scripts/setup-agent-env.sh" .github/scripts/setup-agent-env.sh + - name: Validate enrollment and extract repo metadata id: repo-parts uses: ./.defaults/.github/actions/validate-enrollment diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e98d59129..51952ee48c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,6 +74,8 @@ repos: - property "workflow_repository" is not defined - -ignore - SC2016 + - -ignore + - '__REUSABLE_(WORKFLOW|DISPATCH)__' - repo: local hooks: diff --git a/action.yml b/action.yml index 6653f7e004..c7ed9079af 100644 --- a/action.yml +++ b/action.yml @@ -74,7 +74,7 @@ runs: done } - # Use vendored binary if present (placed by fullsend admin install --vendor-fullsend-binary). + # Use vendored binary if present (placed by fullsend admin install --vendor). # Per-org mode stores it at bin/fullsend (in .fullsend config repo); # per-repo mode stores it at .fullsend/bin/fullsend (in the target repo). # GitHub Contents API does not preserve the executable bit, so check -f not -x. diff --git a/docs/ADRs/0035-layered-content-resolution.md b/docs/ADRs/0035-layered-content-resolution.md index dbec2466a1..6f1e03a1da 100644 --- a/docs/ADRs/0035-layered-content-resolution.md +++ b/docs/ADRs/0035-layered-content-resolution.md @@ -63,7 +63,9 @@ they are populated at runtime from upstream. replaced the earlier checkout at `@v0` with a checkout at a caller-controlled ref), copies them into the main dirs (`agents/`, `skills/`, etc.), then copies customizations on top so override files replace upstream -defaults. The workflow inspects `install_mode` to resolve the correct +defaults. When `--vendor` has committed upstream mirror content under +`.defaults/`, the sparse checkout is skipped (see +[ADR 0046](0046-vendored-installs-with-vendor-flag.md)). The workflow inspects `install_mode` to resolve the correct customization base: - `per-org`: reads from `customized/` diff --git a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md new file mode 100644 index 0000000000..93d3cd0949 --- /dev/null +++ b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md @@ -0,0 +1,83 @@ +--- +title: "46. Vendored installs with --vendor" +status: Accepted +relates_to: + - testing-agents +topics: + - vendor + - layered-content + - workflows +--- + +# ADR 0046: Vendored installs with `--vendor` + +## Status + +Accepted + +## Context + +Layered installs (the default) fetch reusable workflows and agent content from +`fullsend-ai/fullsend@v0` at runtime via sparse checkout. That keeps config repos +small and picks up upstream fixes automatically. + +Some workflows need to run unreleased fullsend changes (forks, local workflow +edits, pre-release CI) without publishing tags. A single install flag should +vendor binary + workflow/agent assets at install time; runtime should detect +vendored files without `config.yaml` distribution settings. + +## Decision + +### Install-time: `--vendor` + +`fullsend admin install`, `fullsend github setup`, and +`fullsend github sync-scaffold` accept: + +| Flag | Purpose | +|------|---------| +| `--vendor` | Vendor linux/amd64 binary, reusable workflows, composite actions, and agent content | +| `--fullsend-source ` | Explicit fullsend checkout for content walks and binary cross-compile | +| `--fullsend-binary ` | Explicit Linux ELF; skips cross-compile (requires `--vendor`) | + +Source resolution (shared by binary and content) in `internal/binary`: + +1. `--fullsend-source` (validated checkout: `go.mod`, `cmd/fullsend/`) +2. `ModuleRoot()` when CWD is inside a checkout +3. GitHub source fetch at CLI version (released CLI only) + +Without `--vendor`, install removes stale vendored binary and content paths and +renders thin callers with upstream `uses: fullsend-ai/fullsend/.../reusable-*.yml@v0`. + +### Runtime: file-presence detection + +Reusable workflows detect vendored installs before sparse checkout: + +- **All modes:** `.defaults/action.yml` in the checked-out repo (committed by `--vendor`, or populated by sparse checkout at runtime) + +When present, upstream sparse checkout is skipped. Infra is referenced from +`.defaults/` (`uses: ./.defaults/.github/actions/...`, `uses: ./.defaults/`). +Layered agent content is copied from `.defaults/internal/scaffold/fullsend-repo/` +onto the workspace root at job start (inline prepare step). + +Thin caller `uses:` paths are rendered at install/sync time (local `./...` when +`--vendor`, upstream `@v0` when layered). + +### What was removed + +- `distribution.mode` / `distribution.upstream.ref` in org and per-repo config +- `--distribution-mode`, `--upstream-ref` CLI flags +- `distribution_mode` workflow input +- `upstreamembed.go` (content read from resolved source tree instead) + +## Consequences + +- **Positive:** One flag, no config block, runtime auto-detect; dev/CI can test unreleased workflow changes. +- **Negative:** Deleting vendored files without re-install leaves broken local `uses:` paths until sync-scaffold or re-install. +- **Neutral:** Default layered behavior unchanged for installs without `--vendor`. + +## References + +- [Installation guide](../guides/getting-started/installation.md) +- [Testing workflows](../guides/dev/testing-workflows.md) +- ADR 0031 (reusable workflows for distribution) +- ADR 0033 (per-repo installation mode) diff --git a/docs/architecture.md b/docs/architecture.md index 872bc2c79f..27d8eb601a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Infrastructure platform choice and configuration are specified in the adopting o - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). - Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos. - Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants. -- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). +- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). **`--vendor`** ([ADR 0046](ADRs/0046-vendored-installs-with-vendor-flag.md)) commits workflows and agent content at install time; layered installs (default) fetch upstream at runtime. - Event-driven stage dispatch: eliminate `workflow_dispatch` + `gh workflow run` fan-out from `dispatch.yml` in favor of synchronous `workflow_call` so the dispatched run stays linked to the caller ([ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). **Open questions:** @@ -344,9 +344,11 @@ See [ADR 0003](ADRs/0003-org-config-repo-convention.md) for the config repo conv **Decided:** - Layered content resolution: upstream defaults (agents, skills, schemas, - harness, policies, scripts) are provided at runtime via a full checkout of - `fullsend-ai/fullsend` at the ref passed via `fullsend_ai_ref`. The scaffold - installs only org-specific files and a `customized/` directory for org + harness, policies, scripts) are provided at runtime via sparse checkout of + `fullsend-ai/fullsend@v0`, or from vendored files when `--vendor` was used at + install (detected via `.defaults/action.yml` — see + [ADR 0046](ADRs/0046-vendored-installs-with-vendor-flag.md)). The + scaffold installs only org-specific files and a `customized/` directory for org overrides. Org files in `customized/` overwrite upstream defaults at runtime ([ADR 0035](ADRs/0035-layered-content-resolution.md)). diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index c964086fc8..2a26a47e1f 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -235,7 +235,7 @@ Install: process 1→7 (forward) Uninstall: process 7→1 (reverse) ``` -Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` and `runGitHubSetupPerRepo()` since there's no need for composable uninstall ordering with a single repo. Binary vendoring (when `--vendor-fullsend-binary` is set) and stale binary cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`. +Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` and `runGitHubSetupPerRepo()` since there's no need for composable uninstall ordering with a single repo. Vendoring (when `--vendor` is set) and stale asset cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`. ### Binary acquisition (`internal/binary`) @@ -427,8 +427,10 @@ fullsend-repo/ (embedded template) | Category | Installed? | Source | Purpose | |----------|-----------|--------|---------| | **Installed** | Yes | Scaffold → `.fullsend` repo | Workflows, configs, static files | -| **Layered** | No (runtime) | Upstream reusable workflows | agents/, skills/, harness/, plugins/, policies/, scripts/, schemas/, env/ | -| **Upstream-only** | No | Referenced directly | .github/actions/, .github/scripts/ | +| **Layered** | No (runtime) or yes with `--vendor` | Upstream `@v0` sparse checkout, or vendored at install | agents/, skills/, harness/, plugins/, policies/, scripts/, schemas/, env/ | +| **Upstream-only** | No (layered) or yes with `--vendor` | Referenced directly or vendored at install | .github/actions/, .github/scripts/ | + +Runtime skips upstream fetch when `.defaults/action.yml` is present (vendored); layered installs sparse-checkout `fullsend-ai/fullsend@v0` into `.defaults/`. ### File Mode Tracking diff --git a/docs/guides/dev/testing-workflows.md b/docs/guides/dev/testing-workflows.md index 846c94fa2c..f386033e7f 100644 --- a/docs/guides/dev/testing-workflows.md +++ b/docs/guides/dev/testing-workflows.md @@ -2,50 +2,65 @@ This guide explains how to test changes to Fullsend's GitHub Actions workflows. -## Per-repo mode +## Vendored installs (recommended for PR testing) -In your repository modify the dispatch job at `.github/workflows/fullsend.yaml` to -use the ref you want to test. Change the reference `uses` use and -`fullsend_ai_ref` to the same value. +Install or re-install with `--vendor` to copy reusable workflows, actions, agent +definitions, and the CLI binary from your local checkout into the config repo or +`.fullsend/` directory: + +```bash +fullsend admin install "$ORG" \ + --vendor \ + --fullsend-source "$PWD" \ + --skip-app-setup \ + --skip-mint-check \ + --mint-url "$MINT_URL" \ + # ... other flags +``` + +E2e uses `--vendor` so CI exercises the commit under test, not upstream `@v0`. +After changing reusable workflows or agent content, re-run install (or +`fullsend github setup`) with `--vendor` to refresh vendored files. +`fullsend github sync-scaffold` updates thin caller templates and auto-detects +vendored vs layered mode from `action.yml` presence. + +Runtime detects vendored installs by `action.yml` presence (config repo root for +Runtime skips the upstream sparse checkout when `.defaults/action.yml` is present (vendored install) and stages content from `.defaults/` instead. +of sparse-checkouting upstream. + +## Layered installs: pin upstream ref + +In layered mode (default), thin callers reference upstream reusable workflows at +`fullsend-ai/fullsend@v0`. To test a specific upstream ref without vendoring, +change the `uses:` ref in the thin caller workflows. + +### Per-repo mode + +In your repository modify the dispatch job at `.github/workflows/fullsend.yaml`: ```yaml # .github/workflows/fullsend.yaml -# [...] jobs: dispatch: - # [...] uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@ - with: - # [...] - fullsend_ai_ref: - # [...] ``` -Then push this change and trigger a Fullsend action: `/fs-triage`, `/fs-code`, ... When the ref is -deleted from fullsend-ai/fullsend (branch deleted or commit amended), revert this back to the -desired reference. +### Per-org mode -## Per-org mode +**WARNING**: this impacts all repositories, so proceed with care. You can install +your test repository using per-repo mode to avoid this problem. -**WARNING**: this impacts all repositories, so proceed with care. You can install your test repository -using the repository install mode to avoid this problem. - -In your `.fullsend` repository modify the desired stage workflow file (triage in the example below). -Change the reference on `uses` for the `reusable-.yml` and the `fullsend_ai_ref` passed to it: +In your `.fullsend` repository modify the desired stage workflow file: ```yaml # .github/workflows/triage.yml -# [...] jobs: triage: - # [...] uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@ - with: - # [...] - fullsend_ai_ref: - # [...] ``` -Then push this change and trigger a Fullsend action on your test repository: `/fs-triage`, `/fs-code`, ... -When the ref is deleted from fullsend-ai/fullsend (branch deleted or commit amended), revert this back -to the desired reference. +Then push and trigger a Fullsend action. When the ref is deleted from +fullsend-ai/fullsend, revert to your desired reference. + +See [ADR 0046](../../ADRs/0046-vendored-installs-with-vendor-flag.md) for the +full distribution model. diff --git a/docs/guides/getting-started/github-setup.md b/docs/guides/getting-started/github-setup.md index a973d0a81c..69ba54a192 100644 --- a/docs/guides/getting-started/github-setup.md +++ b/docs/guides/getting-started/github-setup.md @@ -118,15 +118,16 @@ fullsend github setup acme-corp \ | `--app-set` | No | `fullsend-ai` | App set name prefix for GitHub Apps | | `--enroll-all` | No | `false` | Enroll all repositories without prompting (per-org only) | | `--enroll-none` | No | `false` | Skip enrollment without prompting (per-org only) | -| `--vendor-fullsend-binary` | No | `false` | Resolve and upload a linux/amd64 fullsend binary for CI (see [Vendoring the CLI binary](#vendoring-the-cli-binary)) | +| `--vendor` | No | `false` | Vendor binary, reusable workflows, actions, and agent content (see [Vendored vs layered installs](#vendored-vs-layered-installs)) | +| `--fullsend-source` | No | | Fullsend source checkout for content and cross-compile (requires `--vendor`) | | `--fullsend-binary` | No | | Path to a Linux fullsend binary when vendoring (skips auto-resolution) | | `--dry-run` | No | `false` | Preview changes without making them | -### Vendoring the CLI binary +### Vendored vs layered installs -Same policy as [admin install](installation.md#vendoring-the-cli-binary): `--fullsend-binary` → checkout cross-compile → matching release (released CLI only) → fail. Per-repo setup now wires vendoring and stale-binary cleanup when the flag is off. +Same behavior as [admin install](installation.md#vendored-vs-layered-installs): layered (default) fetches upstream at runtime; `--vendor` installs binary plus workflow/action/agent content and runtime detects vendored installs via `action.yml` presence. -`fullsend admin analyze ` reports when a stale vendored binary is present (no install-intent flags on analyze). +`fullsend admin analyze ` reports when stale vendored assets are present (analyze has no install flags). ## Per-repo setup diff --git a/docs/guides/getting-started/installation.md b/docs/guides/getting-started/installation.md index 35e0aa6015..7fed8c5e58 100644 --- a/docs/guides/getting-started/installation.md +++ b/docs/guides/getting-started/installation.md @@ -256,8 +256,9 @@ The installer automatically provisions [Workload Identity Federation (WIF)](http | `--skip-mint-check` | `false` | Skip mint validation, GCP provisioning, and app setup; requires `--mint-url` | | `--enroll-all` | `false` | Enroll all repositories without prompting (per-org only) | | `--enroll-none` | `false` | Skip repository enrollment without prompting (per-org only) | -| `--vendor-fullsend-binary` | `false` | Resolve and upload a linux/amd64 fullsend binary for CI (see [Vendoring the CLI binary](#vendoring-the-cli-binary)) | -| `--fullsend-binary` | | Path to a Linux fullsend binary to upload when `--vendor-fullsend-binary` is set (skips auto-resolution) | +| `--vendor` | `false` | Vendor binary, reusable workflows, actions, and agent content (see [Vendored vs layered installs](#vendored-vs-layered-installs)) | +| `--fullsend-source` | | Fullsend source checkout for content walks and binary cross-compile (requires `--vendor`) | +| `--fullsend-binary` | | Path to a Linux fullsend binary to upload when `--vendor` is set (skips auto-resolution) | The `--skip-mint-check` flag bypasses all mint validation, GCP provisioning, and app setup. It requires `--mint-url` to be set and only validates that the URL uses HTTPS. This is useful when the mint infrastructure is managed externally or you want to skip GCP API calls entirely. @@ -267,23 +268,32 @@ The installer automatically detects when the deployed mint function is up-to-dat A single token mint can serve multiple GitHub organizations. See [Mint service administration — Multi-org setup](../infrastructure/mint-administration.md#multi-org-setup) for the complete multi-org workflow. -### Vendoring the CLI binary +### Vendored vs layered installs -Use `--vendor-fullsend-binary` to upload a linux/amd64 `fullsend` binary into the config repo (`bin/fullsend`) or per-repo path (`.fullsend/bin/fullsend`). CI workflows prefer this file over downloading from GitHub releases. +**Layered (default):** Thin caller workflows reference upstream reusable workflows at `fullsend-ai/fullsend@v0`. At runtime, reusables sparse-checkout upstream into `.defaults/` and copy agent content to the workspace root. No distribution settings in `config.yaml`. -When the flag is set, the binary is resolved in this order: +**Vendored (`--vendor`):** Install commits a linux/amd64 binary plus reusable workflows and an upstream mirror under `.defaults/` (same layout as the runtime checkout). Thin callers use local `./...` paths. Runtime skips the upstream fetch when `.defaults/action.yml` is already present. + +Source resolution (shared by binary and content): + +1. **`--fullsend-source `** — validated checkout (`go.mod`, `cmd/fullsend/`) +2. **Module root** — when CWD is inside a fullsend checkout +3. **GitHub source fetch** — at CLI version (released CLI only) +4. **Fail** — dev CLI outside a checkout fails with a clear error + +Binary resolution: 1. **`--fullsend-binary `** — upload that file (validated as linux/amd64 ELF) -2. **Checkout build** — cross-compile from the fullsend module root (`go env GOMOD`), stamped `{version}-vendored` -3. **Release fetch** — only if step 2 is unavailable **and** the running CLI is a released version (e.g. `0.4.0`); downloads the matching GitHub release (no `-vendored` suffix) -4. **Fail** — dev CLI outside a checkout fails with a clear error (no “latest release” fallback) +2. Cross-compile from resolved source (stamped `{version}-vendored`) +3. **Release fetch** — only if cross-compile is unavailable **and** the running CLI is a released version +4. **Fail** — no “latest release” fallback for dev builds -When the flag is **off**, any existing vendored binary is removed so CI uses released versions. +When `--vendor` is **off**, stale vendored binary and content paths are removed so CI uses released upstream versions. **Notes:** -- Vendoring the CLI alone does not air-gap the full pipeline (OpenShell, gateway, sandbox image, upstream scaffold still download at runtime). -- Release fallback requires network access at install time; CI consumes the uploaded file. +- Vendoring does not air-gap the full pipeline (OpenShell, gateway, sandbox image still download at runtime). +- Release fallback requires network access at install time; CI consumes the uploaded files. - Works from any directory inside the module checkout (module root discovery via `GOMOD`). ### Merge enrollment PRs diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index 948832d44d..90645c31b5 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -141,7 +141,7 @@ func TestAdminInstallUninstall(t *testing.T) { "--mint-url", env.cfg.mintURL, "--app-set", e2eAppSet, "--enroll-all", - "--vendor-fullsend-binary", + "--vendor", } if env.cfg.gcpProjectID != "" { installArgs = append(installArgs, "--inference-project", env.cfg.gcpProjectID) @@ -159,14 +159,15 @@ func TestAdminInstallUninstall(t *testing.T) { parsedCfg, err := config.ParseOrgConfig(cfgData) require.NoError(t, err, "config.yaml should parse") require.Len(t, parsedCfg.Defaults.Roles, len(defaultRoles), "should have %d roles", len(defaultRoles)) + _, err = env.client.GetFileContent(ctx, env.org, forge.ConfigRepoName, ".defaults/action.yml") + require.NoError(t, err, "vendored marker .defaults/action.yml should exist") + _, err = env.client.GetFileContent(ctx, env.org, forge.ConfigRepoName, layers.VendoredBinaryPath) + require.NoError(t, err, "vendored binary should exist at %s", layers.VendoredBinaryPath) analyzeOutput := runCLI(t, env.binary, env.token, "admin", "analyze", env.org) t.Logf("Analyze output:\n%s", analyzeOutput) - // Agent runtime files exist (from scaffold). - // ADR 35: only non-layered, non-upstream-only files are installed. - // Layered dirs (agents/, skills/, schemas/, harness/, plugins/, policies/, - // scripts/, env/) and upstream-only dirs (.github/actions/, .github/scripts/) are - // provided at runtime via sparse checkout in reusable workflows. + // Standalone install vendors reusable workflows, actions, and agent content + // at install time so e2e exercises the commit-built CLI, not upstream @v0. for _, path := range []string{ ".github/workflows/triage.yml", ".github/workflows/code.yml", @@ -176,6 +177,10 @@ func TestAdminInstallUninstall(t *testing.T) { ".github/workflows/repo-maintenance.yml", ".github/workflows/prioritize.yml", ".github/workflows/prioritize-scheduler.yml", + ".github/workflows/reusable-triage.yml", + ".defaults/internal/scaffold/fullsend-repo/agents/triage.md", + ".defaults/.github/actions/mint-token/action.yml", + ".defaults/action.yml", "customized/agents/.gitkeep", "customized/skills/.gitkeep", "customized/schemas/.gitkeep", @@ -653,7 +658,7 @@ func runUnenrollmentTest(t *testing.T, env *e2eEnv) { t.Log("Verified shim is gone") } -// TestVendorFromSubdirectory verifies that --vendor-fullsend-binary cross-compiles +// TestVendorFromSubdirectory verifies that --vendor cross-compiles // when the CLI is run from a subdirectory inside the module (GOMOD discovery). func TestVendorFromSubdirectory(t *testing.T) { env := setupE2ETest(t) @@ -667,7 +672,7 @@ func TestVendorFromSubdirectory(t *testing.T) { "--mint-url", env.cfg.mintURL, "--app-set", e2eAppSet, "--enroll-none", - "--vendor-fullsend-binary", + "--vendor", } runCLIFromDir(t, env.binary, env.token, subdir, installArgs...) diff --git a/internal/binary/acquire.go b/internal/binary/acquire.go index 0f7e70d9ad..dd1dd4d92f 100644 --- a/internal/binary/acquire.go +++ b/internal/binary/acquire.go @@ -74,42 +74,55 @@ func ResolveForRun(version, arch string) (AcquireResult, error) { return AcquireResult{}, fmt.Errorf("all strategies failed for linux/%s: provide --fullsend-binary or install Go toolchain", arch) } +// VendorOpts configures binary resolution for vendoring. +type VendorOpts struct { + SourceDir string + Version string + Arch string +} + // ResolveForVendor obtains a Linux binary using the vendoring policy: -// cross-compile from checkout → matching release (released CLI only) → fail. -// No latest-release fallback. -func ResolveForVendor(version, arch string) (AcquireResult, error) { +// cross-compile from resolved source root → matching release (released CLI only) → fail. +func ResolveForVendor(opts VendorOpts) (AcquireResult, error) { tmpDir, err := os.MkdirTemp("", "fullsend-linux-*") if err != nil { return AcquireResult{}, fmt.Errorf("creating temp dir: %w", err) } binaryPath := filepath.Join(tmpDir, "fullsend") - // 1. Cross-compile from checkout. - fmt.Fprintf(os.Stderr, "Cross-compiling fullsend for linux/%s...\n", arch) - if ccErr := CrossCompile(CrossCompileOpts{ - Version: version, - Arch: arch, - DestPath: binaryPath, - VersionStamp: "-vendored", - }); ccErr == nil { - fmt.Fprintf(os.Stderr, "Cross-compiled fullsend for linux/%s\n", arch) - return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceCheckoutBuild}, nil + root, rootErr := ResolveVendorRoot(opts.SourceDir, opts.Version) + if rootErr == nil { + if root.Cleanup != nil { + defer root.Cleanup() + } + fmt.Fprintf(os.Stderr, "Cross-compiling fullsend for linux/%s...\n", opts.Arch) + if ccErr := CrossCompile(CrossCompileOpts{ + Version: opts.Version, + Arch: opts.Arch, + DestPath: binaryPath, + VersionStamp: "-vendored", + SourceDir: root.Path, + }); ccErr == nil { + fmt.Fprintf(os.Stderr, "Cross-compiled fullsend for linux/%s\n", opts.Arch) + return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceCheckoutBuild}, nil + } else { + fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr) + } } else { - fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr) + fmt.Fprintf(os.Stderr, "WARNING: could not resolve source root: %v\n", rootErr) } - // 2. Release fetch only for released CLI versions. - if IsReleasedVersion(version) { - fmt.Fprintf(os.Stderr, "Downloading fullsend %s for linux/%s from GitHub Release...\n", version, arch) - if dlErr := DownloadRelease(version, arch, binaryPath); dlErr == nil { - fmt.Fprintf(os.Stderr, "Downloaded fullsend for linux/%s\n", arch) + if IsReleasedVersion(opts.Version) { + fmt.Fprintf(os.Stderr, "Downloading fullsend %s for linux/%s from GitHub Release...\n", opts.Version, opts.Arch) + if dlErr := DownloadRelease(opts.Version, opts.Arch, binaryPath); dlErr == nil { + fmt.Fprintf(os.Stderr, "Downloaded fullsend for linux/%s\n", opts.Arch) return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceReleaseDownload}, nil } else { os.RemoveAll(tmpDir) - return AcquireResult{}, fmt.Errorf("cross-compilation unavailable and release download failed for v%s: %w", version, dlErr) + return AcquireResult{}, fmt.Errorf("cross-compilation unavailable and release download failed for v%s: %w", opts.Version, dlErr) } } os.RemoveAll(tmpDir) - return AcquireResult{}, fmt.Errorf("cannot vendor binary: not in fullsend source tree and CLI version %s is a dev build — use --fullsend-binary, run from a checkout, or use a released CLI", version) + return AcquireResult{}, fmt.Errorf("cannot vendor binary: not in fullsend source tree and CLI version %s is a dev build — use --fullsend-binary, --fullsend-source, run from a checkout, or use a released CLI", opts.Version) } diff --git a/internal/binary/crosscompile.go b/internal/binary/crosscompile.go index d71b0407ae..ac858f106a 100644 --- a/internal/binary/crosscompile.go +++ b/internal/binary/crosscompile.go @@ -14,6 +14,7 @@ type CrossCompileOpts struct { Arch string DestPath string VersionStamp string // e.g. "-vendored", "-crosscompiled", or "" + SourceDir string // optional module root; defaults to ModuleRoot() } // ModuleRoot returns the fullsend module root directory, or an error if not @@ -35,6 +36,16 @@ func ModuleRoot() (string, error) { return filepath.Dir(modPath), nil } +func resolveBuildRoot(sourceDir string) (string, error) { + if sourceDir != "" { + if err := ValidateSourceRoot(sourceDir); err != nil { + return "", err + } + return filepath.Abs(sourceDir) + } + return ModuleRoot() +} + // CrossCompile builds a Linux fullsend binary and writes it to DestPath. // Requires the Go toolchain and a fullsend module checkout (go env GOMOD). func CrossCompile(opts CrossCompileOpts) error { @@ -43,7 +54,7 @@ func CrossCompile(opts CrossCompileOpts) error { return fmt.Errorf("Go toolchain not found — install Go or use a released version of fullsend: %w", lookErr) } - modRoot, err := ModuleRoot() + modRoot, err := resolveBuildRoot(opts.SourceDir) if err != nil { return fmt.Errorf("not in a Go module — run from the fullsend source tree or use a released version: %w", err) } diff --git a/internal/binary/download.go b/internal/binary/download.go index 8714a34555..bd66610f42 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "os" "path/filepath" @@ -141,6 +142,141 @@ func resolveLatestReleaseTag() (string, error) { return release.TagName, nil } +// SourceArchiveBaseURL is the GitHub source archive base URL. Tests may override. +var SourceArchiveBaseURL = "https://github.com/fullsend-ai/fullsend/archive/refs/tags" + +// FetchSourceTree downloads the fullsend source tree for the given release +// version and extracts it into destDir (module root contents, not wrapped). +func FetchSourceTree(version, destDir string) error { + tag := version + if !strings.HasPrefix(tag, "v") { + tag = "v" + strings.TrimPrefix(version, "v") + } + url := fmt.Sprintf("%s/%s.tar.gz", SourceArchiveBaseURL, tag) + + resp, err := HTTPClient.Get(url) //nolint:gosec // URL is constructed from known constants + if err != nil { + return fmt.Errorf("fetching source archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s returned %d", url, resp.StatusCode) + } + + maxSize := int64(maxDownloadSize) + var buf bytes.Buffer + if _, err := io.Copy(&buf, io.LimitReader(resp.Body, maxSize+1)); err != nil { + return fmt.Errorf("reading source archive: %w", err) + } + if int64(buf.Len()) > maxSize { + return fmt.Errorf("source archive exceeds maximum size (%d bytes)", maxSize) + } + + return extractSourceTree(bytes.NewReader(buf.Bytes()), destDir) +} + +func extractSourceTree(r io.Reader, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer gz.Close() + + tmpDir, err := os.MkdirTemp(filepath.Dir(destDir), "fullsend-src-*") + if err != nil { + return fmt.Errorf("creating temp extract dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + tr := tar.NewReader(gz) + var rootPrefix string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("reading source tar: %w", err) + } + clean := filepath.Clean(hdr.Name) + if strings.Contains(clean, "..") || filepath.IsAbs(clean) { + continue + } + if rootPrefix == "" { + parts := strings.SplitN(clean, "/", 2) + if len(parts) == 0 || parts[0] == "" { + return fmt.Errorf("unexpected source archive layout") + } + rootPrefix = parts[0] + "/" + } + if !strings.HasPrefix(clean+"/", rootPrefix) { + continue + } + rel := strings.TrimPrefix(clean, strings.TrimSuffix(rootPrefix, "/")) + if rel == "" || rel == "." { + continue + } + target := filepath.Join(tmpDir, rel) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("creating dir %s: %w", rel, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("creating parent for %s: %w", rel, err) + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o777) + if err != nil { + return fmt.Errorf("creating file %s: %w", rel, err) + } + if _, err := io.Copy(f, io.LimitReader(tr, int64(maxDownloadSize)+1)); err != nil { + f.Close() + return fmt.Errorf("extracting %s: %w", rel, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("closing %s: %w", rel, err) + } + } + } + + if err := os.RemoveAll(destDir); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("preparing dest dir: %w", err) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("creating dest dir: %w", err) + } + return copyDirContents(tmpDir, destDir) +} + +func copyDirContents(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) +} + // ExtractFullsendFromTarGz reads a tar.gz stream and extracts the "fullsend" // binary to destPath. func ExtractFullsendFromTarGz(r io.Reader, destPath string) error { diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 23b20db993..8df988b32a 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -305,7 +305,7 @@ func TestResolveForVendor_DevNoCheckoutFails(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) t.Cleanup(func() { _ = os.Chdir(origDir) }) - _, err = ResolveForVendor("dev", "amd64") + _, err = ResolveForVendor(VendorOpts{Version: "dev", Arch: "amd64"}) require.Error(t, err) assert.Contains(t, err.Error(), "dev build") } @@ -335,7 +335,7 @@ func TestResolveForVendor_NoLatestFallback(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) t.Cleanup(func() { _ = os.Chdir(origDir) }) - _, err = ResolveForVendor("0.4.0", "amd64") + _, err = ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) require.Error(t, err) assert.Equal(t, int32(0), latestCalls.Load(), "vendor path must not call latest release API") assert.NotContains(t, err.Error(), "latest") @@ -383,7 +383,7 @@ func TestResolveForVendor_ReleaseFallback(t *testing.T) { require.NoError(t, os.Chdir(tmpDir)) t.Cleanup(func() { _ = os.Chdir(origDir) }) - result, err := ResolveForVendor("0.4.0", "amd64") + result, err := ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) require.NoError(t, err) t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) assert.Equal(t, SourceReleaseDownload, result.Source) diff --git a/internal/binary/vendorroot.go b/internal/binary/vendorroot.go new file mode 100644 index 0000000000..8569522797 --- /dev/null +++ b/internal/binary/vendorroot.go @@ -0,0 +1,79 @@ +package binary + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const moduleImportPath = "github.com/fullsend-ai/fullsend" + +// VendorRoot holds a resolved fullsend source tree for vendoring. +type VendorRoot struct { + Path string + Cleanup func() +} + +// ValidateSourceRoot checks that dir is a fullsend module checkout. +func ValidateSourceRoot(dir string) error { + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("resolving source path: %w", err) + } + info, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("source path %s: %w", dir, err) + } + if !info.IsDir() { + return fmt.Errorf("source path %s is not a directory", dir) + } + modData, err := os.ReadFile(filepath.Join(abs, "go.mod")) + if err != nil { + return fmt.Errorf("source path %s missing go.mod: %w", dir, err) + } + if !strings.Contains(string(modData), "module "+moduleImportPath) { + return fmt.Errorf("source path %s is not a fullsend module checkout", dir) + } + cmdPath := filepath.Join(abs, "cmd", "fullsend") + cmdInfo, err := os.Stat(cmdPath) + if err != nil || !cmdInfo.IsDir() { + return fmt.Errorf("source path %s missing cmd/fullsend", dir) + } + return nil +} + +// ResolveVendorRoot resolves a fullsend source tree for vendoring content and +// cross-compilation. Precedence: explicit sourceDir → ModuleRoot() → GitHub +// source fetch (released CLI only). +func ResolveVendorRoot(sourceDir, version string) (VendorRoot, error) { + if sourceDir != "" { + if err := ValidateSourceRoot(sourceDir); err != nil { + return VendorRoot{}, err + } + abs, err := filepath.Abs(sourceDir) + if err != nil { + return VendorRoot{}, err + } + return VendorRoot{Path: abs}, nil + } + + if root, err := ModuleRoot(); err == nil { + return VendorRoot{Path: root}, nil + } + + if !IsReleasedVersion(version) { + return VendorRoot{}, fmt.Errorf("cannot resolve fullsend source: not in a checkout and CLI version %s is a dev build — use --fullsend-source, run from a checkout, or use a released CLI", version) + } + + tmpDir, err := os.MkdirTemp("", "fullsend-source-*") + if err != nil { + return VendorRoot{}, fmt.Errorf("creating temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + if err := FetchSourceTree(version, tmpDir); err != nil { + cleanup() + return VendorRoot{}, err + } + return VendorRoot{Path: tmpDir, Cleanup: cleanup}, nil +} diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 0e23ad809d..62a5264406 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -149,8 +149,9 @@ type perRepoInstallConfig struct { MintSkipDeploy bool SkipMintCheck bool AppSet string - VendorBinary bool + Vendor bool FullsendBinary string + FullsendSource string } // wifProviderPattern validates the full WIF provider resource name format @@ -226,8 +227,9 @@ func newInstallCmd() *cobra.Command { var agents string var dryRun bool var skipAppSetup bool - var vendorBinary bool + var vendor bool var fullsendBinary string + var fullsendSource string var enrollAllFlag bool var enrollNoneFlag bool var inferenceProject string @@ -272,7 +274,7 @@ Inference authentication: if err := appsetup.ValidateAppSet(appSet); err != nil { return fmt.Errorf("invalid --app-set: %w", err) } - if err := validateVendorBinaryFlags(vendorBinary, fullsendBinary); err != nil { + if err := validateVendorFlags(vendor, fullsendBinary, fullsendSource); err != nil { return err } @@ -308,8 +310,9 @@ Inference authentication: MintSkipDeploy: mintSkipDeploy, SkipMintCheck: skipMintCheck, AppSet: appSet, - VendorBinary: vendorBinary, + Vendor: vendor, FullsendBinary: fullsendBinary, + FullsendSource: fullsendSource, }) } @@ -496,7 +499,7 @@ Inference authentication: printer.Blank() if dryRun { - return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName, skipMintCheck, mintURL, allRepos, vendorBinary, fullsendBinary) + return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName, skipMintCheck, mintURL, allRepos, vendor, fullsendBinary, fullsendSource) } if err := checkInstallScopes(ctx, client, printer); err != nil { @@ -539,15 +542,14 @@ Inference authentication: agentCreds = creds } - return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName, vendorBinary, fullsendBinary, mintProvider, mintProject, mintRegion, mintSourceDir, mintSkipDeploy, mintURL, skipMintCheck, allRepos) + return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName, vendor, fullsendBinary, fullsendSource, mintProvider, mintProject, mintRegion, mintSourceDir, mintSkipDeploy, mintURL, skipMintCheck, allRepos) }, } cmd.Flags().StringVar(&agents, "agents", strings.Join(config.DefaultAgentRoles(), ","), "comma-separated agent roles") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") cmd.Flags().BoolVar(&skipAppSetup, "skip-app-setup", false, "skip GitHub App creation/setup") - cmd.Flags().BoolVar(&vendorBinary, "vendor-fullsend-binary", false, "resolve and upload a linux/amd64 fullsend binary for CI") - cmd.Flags().StringVar(&fullsendBinary, "fullsend-binary", "", "path to a Linux fullsend binary to upload when vendoring (default: auto-resolve)") + addVendorFlags(cmd, &vendor, &fullsendBinary, &fullsendSource) cmd.Flags().BoolVar(&enrollAllFlag, "enroll-all", false, "enroll all repositories without prompting") cmd.Flags().BoolVar(&enrollNoneFlag, "enroll-none", false, "skip repository enrollment without prompting") cmd.Flags().StringVar(&inferenceProject, "inference-project", "", "GCP project ID for inference (Agent Platform)") @@ -583,8 +585,9 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { mintSourceDir := c.MintSourceDir mintSkipDeploy := c.MintSkipDeploy skipMintCheck := c.SkipMintCheck - vendorBinary := c.VendorBinary + vendor := c.Vendor fullsendBinary := c.FullsendBinary + fullsendSource := c.FullsendSource if strings.Contains(repoFullName, "://") || strings.HasPrefix(repoFullName, "www.") { return fmt.Errorf("expected owner/repo format, got a URL — use just the owner/repo portion (e.g. acme/widget)") @@ -649,36 +652,30 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { return fmt.Errorf("invalid config: %w", err) } - shimContent, err := scaffold.PerRepoShimTemplate() + cfgYAML, err := cfg.Marshal() if err != nil { - return fmt.Errorf("loading per-repo shim template: %w", err) + return fmt.Errorf("marshaling per-repo config: %w", err) } - cfgYAML, err := cfg.Marshal() + installFiles, err := scaffold.CollectPerRepoInstallFiles(vendor) if err != nil { - return fmt.Errorf("marshaling per-repo config: %w", err) + return fmt.Errorf("collecting per-repo scaffold files: %w", err) } var files []forge.TreeFile - files = append(files, forge.TreeFile{ - Path: ".github/workflows/fullsend.yaml", - Content: shimContent, - Mode: "100644", - }) + for _, f := range installFiles { + files = append(files, forge.TreeFile{ + Path: f.Path, + Content: f.Content, + Mode: f.Mode, + }) + } files = append(files, forge.TreeFile{ Path: ".fullsend/config.yaml", Content: cfgYAML, Mode: "100644", }) - for _, dir := range scaffold.PerRepoCustomizedDirs() { - files = append(files, forge.TreeFile{ - Path: dir + "/.gitkeep", - Content: []byte(""), - Mode: "100644", - }) - } - needsWIFProvision := inferenceWIFProvider == "" guardVal, guardExists, guardErr := client.GetRepoVariable(ctx, owner, repo, forge.PerRepoGuardVar) @@ -835,12 +832,12 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { for _, name := range secretNames { printer.StepInfo(fmt.Sprintf(" %s", name)) } - if vendorBinary { + if vendor { printer.Blank() - printer.StepInfo(vendorDryRunMessage(fullsendBinary, layers.VendoredBinaryPathPerRepo)) + printer.StepInfo(vendorDryRunMessage(fullsendBinary, fullsendSource, layers.VendoredBinaryPathPerRepo)) } else { printer.Blank() - printer.StepInfo(fmt.Sprintf("Would remove stale vendored binary at %s (if present)", layers.VendoredBinaryPathPerRepo)) + printer.StepInfo(fmt.Sprintf("Would remove stale vendored assets at %s (if present)", layers.VendoredBinaryPathPerRepo)) } return nil } @@ -1025,12 +1022,12 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { } printer.StepDone(fmt.Sprintf("Set %d repository secrets", len(repoSecrets))) - if vendorBinary { - if err := acquireAndVendorFullsendBinary(ctx, client, printer, owner, repo, fullsendBinary); err != nil { - return fmt.Errorf("vendoring binary: %w", err) + if vendor { + if err := acquireAndVendor(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource); err != nil { + return fmt.Errorf("vendoring assets: %w", err) } } else { - if err := removeStaleVendoredBinary(ctx, client, printer, owner, repo, layers.VendoredBinaryPathPerRepo); err != nil { + if err := removeStaleVendoredAssets(ctx, client, printer, owner, repo, true); err != nil { return err } } @@ -1133,7 +1130,7 @@ func newAnalyzeCmd() *cobra.Command { // runDryRun builds a layer stack with empty credentials and analyzes. // If discoveredRepos is non-nil, it will be used instead of calling ListOrgRepos. -func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, inferenceProvider inference.Provider, inferenceProviderName string, skipMintCheck bool, mintURL string, discoveredRepos []forge.Repository, vendorBinary bool, fullsendBinary string) error { +func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, inferenceProvider inference.Provider, inferenceProviderName string, skipMintCheck bool, mintURL string, discoveredRepos []forge.Repository, vendor bool, fullsendBinary, fullsendSource string) error { printer.Header("Dry run - analyzing what install would do") printer.Blank() @@ -1194,7 +1191,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } else { dispatcher = gcf.NewProvisioner(gcf.Config{}, nil) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), dispatcher) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1455,7 +1452,7 @@ func validateEnabledRepos(enabledRepos, discoveredNames []string) error { // runInstall performs the full installation. // If discoveredRepos is non-nil, it will be used instead of calling ListOrgRepos. -func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, agentCreds []layers.AgentCredentials, inferenceProvider inference.Provider, inferenceProviderName string, vendorBinary bool, fullsendBinary, mintProvider, mintProject, mintRegion, mintSourceDir string, mintSkipDeploy bool, mintURL string, skipMintCheck bool, discoveredRepos []forge.Repository) error { +func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, agentCreds []layers.AgentCredentials, inferenceProvider inference.Provider, inferenceProviderName string, vendor bool, fullsendBinary, fullsendSource, mintProvider, mintProject, mintRegion, mintSourceDir string, mintSkipDeploy bool, mintURL string, skipMintCheck bool, discoveredRepos []forge.Repository) error { var allRepos []forge.Repository var err error @@ -1547,7 +1544,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o }, gcf.NewLiveGCFClient(mintProject)) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendorBinary, makeVendorFunc(fullsendBinary), disp) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), disp) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1640,7 +1637,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, emptyCfg := config.NewOrgConfig(nil, nil, nil, nil, "") stack := layers.NewStack( layers.NewConfigRepoLayer(org, client, emptyCfg, printer, false), - layers.NewWorkflowsLayer(org, client, printer, "", version), + layers.NewWorkflowsLayer(org, client, printer, "", version, false), layers.NewSecretsLayer(org, client, nil, printer), layers.NewInferenceLayer(org, client, nil, printer), dispatchLayer, @@ -1814,7 +1811,7 @@ func buildLayerStack( agentCreds []layers.AgentCredentials, enrolledRepoIDs []int64, inferenceProvider inference.Provider, - vendorBinary bool, + vendor bool, vendorFn layers.VendorFunc, dispatcher dispatch.Dispatcher, ) *layers.Stack { @@ -1832,8 +1829,8 @@ func buildLayerStack( return layers.NewStack( layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo), - layers.NewWorkflowsLayer(org, client, printer, user, version), - layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendorBinary, vendorFn), + layers.NewWorkflowsLayer(org, client, printer, user, version, vendor), + layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendor, vendorFn), layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(), layers.NewInferenceLayer(org, client, inferenceProvider, printer), dispatchLayer, diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 703b6f08c8..2efcb3da08 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -55,9 +55,9 @@ func TestInstallCmd_Flags(t *testing.T) { skipAppSetupFlag := cmd.Flags().Lookup("skip-app-setup") require.NotNil(t, skipAppSetupFlag, "expected --skip-app-setup flag") - vendorBinaryFlag := cmd.Flags().Lookup("vendor-fullsend-binary") - require.NotNil(t, vendorBinaryFlag, "expected --vendor-fullsend-binary flag") - assert.Equal(t, "false", vendorBinaryFlag.DefValue) + vendorFlag := cmd.Flags().Lookup("vendor") + require.NotNil(t, vendorFlag, "expected --vendor flag") + assert.Equal(t, "false", vendorFlag.DefValue) inferenceProjectFlag := cmd.Flags().Lookup("inference-project") require.NotNil(t, inferenceProjectFlag, "expected --inference-project flag") @@ -228,7 +228,7 @@ func TestInstallCmd_PerRepoAcceptsSharedFlags(t *testing.T) { {"mint-source-dir", "/tmp/src"}, {"skip-mint-deploy", ""}, {"app-set", "custom-prefix"}, - {"vendor-fullsend-binary", ""}, + {"vendor", ""}, } for _, tc := range sharedFlags { t.Run(tc.flag, func(t *testing.T) { @@ -1210,7 +1210,7 @@ func TestCheckInstallScopes_SyncWithLayers(t *testing.T) { emptyCfg := &config.OrgConfig{} stack := layers.NewStack( layers.NewConfigRepoLayer("test-org", nil, emptyCfg, ui.New(&discardWriter{}), false), - layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version"), + layers.NewWorkflowsLayer("test-org", nil, ui.New(&discardWriter{}), "", "test-version", false), layers.NewSecretsLayer("test-org", nil, nil, ui.New(&discardWriter{})), layers.NewInferenceLayer("test-org", nil, nil, ui.New(&discardWriter{})), layers.NewOIDCDispatchLayer("test-org", nil, nil, nil, ui.New(&discardWriter{})), diff --git a/internal/cli/github.go b/internal/cli/github.go index ed695b7213..ef323c311d 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -59,9 +59,10 @@ type githubSetupConfig struct { appSet string enrollAll bool enrollNone bool - vendorBinary bool - fullsendBinary string - dryRun bool + vendor bool + fullsendBinary string + fullsendSource string + dryRun bool } func newGitHubSetupCmd() *cobra.Command { @@ -90,7 +91,7 @@ values (mint URL, WIF provider, project ID) are provided as flags.`, if err := appsetup.ValidateAppSet(cfg.appSet); err != nil { return fmt.Errorf("invalid --app-set: %w", err) } - if err := validateVendorBinaryFlags(cfg.vendorBinary, cfg.fullsendBinary); err != nil { + if err := validateVendorFlags(cfg.vendor, cfg.fullsendBinary, cfg.fullsendSource); err != nil { return err } @@ -136,9 +137,8 @@ values (mint URL, WIF provider, project ID) are provided as flags.`, cmd.Flags().StringVar(&cfg.appSet, "app-set", appsetup.DefaultAppSet, "app set name prefix for GitHub Apps") cmd.Flags().BoolVar(&cfg.enrollAll, "enroll-all", false, "enroll all repositories without prompting") cmd.Flags().BoolVar(&cfg.enrollNone, "enroll-none", false, "skip repository enrollment without prompting") - cmd.Flags().BoolVar(&cfg.vendorBinary, "vendor-fullsend-binary", false, "resolve and upload a linux/amd64 fullsend binary for CI") - cmd.Flags().StringVar(&cfg.fullsendBinary, "fullsend-binary", "", "path to a Linux fullsend binary to upload when vendoring (default: auto-resolve)") - cmd.Flags().BoolVar(&cfg.dryRun, "dry-run", false, "preview changes without making them") + cmd.Flags().BoolVar(&cfg.dryRun, "dry-run", false, "print actions without making changes") + addVendorFlags(cmd, &cfg.vendor, &cfg.fullsendBinary, &cfg.fullsendSource) return cmd } @@ -212,34 +212,29 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui return fmt.Errorf("invalid config: %w", err) } - shimContent, err := scaffold.PerRepoShimTemplate() + cfgYAML, err := perRepoCfg.Marshal() if err != nil { - return fmt.Errorf("loading per-repo shim template: %w", err) + return fmt.Errorf("marshaling per-repo config: %w", err) } - cfgYAML, err := perRepoCfg.Marshal() + installFiles, err := scaffold.CollectPerRepoInstallFiles(cfg.vendor) if err != nil { - return fmt.Errorf("marshaling per-repo config: %w", err) + return fmt.Errorf("collecting per-repo scaffold files: %w", err) } var files []forge.TreeFile - files = append(files, forge.TreeFile{ - Path: ".github/workflows/fullsend.yaml", - Content: shimContent, - Mode: "100644", - }) + for _, f := range installFiles { + files = append(files, forge.TreeFile{ + Path: f.Path, + Content: f.Content, + Mode: f.Mode, + }) + } files = append(files, forge.TreeFile{ Path: ".fullsend/config.yaml", Content: cfgYAML, Mode: "100644", }) - for _, dir := range scaffold.PerRepoCustomizedDirs() { - files = append(files, forge.TreeFile{ - Path: dir + "/.gitkeep", - Content: []byte(""), - Mode: "100644", - }) - } repoVars := map[string]string{ "FULLSEND_MINT_URL": cfg.mintURL, @@ -271,12 +266,12 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui for _, name := range secretNames { printer.StepInfo(fmt.Sprintf(" %s", name)) } - if cfg.vendorBinary { + if cfg.vendor { printer.Blank() - printer.StepInfo(vendorDryRunMessage(cfg.fullsendBinary, layers.VendoredBinaryPathPerRepo)) + printer.StepInfo(vendorDryRunMessage(cfg.fullsendBinary, cfg.fullsendSource, layers.VendoredBinaryPathPerRepo)) } else { printer.Blank() - printer.StepInfo(fmt.Sprintf("Would remove stale vendored binary at %s (if present)", layers.VendoredBinaryPathPerRepo)) + printer.StepInfo(fmt.Sprintf("Would remove stale vendored assets at %s (if present)", layers.VendoredBinaryPathPerRepo)) } return nil } @@ -317,12 +312,12 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui } printer.StepDone(fmt.Sprintf("Set %d repository secrets", len(repoSecrets))) - if cfg.vendorBinary { - if err := acquireAndVendorFullsendBinary(ctx, client, printer, owner, repo, cfg.fullsendBinary); err != nil { - return fmt.Errorf("vendoring binary: %w", err) + if cfg.vendor { + if err := acquireAndVendor(ctx, client, printer, owner, repo, cfg.fullsendBinary, cfg.fullsendSource); err != nil { + return fmt.Errorf("vendoring assets: %w", err) } } else { - if err := removeStaleVendoredBinary(ctx, client, printer, owner, repo, layers.VendoredBinaryPathPerRepo); err != nil { + if err := removeStaleVendoredAssets(ctx, client, printer, owner, repo, true); err != nil { return err } } @@ -473,11 +468,11 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. dispatcher := &skipMintDispatcher{mintURL: cfg.mintURL} var vendorFn layers.VendorFunc - if cfg.vendorBinary { - vendorFn = makeVendorFunc(cfg.fullsendBinary) + if cfg.vendor { + vendorFn = makeVendorFunc(cfg.fullsendBinary, cfg.fullsendSource) } - stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher) + stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, dispatcher) if cfg.dryRun { printer.Header("Dry run — analyzing what setup would do") @@ -513,7 +508,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) orgCfg.Dispatch.Mode = "oidc-mint" - stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendorBinary, vendorFn, dispatcher) + stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, dispatcher) } if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { @@ -1007,7 +1002,22 @@ func runGitHubSyncScaffold(ctx context.Context, client forge.Client, printer *ui return fmt.Errorf("getting authenticated user: %w", err) } - workflowsLayer := layers.NewWorkflowsLayer(org, client, printer, user, version) + vendored := false + if _, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, scaffold.VendoredMarkerPath()); err == nil { + vendored = true + } else if !forge.IsNotFound(err) { + return fmt.Errorf("checking vendored marker: %w", err) + } + + if cfgData, cfgErr := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml"); cfgErr == nil { + if _, parseErr := config.ParseOrgConfig(cfgData); parseErr != nil { + return fmt.Errorf("parsing config.yaml: %w", parseErr) + } + } else if !forge.IsNotFound(cfgErr) { + return fmt.Errorf("reading config.yaml: %w", cfgErr) + } + + workflowsLayer := layers.NewWorkflowsLayer(org, client, printer, user, version, vendored) if err := workflowsLayer.Install(ctx); err != nil { return fmt.Errorf("syncing scaffold: %w", err) diff --git a/internal/cli/github_test.go b/internal/cli/github_test.go index 3761e74776..391f38592c 100644 --- a/internal/cli/github_test.go +++ b/internal/cli/github_test.go @@ -80,8 +80,8 @@ func TestGitHubSetupCmd_Flags(t *testing.T) { enrollNoneFlag := cmd.Flags().Lookup("enroll-none") require.NotNil(t, enrollNoneFlag, "expected --enroll-none flag") - vendorBinaryFlag := cmd.Flags().Lookup("vendor-fullsend-binary") - require.NotNil(t, vendorBinaryFlag, "expected --vendor-fullsend-binary flag") + vendorFlag := cmd.Flags().Lookup("vendor") + require.NotNil(t, vendorFlag, "expected --vendor flag") inferenceProjectFlag := cmd.Flags().Lookup("inference-project") require.NotNil(t, inferenceProjectFlag, "expected --inference-project flag") diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index bf455a4f78..ec6f61f15d 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -5,37 +5,60 @@ import ( "fmt" "os" + "github.com/spf13/cobra" + "github.com/fullsend-ai/fullsend/internal/binary" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/layers" + "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" ) const vendorArch = binary.DefaultArch -func validateVendorBinaryFlags(vendorBinary bool, fullsendBinary string) error { - if fullsendBinary != "" && !vendorBinary { - return fmt.Errorf("--fullsend-binary requires --vendor-fullsend-binary") +func validateVendorFlags(vendor bool, fullsendBinary, fullsendSource string) error { + if fullsendBinary != "" && !vendor { + return fmt.Errorf("--fullsend-binary requires --vendor") + } + if fullsendSource != "" && !vendor { + return fmt.Errorf("--fullsend-source requires --vendor") } return nil } -// makeVendorFunc returns a VendorFunc closure that uploads a fullsend binary -// using the vendoring acquisition policy. -func makeVendorFunc(fullsendBinary string) layers.VendorFunc { +func addVendorFlags(cmd *cobra.Command, vendor *bool, fullsendBinary, fullsendSource *string) { + cmd.Flags().BoolVar(vendor, "vendor", false, "vendor binary, reusable workflows, actions, and agent content for CI") + cmd.Flags().StringVar(fullsendBinary, "fullsend-binary", "", "path to a Linux fullsend binary to upload when vendoring (default: auto-resolve)") + cmd.Flags().StringVar(fullsendSource, "fullsend-source", "", "fullsend source checkout for content and cross-compile (default: auto-detect or GitHub fetch)") +} + +// makeVendorFunc returns a VendorFunc closure that uploads vendored assets. +func makeVendorFunc(fullsendBinary, fullsendSource string) layers.VendorFunc { return func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) error { - return acquireAndVendorFullsendBinary(ctx, client, printer, owner, repo, fullsendBinary) + return acquireAndVendor(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource) } } -// acquireAndVendorFullsendBinary resolves a Linux binary and uploads it to the -// target repo using the vendoring policy. -func acquireAndVendorFullsendBinary(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary string) error { +func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) error { + perRepo := repo != forge.ConfigRepoName + pathPrefix := "" + if perRepo { + pathPrefix = ".fullsend/" + } destPath := layers.VendoredBinaryPath - if repo != forge.ConfigRepoName { + if perRepo { destPath = layers.VendoredBinaryPathPerRepo } + root, err := binary.ResolveVendorRoot(fullsendSource, version) + if err != nil { + printer.StepFail("Failed to resolve fullsend source") + return err + } + if root.Cleanup != nil { + defer root.Cleanup() + } + var ( binPath string source binary.Source @@ -52,7 +75,11 @@ func acquireAndVendorFullsendBinary(ctx context.Context, client forge.Client, pr source = binary.SourceExplicitPath printer.StepDone("Validated linux/amd64 ELF binary") } else { - result, err := binary.ResolveForVendor(version, vendorArch) + result, err := binary.ResolveForVendor(binary.VendorOpts{ + SourceDir: fullsendSource, + Version: version, + Arch: vendorArch, + }) if err != nil { printer.StepFail("Failed to obtain binary for vendoring") return err @@ -71,19 +98,92 @@ func acquireAndVendorFullsendBinary(ctx context.Context, client forge.Client, pr return fmt.Errorf("stat binary: %w", err) } - commitMsg := layers.VendorCommitMessage(source, version, destPath, info.Size()) - printer.StepStart(fmt.Sprintf("Uploading vendored binary to %s", destPath)) - if err := layers.VendorBinary(ctx, client, owner, repo, destPath, binPath, commitMsg); err != nil { + binMsg := layers.VendorCommitMessage(source, version, destPath, info.Size()) + if err := layers.VendorBinary(ctx, client, owner, repo, destPath, binPath, binMsg); err != nil { printer.StepFail("Failed to upload vendored binary") return err } - printer.StepDone(fmt.Sprintf("Uploaded vendored binary (%d MB)", info.Size()/(1024*1024))) + + assets, err := scaffold.CollectVendoredAssets(root.Path, pathPrefix) + if err != nil { + printer.StepFail("Failed to collect vendored content") + return fmt.Errorf("collecting vendored content: %w", err) + } + + var files []forge.TreeFile + for _, f := range assets { + files = append(files, forge.TreeFile{ + Path: f.Path, + Content: f.Content, + Mode: f.Mode, + }) + } + + printer.StepStart(fmt.Sprintf("Uploading %d vendored content files", len(files))) + contentMsg := layers.VendorContentCommitMessage(version, pathPrefix, len(files)) + committed, err := client.CommitFiles(ctx, owner, repo, contentMsg, files) + if err != nil { + printer.StepFail("Failed to upload vendored content") + return fmt.Errorf("committing vendored content: %w", err) + } + if committed { + printer.StepDone(fmt.Sprintf("Uploaded %d vendored content files", len(files))) + } else { + printer.StepDone("Vendored content up to date") + } + + return nil +} + +func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string, perRepo bool) error { + pathPrefix := "" + if perRepo { + pathPrefix = ".fullsend/" + } + + destPath := layers.VendoredBinaryPath + if perRepo { + destPath = layers.VendoredBinaryPathPerRepo + } + if err := removeStaleVendoredBinary(ctx, client, printer, owner, repo, destPath); err != nil { + return err + } + + paths, err := scaffold.ManagedVendoredContentPaths(pathPrefix) + if err != nil { + return fmt.Errorf("enumerating vendored content paths: %w", err) + } + + legacy, err := scaffold.LegacyFlatVendoredPaths(pathPrefix) + if err != nil { + return fmt.Errorf("enumerating legacy vendored paths: %w", err) + } + paths = append(paths, legacy...) + + var removed int + for _, path := range paths { + _, err := client.GetFileContent(ctx, owner, repo, path) + if err != nil { + if forge.IsNotFound(err) { + continue + } + return fmt.Errorf("checking for vendored content at %s: %w", path, err) + } + deleteMsg := layers.RemoveStaleContentCommitMessage(path) + if err := client.DeleteFile(ctx, owner, repo, path, deleteMsg); err != nil { + return fmt.Errorf("deleting vendored content at %s: %w", path, err) + } + removed++ + } + + if removed > 0 { + printer.StepDone(fmt.Sprintf("Removed %d stale vendored content files", removed)) + } return nil } -// removeStaleVendoredBinary deletes a stale vendored binary when vendoring is disabled. func removeStaleVendoredBinary(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, destPath string) error { _, err := client.GetFileContent(ctx, owner, repo, destPath) if err != nil { @@ -103,16 +203,22 @@ func removeStaleVendoredBinary(ctx context.Context, client forge.Client, printer return nil } -// vendorDryRunMessage returns a dry-run line describing what vendoring would do. -func vendorDryRunMessage(fullsendBinary, destPath string) string { +func vendorDryRunMessage(fullsendBinary, fullsendSource, destPath string) string { if fullsendBinary != "" { - return fmt.Sprintf("Would upload provided binary from %s to %s", fullsendBinary, destPath) + msg := fmt.Sprintf("Would upload provided binary from %s to %s", fullsendBinary, destPath) + if fullsendSource != "" { + msg += fmt.Sprintf("; content from %s", fullsendSource) + } + return msg + } + if fullsendSource != "" { + return fmt.Sprintf("Would cross-compile from %s and upload vendored binary and content", fullsendSource) } if _, err := binary.ModuleRoot(); err == nil { - return fmt.Sprintf("Would cross-compile and upload vendored binary to %s", destPath) + return fmt.Sprintf("Would cross-compile and upload vendored binary and content to %s", destPath) } if binary.IsReleasedVersion(version) { - return fmt.Sprintf("Would download release %s and upload vendored binary to %s", version, destPath) + return fmt.Sprintf("Would download release %s source/binary and upload vendored assets to %s", version, destPath) } return fmt.Sprintf("Would fail: dev CLI outside checkout cannot vendor to %s", destPath) } diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index f8a4c60eae..9ddfe2082f 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -15,14 +15,19 @@ import ( "github.com/fullsend-ai/fullsend/internal/ui" ) -func TestValidateVendorBinaryFlags(t *testing.T) { - require.NoError(t, validateVendorBinaryFlags(false, "")) - require.NoError(t, validateVendorBinaryFlags(true, "")) - require.NoError(t, validateVendorBinaryFlags(true, "/tmp/fullsend")) +func TestValidateVendorFlags(t *testing.T) { + require.NoError(t, validateVendorFlags(false, "", "")) + require.NoError(t, validateVendorFlags(true, "", "")) + require.NoError(t, validateVendorFlags(true, "/tmp/fullsend", "")) + require.NoError(t, validateVendorFlags(true, "", "/tmp/src")) - err := validateVendorBinaryFlags(false, "/tmp/fullsend") + err := validateVendorFlags(false, "/tmp/fullsend", "") require.Error(t, err) - assert.Contains(t, err.Error(), "--fullsend-binary requires --vendor-fullsend-binary") + assert.Contains(t, err.Error(), "--fullsend-binary requires --vendor") + + err = validateVendorFlags(false, "", "/tmp/src") + require.Error(t, err) + assert.Contains(t, err.Error(), "--fullsend-source requires --vendor") } func TestInstallCmd_HasFullsendBinaryFlag(t *testing.T) { @@ -39,12 +44,12 @@ func TestGitHubSetupCmd_HasFullsendBinaryFlag(t *testing.T) { } func TestVendorDryRunMessage(t *testing.T) { - msg := vendorDryRunMessage("/tmp/fullsend", layers.VendoredBinaryPathPerRepo) + msg := vendorDryRunMessage("/tmp/fullsend", "", layers.VendoredBinaryPathPerRepo) assert.Contains(t, msg, "/tmp/fullsend") assert.Contains(t, msg, layers.VendoredBinaryPathPerRepo) } -func TestAcquireAndVendorFullsendBinary_ExplicitPath(t *testing.T) { +func TestAcquireAndVendor_ExplicitPath(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("needs Linux ELF binary") } @@ -55,7 +60,7 @@ func TestAcquireAndVendorFullsendBinary_ExplicitPath(t *testing.T) { var buf strings.Builder printer := ui.New(&buf) - err = acquireAndVendorFullsendBinary(context.Background(), client, printer, "org", "my-repo", exe) + err = acquireAndVendor(context.Background(), client, printer, "org", "my-repo", exe, "") require.NoError(t, err) key := "org/my-repo/" + layers.VendoredBinaryPathPerRepo @@ -65,7 +70,7 @@ func TestAcquireAndVendorFullsendBinary_ExplicitPath(t *testing.T) { assert.Contains(t, client.CreatedFiles[0].Message, "Source: --fullsend-binary") } -func TestAcquireAndVendorFullsendBinary_CheckoutBuild(t *testing.T) { +func TestAcquireAndVendor_CheckoutBuild(t *testing.T) { if testing.Short() { t.Skip("skipping cross-compile in short mode") } @@ -74,7 +79,7 @@ func TestAcquireAndVendorFullsendBinary_CheckoutBuild(t *testing.T) { var buf strings.Builder printer := ui.New(&buf) - err := acquireAndVendorFullsendBinary(context.Background(), client, printer, "org", forge.ConfigRepoName, "") + err := acquireAndVendor(context.Background(), client, printer, "org", forge.ConfigRepoName, "", "") require.NoError(t, err) key := "org/" + forge.ConfigRepoName + "/" + layers.VendoredBinaryPath diff --git a/internal/config/config.go b/internal/config/config.go index 674cd1258c..338a9181a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,13 @@ import ( "gopkg.in/yaml.v3" ) +const ( + // DefaultUpstreamRepo is the canonical fullsend repository for layered workflow calls. + DefaultUpstreamRepo = "fullsend-ai/fullsend" + // DefaultUpstreamRef is the default tag for layered upstream workflow calls. + DefaultUpstreamRef = "v0" +) + // AgentEntry represents a configured agent with its role and app identity. type AgentEntry struct { Role string `yaml:"role"` diff --git a/internal/layers/vendor.go b/internal/layers/vendor.go index 6ddd0639e5..900239a476 100644 --- a/internal/layers/vendor.go +++ b/internal/layers/vendor.go @@ -89,9 +89,31 @@ func VendorCommitMessage(source binary.Source, version, destPath string, sizeByt func RemoveStaleBinaryCommitMessage(destPath string) string { title := "chore: remove vendored fullsend binary" body := strings.Join([]string{ - "Reason: --vendor-fullsend-binary not set; removing stale binary so CI uses released versions", + "Reason: --vendor not set; removing stale binary so CI uses released versions", fmt.Sprintf("Path: %s", destPath), - "Note: re-run install with --vendor-fullsend-binary to upload again", + "Note: re-run install with --vendor to upload again", + }, "\n") + return title + "\n\n" + body +} + +// VendorContentCommitMessage returns a commit message for vendored content upload. +func VendorContentCommitMessage(version, pathPrefix string, fileCount int) string { + title := "chore: vendor fullsend workflow and agent content" + body := strings.Join([]string{ + fmt.Sprintf("CLI version: %s", version), + fmt.Sprintf("Prefix: %s", pathPrefix), + fmt.Sprintf("Files: %d", fileCount), + "Source: --vendor install", + }, "\n") + return title + "\n\n" + body +} + +// RemoveStaleContentCommitMessage returns title + body for stale content deletion. +func RemoveStaleContentCommitMessage(path string) string { + title := "chore: remove stale vendored fullsend content" + body := strings.Join([]string{ + "Reason: --vendor not set; removing stale vendored content", + fmt.Sprintf("Path: %s", path), }, "\n") return title + "\n\n" + body } diff --git a/internal/layers/vendor_test.go b/internal/layers/vendor_test.go index 4c19c5936b..4d9e448903 100644 --- a/internal/layers/vendor_test.go +++ b/internal/layers/vendor_test.go @@ -60,7 +60,7 @@ func TestRemoveStaleBinaryCommitMessage_HasTitleAndBody(t *testing.T) { require.Contains(t, msg, "\n\n") assert.Contains(t, msg, "chore: remove vendored fullsend binary") assert.Contains(t, msg, "Path: .fullsend/bin/fullsend") - assert.Contains(t, msg, "--vendor-fullsend-binary not set") + assert.Contains(t, msg, "--vendor not set") } func TestVendorCommitMessage_ReleaseTitle(t *testing.T) { diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index 901920a0fc..b8e138fc00 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -5,18 +5,17 @@ import ( "fmt" "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" ) -// VendorFunc is a callback that cross-compiles and uploads a vendored binary. +// VendorFunc uploads vendored binary and content when --vendor is set. type VendorFunc func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) error -// VendorBinaryLayer manages the vendored development binary. +// VendorBinaryLayer manages vendored binary and content assets. // -// When enabled (--vendor-fullsend-binary flag), it calls a VendorFunc callback -// to cross-compile and upload the binary. When disabled (the default), it -// checks whether a vendored binary exists and deletes it to prevent a stale -// binary from shadowing released versions. +// When enabled (--vendor), it calls VendorFunc to upload binary and content. +// When disabled, it removes stale vendored assets from prior installs. type VendorBinaryLayer struct { org string repo string @@ -41,10 +40,8 @@ func NewVendorBinaryLayer(org, repo string, client forge.Client, printer *ui.Pri } } -func (l *VendorBinaryLayer) Name() string { return "vendor-binary" } +func (l *VendorBinaryLayer) Name() string { return "vendor" } -// binaryPath returns the upload path for the vendored binary based on the -// target repo: per-org uses bin/fullsend, per-repo uses .fullsend/bin/fullsend. func (l *VendorBinaryLayer) binaryPath() string { if l.repo != forge.ConfigRepoName { return VendoredBinaryPathPerRepo @@ -52,6 +49,10 @@ func (l *VendorBinaryLayer) binaryPath() string { return VendoredBinaryPath } +func (l *VendorBinaryLayer) perRepo() bool { + return l.repo != forge.ConfigRepoName +} + // RequiredScopes returns the scopes needed for the given operation. func (l *VendorBinaryLayer) RequiredScopes(op Operation) []string { switch op { @@ -62,8 +63,7 @@ func (l *VendorBinaryLayer) RequiredScopes(op Operation) []string { } } -// Install either vendors the binary (when enabled) or removes a stale one -// (when disabled). +// Install either vendors assets (when enabled) or removes stale ones. func (l *VendorBinaryLayer) Install(ctx context.Context) error { if l.enabled { if l.vendorFn == nil { @@ -72,57 +72,105 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return l.vendorFn(ctx, l.client, l.ui, l.org, l.repo) } - // Disabled — clean up any vendored binary left from a previous install. path := l.binaryPath() _, err := l.client.GetFileContent(ctx, l.org, l.repo, path) - if err != nil { - if forge.IsNotFound(err) { - return nil - } + if err != nil && !forge.IsNotFound(err) { return fmt.Errorf("checking for vendored binary: %w", err) } + if err == nil { + l.ui.StepStart("removing stale vendored binary") + deleteMsg := RemoveStaleBinaryCommitMessage(path) + if err := l.client.DeleteFile(ctx, l.org, l.repo, path, deleteMsg); err != nil { + l.ui.StepFail("failed to remove vendored binary") + return fmt.Errorf("deleting vendored binary: %w", err) + } + l.ui.StepDone("removed stale vendored binary") + } - l.ui.StepStart("removing stale vendored binary") - deleteMsg := RemoveStaleBinaryCommitMessage(path) - if err := l.client.DeleteFile(ctx, l.org, l.repo, path, deleteMsg); err != nil { - l.ui.StepFail("failed to remove vendored binary") - return fmt.Errorf("deleting vendored binary: %w", err) + pathPrefix := "" + if l.perRepo() { + pathPrefix = ".fullsend/" + } + paths, err := scaffold.ManagedVendoredContentPaths(pathPrefix) + if err != nil { + return fmt.Errorf("enumerating vendored content paths: %w", err) + } + legacy, err := scaffold.LegacyFlatVendoredPaths(pathPrefix) + if err != nil { + return fmt.Errorf("enumerating legacy vendored paths: %w", err) + } + paths = append(paths, legacy...) + + var removed int + for _, p := range paths { + _, err := l.client.GetFileContent(ctx, l.org, l.repo, p) + if err != nil { + if forge.IsNotFound(err) { + continue + } + return fmt.Errorf("checking for vendored content at %s: %w", p, err) + } + l.ui.StepStart("removing stale vendored content") + deleteMsg := RemoveStaleContentCommitMessage(p) + if err := l.client.DeleteFile(ctx, l.org, l.repo, p, deleteMsg); err != nil { + l.ui.StepFail("failed to remove vendored content") + return fmt.Errorf("deleting vendored content at %s: %w", p, err) + } + removed++ + } + if removed > 0 { + l.ui.StepDone(fmt.Sprintf("removed %d stale vendored content files", removed)) } - l.ui.StepDone("removed stale vendored binary") return nil } -// Uninstall is a no-op. In per-org mode the vendored binary is removed when -// the config repo is deleted by ConfigRepoLayer. In per-repo mode the binary -// lives in the target repo and is cleaned up on re-install with vendor disabled. func (l *VendorBinaryLayer) Uninstall(_ context.Context) error { return nil } -// Analyze assesses the current state of the vendored binary. func (l *VendorBinaryLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} - _, err := l.client.GetFileContent(ctx, l.org, l.repo, l.binaryPath()) - if err != nil { - if forge.IsNotFound(err) { - if l.enabled { - report.Status = StatusNotInstalled - report.WouldInstall = append(report.WouldInstall, "upload vendored binary") - } else { - report.Status = StatusInstalled - report.Details = append(report.Details, "no vendored binary present") - } - return report, nil - } - return nil, fmt.Errorf("checking for vendored binary: %w", err) + marker := scaffold.VendoredMarkerPath() + + _, markerErr := l.client.GetFileContent(ctx, l.org, l.repo, marker) + if markerErr != nil && !forge.IsNotFound(markerErr) { + return nil, fmt.Errorf("checking vendored marker at %s: %w", marker, markerErr) } + hasMarker := markerErr == nil - if l.enabled { - report.Status = StatusInstalled - report.Details = append(report.Details, fmt.Sprintf("vendored binary present at %s", l.binaryPath())) - } else { + _, binErr := l.client.GetFileContent(ctx, l.org, l.repo, l.binaryPath()) + if binErr != nil && !forge.IsNotFound(binErr) { + return nil, fmt.Errorf("checking vendored binary: %w", binErr) + } + hasBinary := binErr == nil + + switch { + case l.enabled: + if hasBinary || hasMarker { + report.Status = StatusInstalled + if hasBinary { + report.Details = append(report.Details, fmt.Sprintf("vendored binary present at %s", l.binaryPath())) + } + if hasMarker { + report.Details = append(report.Details, "vendored content marker present") + } + } else { + report.Status = StatusNotInstalled + report.WouldInstall = append(report.WouldInstall, "upload vendored binary and content") + } + case hasBinary || hasMarker: report.Status = StatusDegraded - report.Details = append(report.Details, fmt.Sprintf("stale vendored binary present at %s", l.binaryPath())) - report.WouldFix = append(report.WouldFix, "delete vendored binary") + if hasBinary { + report.Details = append(report.Details, fmt.Sprintf("stale vendored binary at %s", l.binaryPath())) + report.WouldFix = append(report.WouldFix, "delete vendored binary") + } + if hasMarker { + report.Details = append(report.Details, "stale vendored content present") + report.WouldFix = append(report.WouldFix, "delete vendored content") + } + default: + report.Status = StatusInstalled + report.Details = append(report.Details, "no vendored assets present") } + return report, nil } diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index 72ee7d1e05..4ddd0e2d4a 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -24,7 +24,7 @@ func newVendorBinaryLayer(t *testing.T, client *forge.FakeClient, enabled bool, func TestVendorBinaryLayer_Name(t *testing.T) { layer, _ := newVendorBinaryLayer(t, &forge.FakeClient{}, false, nil) - assert.Equal(t, "vendor-binary", layer.Name()) + assert.Equal(t, "vendor", layer.Name()) } func TestVendorBinaryLayer_RequiredScopes(t *testing.T) { @@ -144,7 +144,7 @@ func TestVendorBinaryLayer_Analyze_EnabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) - assert.Equal(t, "vendor-binary", report.Name) + assert.Equal(t, "vendor", report.Name) assert.Equal(t, StatusInstalled, report.Status) assert.True(t, strings.Contains(strings.Join(report.Details, " "), "vendored binary present at")) } @@ -158,7 +158,7 @@ func TestVendorBinaryLayer_Analyze_EnabledAbsent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusNotInstalled, report.Status) - assert.Contains(t, report.WouldInstall, "upload vendored binary") + assert.Contains(t, report.WouldInstall, "upload vendored binary and content") } func TestVendorBinaryLayer_Analyze_DisabledPresent(t *testing.T) { @@ -172,7 +172,7 @@ func TestVendorBinaryLayer_Analyze_DisabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) - assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary present at")) + assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary at")) assert.Contains(t, report.WouldFix, "delete vendored binary") } @@ -185,10 +185,10 @@ func TestVendorBinaryLayer_Analyze_DisabledAbsent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusInstalled, report.Status) - assert.Contains(t, report.Details, "no vendored binary present") + assert.Contains(t, report.Details, "no vendored assets present") } -func TestVendorBinaryLayer_Analyze_Error(t *testing.T) { +func TestVendorBinaryLayer_Analyze_GetFileContentError(t *testing.T) { client := &forge.FakeClient{ Errors: map[string]error{ "GetFileContent": errors.New("network error"), @@ -198,7 +198,7 @@ func TestVendorBinaryLayer_Analyze_Error(t *testing.T) { _, err := layer.Analyze(context.Background()) require.Error(t, err) - assert.Contains(t, err.Error(), "checking for vendored binary") + assert.Contains(t, err.Error(), "checking vendored marker") } // binaryPath tests — per-org vs per-repo path selection. @@ -264,7 +264,7 @@ func TestVendorBinaryLayer_PerRepo_Analyze_DisabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) - assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary present at")) + assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary at")) } func TestVendorBinaryLayer_PerRepo_EnabledCallsVendorFn(t *testing.T) { diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 30ec631a5a..9c10ccb0e5 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -11,64 +11,39 @@ import ( const codeownersPath = "CODEOWNERS" -// managedFiles lists every file this layer manages. -// Populated at init from the scaffold plus the CODEOWNERS sentinel. -var managedFiles []string - -func init() { - if err := scaffold.WalkFullsendRepo(func(path string, _ []byte) error { - managedFiles = append(managedFiles, path) - return nil - }); err != nil { - panic(fmt.Sprintf("walking scaffold: %v", err)) - } - for _, dir := range scaffold.CustomizedDirs() { - managedFiles = append(managedFiles, dir+"/.gitkeep") - } - managedFiles = append(managedFiles, codeownersPath) -} - // WorkflowsLayer manages workflow files and CODEOWNERS in the .fullsend -// config repo. It writes the thin caller workflows, composite actions, -// and a CODEOWNERS file that grants the installing user ownership of all -// config-repo contents. +// config repo. type WorkflowsLayer struct { org string client forge.Client ui *ui.Printer authenticatedUser string version string + vendored bool } -// Compile-time check that WorkflowsLayer implements Layer. var _ Layer = (*WorkflowsLayer)(nil) // NewWorkflowsLayer creates a new WorkflowsLayer. -// user is the authenticated user who will own CODEOWNERS entries. -// version is the fullsend CLI version that generated the scaffold. -func NewWorkflowsLayer(org string, client forge.Client, printer *ui.Printer, user, version string) *WorkflowsLayer { +func NewWorkflowsLayer(org string, client forge.Client, printer *ui.Printer, user, version string, vendored bool) *WorkflowsLayer { return &WorkflowsLayer{ org: org, client: client, ui: printer, authenticatedUser: user, version: version, + vendored: vendored, } } -func (l *WorkflowsLayer) Name() string { - return "workflows" -} +func (l *WorkflowsLayer) Name() string { return "workflows" } -// RequiredScopes returns the scopes needed for the given operation. func (l *WorkflowsLayer) RequiredScopes(op Operation) []string { switch op { case OpInstall: - // Writing to .github/workflows/ paths requires the workflow scope. - // Without it, GitHub returns 404 (not 403), which is deeply confusing. return []string{"repo", "workflow"} case OpUninstall: - return nil // no-op + return nil case OpAnalyze: return []string{"repo"} default: @@ -76,28 +51,21 @@ func (l *WorkflowsLayer) RequiredScopes(op Operation) []string { } } -// Install writes the workflow files and CODEOWNERS to the .fullsend repo -// in a single atomic commit using the Git Trees API. If all files already -// match the current tree, no commit is created (idempotent). func (l *WorkflowsLayer) Install(ctx context.Context) error { - var files []forge.TreeFile - err := scaffold.WalkFullsendRepo(func(path string, content []byte) error { - files = append(files, forge.TreeFile{ - Path: path, - Content: content, - Mode: scaffold.FileMode(path), - }) - return nil + installFiles, err := scaffold.CollectInstallFiles(scaffold.CollectInstallFilesOptions{ + RenderOptions: scaffold.RenderOptionsForInstall(l.vendored, false), + PathPrefix: "", }) if err != nil { return fmt.Errorf("collecting scaffold files: %w", err) } - for _, dir := range scaffold.CustomizedDirs() { + var files []forge.TreeFile + for _, f := range installFiles { files = append(files, forge.TreeFile{ - Path: dir + "/.gitkeep", - Content: []byte(""), - Mode: "100644", + Path: f.Path, + Content: f.Content, + Mode: f.Mode, }) } @@ -123,18 +91,26 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { return nil } -// Uninstall is a no-op. Workflow files are removed when the config repo -// is deleted by the ConfigRepoLayer. -func (l *WorkflowsLayer) Uninstall(_ context.Context) error { - return nil -} +func (l *WorkflowsLayer) Uninstall(_ context.Context) error { return nil } -// Analyze checks which managed files exist in the config repo. func (l *WorkflowsLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} + vendored := l.vendored + if marker, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, scaffold.VendoredMarkerPath()); err == nil && len(marker) > 0 { + vendored = true + } else if !forge.IsNotFound(err) { + return nil, fmt.Errorf("checking vendored marker: %w", err) + } + + managed, err := scaffold.ManagedPaths(vendored, "") + if err != nil { + return nil, err + } + managed = append(managed, codeownersPath) + var present, missing []string - for _, path := range managedFiles { + for _, path := range managed { _, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, path) if err != nil { if forge.IsNotFound(err) { diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index 285f113c05..fa1db704e3 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -15,27 +15,26 @@ import ( "github.com/fullsend-ai/fullsend/internal/ui" ) -func newWorkflowsLayer(t *testing.T, client *forge.FakeClient) (*WorkflowsLayer, *bytes.Buffer) { +func newWorkflowsLayer(t *testing.T, client *forge.FakeClient, vendored bool) (*WorkflowsLayer, *bytes.Buffer) { t.Helper() var buf bytes.Buffer printer := ui.New(&buf) - layer := NewWorkflowsLayer("test-org", client, printer, "admin-user", "test-version") + layer := NewWorkflowsLayer("test-org", client, printer, "admin-user", "test-version", vendored) return layer, &buf } func TestWorkflowsLayer_Name(t *testing.T) { - layer, _ := newWorkflowsLayer(t, forge.NewFakeClient()) + layer, _ := newWorkflowsLayer(t, forge.NewFakeClient(), false) assert.Equal(t, "workflows", layer.Name()) } func TestWorkflowsLayer_Install_WritesAllFiles(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Install(context.Background()) require.NoError(t, err) - // Scaffold files go through CommitFiles as a single batch. require.Len(t, client.CommittedFiles, 1, "expected exactly one CommitFiles call") batch := client.CommittedFiles[0] assert.Equal(t, "test-org", batch.Owner) @@ -51,15 +50,13 @@ func TestWorkflowsLayer_Install_WritesAllFiles(t *testing.T) { assert.Contains(t, paths, ".github/workflows/review.yml") assert.Contains(t, paths, ".github/workflows/fix.yml") assert.Contains(t, paths, ".github/workflows/repo-maintenance.yml") - - // CODEOWNERS is included in the same batch. assert.Contains(t, paths, "CODEOWNERS") assert.Contains(t, paths["CODEOWNERS"], "admin-user") } func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Install(context.Background()) require.NoError(t, err) @@ -73,14 +70,35 @@ func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { } require.NotEmpty(t, triageContent, "triage.yml should have been written") - expected, err := scaffold.FullsendRepoFile(".github/workflows/triage.yml") + assert.Contains(t, triageContent, "fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0") + assert.NotContains(t, triageContent, "distribution_mode") + assert.NotContains(t, triageContent, "fullsend_ai_repo:") +} + +func TestWorkflowsLayer_Install_VendoredUsesLocalReusablePaths(t *testing.T) { + client := forge.NewFakeClient() + layer, _ := newWorkflowsLayer(t, client, true) + + err := layer.Install(context.Background()) require.NoError(t, err) - assert.Equal(t, string(expected), triageContent) + + var triageContent string + for _, f := range client.CommittedFiles[0].Files { + if f.Path == ".github/workflows/triage.yml" { + triageContent = string(f.Content) + break + } + } + require.NotEmpty(t, triageContent, "triage.yml should have been written") + + assert.Contains(t, triageContent, "uses: ./.github/workflows/reusable-triage.yml") + assert.NotContains(t, triageContent, "fullsend-ai/fullsend/") + assert.NotContains(t, triageContent, "distribution_mode") } func TestWorkflowsLayer_Install_RepoMaintenanceContent(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Install(context.Background()) require.NoError(t, err) @@ -99,14 +117,13 @@ func TestWorkflowsLayer_Install_RepoMaintenanceContent(t *testing.T) { assert.Equal(t, string(expected), maintenanceContent) } - func TestWorkflowsLayer_Install_Error(t *testing.T) { client := &forge.FakeClient{ Errors: map[string]error{ "CommitFiles": errors.New("write failed"), }, } - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Install(context.Background()) require.Error(t, err) @@ -115,7 +132,7 @@ func TestWorkflowsLayer_Install_Error(t *testing.T) { func TestWorkflowsLayer_Install_ExecutableModes(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Install(context.Background()) require.NoError(t, err) @@ -128,60 +145,54 @@ func TestWorkflowsLayer_Install_ExecutableModes(t *testing.T) { assert.Equal(t, "100644", modes[".github/workflows/triage.yml"]) assert.Equal(t, "100644", modes["customized/agents/.gitkeep"]) assert.Equal(t, "100644", modes["AGENTS.md"]) - - for path, mode := range modes { - assert.Equal(t, "100644", mode, "all installed files should be 100644 (no executables after layering): %s", path) - } } - func TestWorkflowsLayer_Uninstall_Noop(t *testing.T) { client := forge.NewFakeClient() - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) err := layer.Uninstall(context.Background()) require.NoError(t, err) - // No repos deleted, no files created assert.Empty(t, client.DeletedRepos) assert.Empty(t, client.CreatedFiles) } func TestWorkflowsLayer_Analyze_AllPresent(t *testing.T) { + managed, err := scaffold.ManagedPaths(false, "") + require.NoError(t, err) + fileContents := map[string][]byte{ "test-org/.fullsend/CODEOWNERS": []byte("* @admin-user"), } - // Populate all scaffold files - _ = scaffold.WalkFullsendRepo(func(path string, content []byte) error { - fileContents["test-org/.fullsend/"+path] = content - return nil - }) - - client := &forge.FakeClient{ - FileContents: fileContents, + for _, path := range managed { + fileContents["test-org/.fullsend/"+path] = []byte("content") } - layer, _ := newWorkflowsLayer(t, client) + + client := &forge.FakeClient{FileContents: fileContents} + layer, _ := newWorkflowsLayer(t, client, false) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, "workflows", report.Name) assert.Equal(t, StatusInstalled, report.Status) - assert.Len(t, report.Details, len(managedFiles)) + assert.Len(t, report.Details, len(managed)+1) } func TestWorkflowsLayer_Analyze_NonePresent(t *testing.T) { - client := &forge.FakeClient{ - FileContents: map[string][]byte{}, - } - layer, _ := newWorkflowsLayer(t, client) + managed, err := scaffold.ManagedPaths(false, "") + require.NoError(t, err) + + client := &forge.FakeClient{FileContents: map[string][]byte{}} + layer, _ := newWorkflowsLayer(t, client, false) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, "workflows", report.Name) assert.Equal(t, StatusNotInstalled, report.Status) - assert.Len(t, report.WouldInstall, len(managedFiles)) + assert.Len(t, report.WouldInstall, len(managed)+1) } func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { @@ -190,47 +201,41 @@ func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { "test-org/.fullsend/.github/workflows/triage.yml": []byte("triage workflow"), }, } - layer, _ := newWorkflowsLayer(t, client) + layer, _ := newWorkflowsLayer(t, client, false) report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, "workflows", report.Name) assert.Equal(t, StatusDegraded, report.Status) - // Details should list what exists joined := strings.Join(report.Details, " ") assert.Contains(t, joined, "triage.yml") - // WouldFix should list what's missing assert.NotEmpty(t, report.WouldFix) fixJoined := strings.Join(report.WouldFix, " ") assert.Contains(t, fixJoined, "CODEOWNERS") } -func TestManagedFilesMatchScaffold(t *testing.T) { +func TestManagedPathsMatchLayeredScaffold(t *testing.T) { + managed, err := scaffold.ManagedPaths(false, "") + require.NoError(t, err) + var scaffoldPaths []string - err := scaffold.WalkFullsendRepo(func(path string, _ []byte) error { + err = scaffold.WalkFullsendRepo(func(path string, _ []byte) error { scaffoldPaths = append(scaffoldPaths, path) return nil }) require.NoError(t, err) for _, path := range scaffoldPaths { - found := false - for _, managed := range managedFiles { - if managed == path { - found = true - break - } - } - assert.True(t, found, "managedFiles should include scaffold file %s", path) + assert.Contains(t, managed, path, "managed paths should include scaffold file %s", path) } } -func TestManagedFilesDoNotIncludeOldPlaceholders(t *testing.T) { - for _, path := range managedFiles { - assert.NotEqual(t, ".github/workflows/agent.yaml", path, - "managedFiles should not include old agent.yaml placeholder") - assert.NotEqual(t, ".github/workflows/repo-onboard.yaml", path, - "managedFiles should not include old repo-onboard.yaml placeholder") - } +func TestManagedPathsVendoredIncludeContent(t *testing.T) { + managed, err := scaffold.ManagedPaths(true, "") + require.NoError(t, err) + + assert.Contains(t, managed, ".github/workflows/reusable-triage.yml") + assert.Contains(t, managed, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") + assert.Contains(t, managed, scaffold.VendoredMarkerPath()) } diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index 5af89146f7..b5fcf61ed8 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -29,13 +29,14 @@ concurrency: jobs: code: - uses: fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} event_payload: ${{ inputs.event_payload }} mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/fix.yml b/internal/scaffold/fullsend-repo/.github/workflows/fix.yml index 0324a75502..50c5a8f171 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/fix.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/fix.yml @@ -50,7 +50,7 @@ concurrency: jobs: fix: - uses: fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} @@ -60,6 +60,7 @@ jobs: instruction: ${{ inputs.instruction || '' }} mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml b/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml index 2c2c5f612a..64742b6049 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/prioritize.yml @@ -27,7 +27,7 @@ concurrency: jobs: prioritize: - uses: fullsend-ai/fullsend/.github/workflows/reusable-prioritize.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} @@ -35,6 +35,7 @@ jobs: mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} project_number: ${{ vars.FULLSEND_PROJECT_NUMBER }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/retro.yml b/internal/scaffold/fullsend-repo/.github/workflows/retro.yml index b0786584ce..2fe8839b2f 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/retro.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/retro.yml @@ -34,13 +34,14 @@ jobs: retro: needs: debounce - uses: fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} event_payload: ${{ inputs.event_payload }} mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index d304c147c9..434d67dee2 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -28,13 +28,14 @@ concurrency: jobs: review: - uses: fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} event_payload: ${{ inputs.event_payload }} mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index 1bd2e91f45..f5166acb69 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -27,13 +27,14 @@ concurrency: jobs: triage: - uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0 + uses: __REUSABLE_WORKFLOW__ with: event_type: ${{ inputs.event_type }} source_repo: ${{ inputs.source_repo }} event_payload: ${{ inputs.event_payload }} mint_url: ${{ vars.FULLSEND_MINT_URL }} gcp_region: ${{ vars.FULLSEND_GCP_REGION }} + install_mode: per-org fullsend_ai_ref: v0 secrets: FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} diff --git a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml index 73e75d7568..d8c36fbda5 100644 --- a/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml +++ b/internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml @@ -41,7 +41,7 @@ jobs: if: >- github.event_name != 'issue_comment' || github.event.comment.user.type != 'Bot' - uses: fullsend-ai/fullsend/.github/workflows/reusable-dispatch.yml@v0 + uses: __REUSABLE_DISPATCH__ with: event_action: ${{ github.event.action }} install_mode: per-repo diff --git a/internal/scaffold/installfiles.go b/internal/scaffold/installfiles.go new file mode 100644 index 0000000000..08dfa14859 --- /dev/null +++ b/internal/scaffold/installfiles.go @@ -0,0 +1,109 @@ +package scaffold + +import ( + "fmt" +) + +// InstallFile is a file to commit during install. +type InstallFile struct { + Path string + Content []byte + Mode string +} + +// CollectInstallFilesOptions controls which scaffold files are collected. +type CollectInstallFilesOptions struct { + RenderOptions + PathPrefix string +} + +// CollectInstallFiles gathers scaffold files for org or per-repo installation. +func CollectInstallFiles(opts CollectInstallFilesOptions) ([]InstallFile, error) { + var files []InstallFile + err := WalkFullsendRepo(func(path string, content []byte) error { + rendered, renderErr := RenderTemplate(path, content, opts.RenderOptions) + if renderErr != nil { + return fmt.Errorf("rendering %s: %w", path, renderErr) + } + files = append(files, InstallFile{ + Path: opts.PathPrefix + path, + Content: rendered, + Mode: FileMode(path), + }) + return nil + }) + if err != nil { + return nil, err + } + + for _, dir := range customizedDirsForPrefix(opts.PathPrefix) { + files = append(files, InstallFile{ + Path: dir + "/.gitkeep", + Content: []byte(""), + Mode: "100644", + }) + } + + return files, nil +} + +func customizedDirsForPrefix(prefix string) []string { + if prefix == ".fullsend/" { + return PerRepoCustomizedDirs() + } + return CustomizedDirs() +} + +// CollectPerRepoInstallFiles gathers files for per-repo installation. +func CollectPerRepoInstallFiles(vendored bool) ([]InstallFile, error) { + opts := RenderOptionsForInstall(vendored, true) + + shimRaw, err := PerRepoShimTemplate() + if err != nil { + return nil, fmt.Errorf("loading per-repo shim template: %w", err) + } + shimRendered, err := RenderTemplate("templates/shim-per-repo.yaml", shimRaw, opts) + if err != nil { + return nil, fmt.Errorf("rendering per-repo shim: %w", err) + } + + files := []InstallFile{{ + Path: ".github/workflows/fullsend.yaml", + Content: shimRendered, + Mode: "100644", + }} + + for _, dir := range PerRepoCustomizedDirs() { + files = append(files, InstallFile{ + Path: dir + "/.gitkeep", + Content: []byte(""), + Mode: "100644", + }) + } + + return files, nil +} + +// ManagedPaths returns install-managed relative paths for analyze/sync. +func ManagedPaths(vendored bool, pathPrefix string) ([]string, error) { + opts := CollectInstallFilesOptions{ + RenderOptions: RenderOptionsForInstall(vendored, pathPrefix != ""), + PathPrefix: pathPrefix, + } + files, err := CollectInstallFiles(opts) + if err != nil { + return nil, err + } + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + if vendored { + vendoredPaths, err := ManagedVendoredContentPaths(pathPrefix) + if err != nil { + return nil, err + } + paths = append(paths, vendoredPaths...) + } + return paths, nil +} diff --git a/internal/scaffold/render.go b/internal/scaffold/render.go new file mode 100644 index 0000000000..bd082ec210 --- /dev/null +++ b/internal/scaffold/render.go @@ -0,0 +1,86 @@ +package scaffold + +import ( + "fmt" + "regexp" + "strings" + + "github.com/fullsend-ai/fullsend/internal/config" +) + +// RenderOptions controls install-time substitution for shim and thin-caller templates. +type RenderOptions struct { + Vendored bool + PerRepo bool +} + +// RenderOptionsForInstall builds render options from the --vendor flag. +func RenderOptionsForInstall(vendored, perRepo bool) RenderOptions { + return RenderOptions{Vendored: vendored, PerRepo: perRepo} +} + +// RenderTemplate applies vendoring-aware substitutions to scaffold templates. +func RenderTemplate(path string, content []byte, opts RenderOptions) ([]byte, error) { + out := string(content) + + switch { + case isThinStageCaller(path): + stage, err := thinStageName(out) + if err != nil { + return nil, err + } + out = strings.ReplaceAll(out, "__REUSABLE_WORKFLOW__", reusableWorkflowUses(stage, opts)) + case path == "templates/shim-per-repo.yaml": + out = strings.ReplaceAll(out, "__REUSABLE_DISPATCH__", reusableDispatchUses(opts)) + } + + return []byte(out), nil +} + +func isThinStageCaller(path string) bool { + switch path { + case ".github/workflows/triage.yml", + ".github/workflows/code.yml", + ".github/workflows/review.yml", + ".github/workflows/fix.yml", + ".github/workflows/retro.yml", + ".github/workflows/prioritize.yml": + return true + default: + return false + } +} + +func thinStageName(content string) (string, error) { + for _, stage := range []string{"triage", "code", "review", "fix", "retro", "prioritize"} { + if strings.Contains(content, "# fullsend-stage: "+stage) { + return stage, nil + } + } + return "", fmt.Errorf("could not determine thin caller stage") +} + +func reusableWorkflowUses(stage string, opts RenderOptions) string { + if opts.Vendored { + if opts.PerRepo { + return "./.fullsend/.github/workflows/reusable-" + stage + ".yml" + } + return "./.github/workflows/reusable-" + stage + ".yml" + } + return config.DefaultUpstreamRepo + "/.github/workflows/reusable-" + stage + ".yml@" + config.DefaultUpstreamRef +} + +func reusableDispatchUses(opts RenderOptions) string { + if opts.Vendored { + return "./.fullsend/.github/workflows/reusable-dispatch.yml" + } + return config.DefaultUpstreamRepo + "/.github/workflows/reusable-dispatch.yml@" + config.DefaultUpstreamRef +} + +// RenderDispatchPerRepoStagePaths rewrites stage workflow paths for vendored +// per-repo installs where reusable-dispatch.yml lives under .fullsend/. +func RenderDispatchPerRepoStagePaths(content []byte) []byte { + return dispatchStageUses.ReplaceAll(content, []byte(`uses: ./.fullsend/.github/workflows/reusable-$1.yml`)) +} + +var dispatchStageUses = regexp.MustCompile(`uses: fullsend-ai/fullsend/\.github/workflows/reusable-([a-z-]+)\.yml@[^\s]+`) diff --git a/internal/scaffold/render_test.go b/internal/scaffold/render_test.go new file mode 100644 index 0000000000..1c4a9de311 --- /dev/null +++ b/internal/scaffold/render_test.go @@ -0,0 +1,120 @@ +package scaffold + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderThinCallerNotVendored(t *testing.T) { + raw, err := FullsendRepoFile(".github/workflows/triage.yml") + require.NoError(t, err) + + rendered, err := RenderTemplate(".github/workflows/triage.yml", raw, RenderOptions{ + Vendored: false, + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0") + assertFreeOfRenderPlaceholders(t, out) + assert.NotContains(t, out, "distribution_mode") + assert.NotContains(t, out, "fullsend_ai_repo:") +} + +func TestRenderThinCallerVendoredPerOrg(t *testing.T) { + raw, err := FullsendRepoFile(".github/workflows/triage.yml") + require.NoError(t, err) + + rendered, err := RenderTemplate(".github/workflows/triage.yml", raw, RenderOptions{ + Vendored: true, + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "uses: ./.github/workflows/reusable-triage.yml") + assertFreeOfRenderPlaceholders(t, out) + assert.NotContains(t, out, "distribution_mode") + assert.Contains(t, out, "install_mode: per-org") +} + +func TestRenderPerRepoShimVendored(t *testing.T) { + raw, err := PerRepoShimTemplate() + require.NoError(t, err) + + rendered, err := RenderTemplate("templates/shim-per-repo.yaml", raw, RenderOptions{ + Vendored: true, + PerRepo: true, + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "uses: ./.fullsend/.github/workflows/reusable-dispatch.yml") + assert.NotContains(t, out, "distribution_mode") +} + +func TestRenderPrioritizeThinCallerVendored(t *testing.T) { + raw, err := FullsendRepoFile(".github/workflows/prioritize.yml") + require.NoError(t, err) + + rendered, err := RenderTemplate(".github/workflows/prioritize.yml", raw, RenderOptions{ + Vendored: true, + }) + require.NoError(t, err) + out := string(rendered) + assert.Contains(t, out, "uses: ./.github/workflows/reusable-prioritize.yml") + assert.NotContains(t, out, "distribution_mode") + assert.Contains(t, out, "project_number: ${{ vars.FULLSEND_PROJECT_NUMBER }}") +} + +func TestWalkUpstreamIncludesReusableWorkflows(t *testing.T) { + var paths []string + err := WalkUpstream(func(path string, _ []byte) error { + paths = append(paths, path) + return nil + }) + require.NoError(t, err) + + for _, want := range []string{ + ".github/workflows/reusable-triage.yml", + ".github/workflows/reusable-prioritize.yml", + ".github/workflows/reusable-dispatch.yml", + ".github/actions/mint-token/action.yml", + "action.yml", + } { + assert.Contains(t, paths, want) + } +} + +func TestRenderDispatchPerRepoStagePaths(t *testing.T) { + var raw []byte + err := WalkUpstream(func(path string, content []byte) error { + if path == ".github/workflows/reusable-dispatch.yml" { + raw = content + } + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, raw) + + rendered := RenderDispatchPerRepoStagePaths(raw) + assert.Contains(t, string(rendered), "uses: ./.fullsend/.github/workflows/reusable-triage.yml") + assert.Contains(t, string(rendered), "uses: ./.fullsend/.github/workflows/reusable-prioritize.yml") + assert.NotContains(t, string(rendered), "uses: fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0") +} + +func assertFreeOfRenderPlaceholders(t *testing.T, out string) { + t.Helper() + for _, placeholder := range []string{ + "__REUSABLE_WORKFLOW__", + "__REUSABLE_DISPATCH__", + "__UPSTREAM_REF__", + "__DISTRIBUTION_MODE__", + } { + assert.NotContains(t, out, placeholder) + } +} + +func TestRenderDispatchPerRepoStagePathsIgnoresOtherRepos(t *testing.T) { + input := []byte("uses: evil-org/evil-repo/.github/workflows/reusable-triage.yml@v0\n") + rendered := RenderDispatchPerRepoStagePaths(input) + assert.Equal(t, string(input), string(rendered)) +} diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 4d35374b2b..75dd4cd6cc 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -131,6 +131,46 @@ func PerRepoCustomizedDirs() []string { return dirs } +// IsLayeredPath reports whether path is in a layered content directory. +func IsLayeredPath(path string) bool { + for _, prefix := range layeredDirs { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +// IsUpstreamOnlyPath reports whether path is upstream-only infrastructure. +func IsUpstreamOnlyPath(path string) bool { + for _, prefix := range upstreamOnlyDirs { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +// WalkLayeredContent calls fn for layered directories and .github/scripts from fullsend-repo. +func WalkLayeredContent(fn func(path string, content []byte) error) error { + return WalkFullsendRepoAll(func(path string, data []byte) error { + if !IsLayeredPath(path) && path != ".github/scripts/setup-agent-env.sh" { + return nil + } + return fn(path, data) + }) +} + +// WalkUpstream calls fn for upstream assets from the current module checkout. +// Used by tests; install-time vendoring reads from ResolveVendorRoot instead. +func WalkUpstream(fn func(path string, content []byte) error) error { + root, err := moduleRootFromScaffold() + if err != nil { + return err + } + return walkVendoredUpstreamFromRoot(root, fn) +} + func walkFullsendRepo(fn func(path string, content []byte) error, filter bool) error { return fs.WalkDir(content, "fullsend-repo", func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index a8568ae2d9..d2319c7361 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -351,7 +351,8 @@ func TestTriageWorkflowContent(t *testing.T) { assert.Contains(t, s, "event_type") assert.Contains(t, s, "source_repo") assert.Contains(t, s, "event_payload") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-triage.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.NotContains(t, s, "secrets: inherit") assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") @@ -390,7 +391,8 @@ func TestCodeWorkflowContent(t *testing.T) { s := string(content) assert.Contains(t, s, "# fullsend-stage: code") assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-code.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.NotContains(t, s, "secrets: inherit") assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") @@ -415,7 +417,8 @@ func TestReviewWorkflowContent(t *testing.T) { s := string(content) assert.Contains(t, s, "# fullsend-stage: review") assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-review.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.NotContains(t, s, "secrets: inherit") assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") @@ -439,7 +442,8 @@ func TestFixWorkflowContent(t *testing.T) { assert.Contains(t, s, "# fullsend-stage: fix") assert.Contains(t, s, "workflow_dispatch") assert.Contains(t, s, "trigger_source") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-fix.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.NotContains(t, s, "secrets: inherit") assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") @@ -463,7 +467,8 @@ func TestRetroWorkflowContent(t *testing.T) { s := string(content) assert.Contains(t, s, "# fullsend-stage: retro") assert.Contains(t, s, "workflow_dispatch") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-retro.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.NotContains(t, s, "secrets: inherit") assert.Contains(t, s, "FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}") @@ -723,7 +728,8 @@ func TestPrioritizeWorkflowContent(t *testing.T) { assert.Contains(t, s, "event_type") assert.Contains(t, s, "source_repo") assert.Contains(t, s, "event_payload") - assert.Contains(t, s, "fullsend-ai/fullsend/.github/workflows/reusable-prioritize.yml@v0") + assert.Contains(t, s, "__REUSABLE_WORKFLOW__") + assert.NotContains(t, s, "distribution_mode") assert.Contains(t, s, "FULLSEND_MINT_URL") assert.Contains(t, s, "FULLSEND_PROJECT_NUMBER") assert.NotContains(t, s, "secrets: inherit") @@ -732,7 +738,6 @@ func TestPrioritizeWorkflowContent(t *testing.T) { assert.Contains(t, s, "concurrency:") assert.Contains(t, s, "fullsend-prioritize-") assert.Contains(t, s, "cancel-in-progress: true") - // Permissions required by the reusable workflow assert.Contains(t, s, "permissions:") assert.Contains(t, s, "actions: write") assert.Contains(t, s, "id-token: write") @@ -762,7 +767,6 @@ func TestPrioritizeSchedulerWorkflowContent(t *testing.T) { assert.Contains(t, s, "id-token: write") assert.NotContains(t, s, "create-github-app-token") assert.NotContains(t, s, "FULLSEND_FULLSEND_CLIENT_ID") - assert.NotContains(t, s, "./.github/actions/") } func TestPrioritizeSchedulerSkipsWhenProjectNumberUnset(t *testing.T) { diff --git a/internal/scaffold/vendorcontent.go b/internal/scaffold/vendorcontent.go new file mode 100644 index 0000000000..604ac3f97f --- /dev/null +++ b/internal/scaffold/vendorcontent.go @@ -0,0 +1,228 @@ +package scaffold + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const defaultsVendoredPrefix = ".defaults/" + +// CollectVendoredAssets gathers files for --vendor installs. +// Upstream mirror content lives under .defaults/ (same layout as runtime sparse checkout). +// Reusable workflows are written under workflowPrefix (.fullsend/ for per-repo, "" for per-org). +func CollectVendoredAssets(root, workflowPrefix string) ([]InstallFile, error) { + var files []InstallFile + + if err := walkVendoredUpstreamFromRoot(root, func(path string, content []byte) error { + if isVendoredReusableWorkflow(path) { + rendered := content + if path == ".github/workflows/reusable-dispatch.yml" && workflowPrefix == ".fullsend/" { + rendered = RenderDispatchPerRepoStagePaths(content) + } + files = append(files, InstallFile{ + Path: workflowPrefix + path, + Content: rendered, + Mode: "100644", + }) + } + if isVendoredDefaultsInfra(path) { + files = append(files, InstallFile{ + Path: defaultsVendoredPrefix + path, + Content: content, + Mode: vendoredInfraFileMode(path), + }) + } + return nil + }); err != nil { + return nil, err + } + + layeredRoot := filepath.Join(root, "internal", "scaffold", "fullsend-repo") + if err := walkLayeredFromRoot(layeredRoot, func(path string, content []byte) error { + files = append(files, InstallFile{ + Path: defaultsVendoredPrefix + "internal/scaffold/fullsend-repo/" + path, + Content: content, + Mode: FileMode(path), + }) + return nil + }); err != nil { + return nil, err + } + + return files, nil +} + +// ManagedVendoredContentPaths returns install-managed paths written when --vendor is set. +func ManagedVendoredContentPaths(workflowPrefix string) ([]string, error) { + root, err := sourceRootForManagedPaths() + if err != nil { + return nil, err + } + files, err := CollectVendoredAssets(root, workflowPrefix) + if err != nil { + return nil, err + } + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + return paths, nil +} + +// LegacyFlatVendoredPaths lists pre-.defaults flat layout paths to remove on re-install. +func LegacyFlatVendoredPaths(workflowPrefix string) ([]string, error) { + root, err := sourceRootForManagedPaths() + if err != nil { + return nil, err + } + return legacyFlatVendoredPathsFromRoot(root, workflowPrefix) +} + +func legacyFlatVendoredPathsFromRoot(root, workflowPrefix string) ([]string, error) { + var paths []string + add := func(p string) { paths = append(paths, p) } + + if err := walkVendoredUpstreamFromRoot(root, func(path string, _ []byte) error { + if isVendoredReusableWorkflow(path) { + add(workflowPrefix + path) + } + if isVendoredDefaultsInfra(path) { + add(path) // was at repo root, e.g. action.yml + } + return nil + }); err != nil { + return nil, err + } + + layeredRoot := filepath.Join(root, "internal", "scaffold", "fullsend-repo") + if err := walkLayeredFromRoot(layeredRoot, func(path string, _ []byte) error { + add(path) // was flat at repo root, e.g. agents/triage.md + return nil + }); err != nil { + return nil, err + } + + if workflowPrefix != "" { + add(workflowPrefix + "action.yml") + } + + return paths, nil +} + +func sourceRootForManagedPaths() (string, error) { + if root, err := moduleRootFromScaffold(); err == nil { + return root, nil + } + return "", fmt.Errorf("cannot enumerate vendored paths outside a fullsend checkout") +} + +func moduleRootFromScaffold() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + dir := wd + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "cmd", "fullsend")); err == nil { + return dir, nil + } + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("not in module") + } + dir = parent + } +} + +func walkVendoredUpstreamFromRoot(root string, fn func(path string, content []byte) error) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !isVendoredReusableWorkflow(rel) && !isVendoredDefaultsInfra(rel) { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return fmt.Errorf("reading %s: %w", rel, readErr) + } + return fn(rel, data) + }) +} + +func walkLayeredFromRoot(layeredRoot string, fn func(path string, content []byte) error) error { + info, err := os.Stat(layeredRoot) + if err != nil { + return fmt.Errorf("layered content root %s: %w", layeredRoot, err) + } + if !info.IsDir() { + return fmt.Errorf("layered content root %s is not a directory", layeredRoot) + } + return filepath.WalkDir(layeredRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(layeredRoot, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !IsLayeredPath(rel) && rel != ".github/scripts/setup-agent-env.sh" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return fmt.Errorf("reading %s: %w", rel, readErr) + } + return fn(rel, data) + }) +} + +func isVendoredReusableWorkflow(path string) bool { + if !strings.HasPrefix(path, ".github/workflows/") { + return false + } + base := path[strings.LastIndex(path, "/")+1:] + return strings.HasPrefix(base, "reusable-") && strings.HasSuffix(base, ".yml") +} + +func isVendoredDefaultsInfra(path string) bool { + if path == "action.yml" { + return true + } + if strings.HasPrefix(path, ".github/actions/") { + return true + } + if strings.HasPrefix(path, ".github/scripts/") && path != ".github/scripts/prepare-agent-workspace.sh" { + return true + } + return false +} + +func vendoredInfraFileMode(path string) string { + if strings.HasPrefix(path, ".github/scripts/") { + return "100755" + } + return "100644" +} + +// VendoredMarkerPath returns the path used to detect a vendored install. +func VendoredMarkerPath() string { + return defaultsVendoredPrefix + "action.yml" +} diff --git a/internal/scaffold/vendorcontent_test.go b/internal/scaffold/vendorcontent_test.go new file mode 100644 index 0000000000..28f88b3758 --- /dev/null +++ b/internal/scaffold/vendorcontent_test.go @@ -0,0 +1,33 @@ +package scaffold + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectVendoredAssetsUsesDefaultsMirror(t *testing.T) { + root, err := moduleRootFromScaffold() + require.NoError(t, err) + + files, err := CollectVendoredAssets(root, "") + require.NoError(t, err) + + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, ".defaults/.github/actions/mint-token/action.yml") + assert.Contains(t, paths, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") + assert.NotContains(t, paths, "action.yml") + assert.NotContains(t, paths, "agents/triage.md") + assert.NotContains(t, paths, ".defaults/.github/workflows/reusable-triage.yml") +} + +func TestVendoredMarkerPath(t *testing.T) { + assert.Equal(t, ".defaults/action.yml", VendoredMarkerPath()) +} diff --git a/internal/scaffold/workflow_call_alignment_test.go b/internal/scaffold/workflow_call_alignment_test.go index 110300beec..0379396e72 100644 --- a/internal/scaffold/workflow_call_alignment_test.go +++ b/internal/scaffold/workflow_call_alignment_test.go @@ -56,6 +56,17 @@ type callerPair struct { jobName string // job key in the caller workflow } +func loadRenderedScaffoldCaller(path string) func(t *testing.T) []byte { + return func(t *testing.T) []byte { + t.Helper() + raw, err := FullsendRepoFile(path) + require.NoError(t, err) + rendered, err := RenderTemplate(path, raw, RenderOptionsForInstall(false, false)) + require.NoError(t, err) + return rendered + } +} + func loadScaffoldFile(path string) func(t *testing.T) []byte { return func(t *testing.T) []byte { t.Helper() @@ -80,12 +91,12 @@ func loadRepoFile(relPath string) func(t *testing.T) []byte { func TestWorkflowCallInputAlignment(t *testing.T) { // All thin callers in the scaffold that reference reusable workflows. pairs := []callerPair{ - {"scaffold/triage.yml", loadScaffoldFile(".github/workflows/triage.yml"), "triage"}, - {"scaffold/code.yml", loadScaffoldFile(".github/workflows/code.yml"), "code"}, - {"scaffold/review.yml", loadScaffoldFile(".github/workflows/review.yml"), "review"}, - {"scaffold/fix.yml", loadScaffoldFile(".github/workflows/fix.yml"), "fix"}, - {"scaffold/retro.yml", loadScaffoldFile(".github/workflows/retro.yml"), "retro"}, - {"scaffold/prioritize.yml", loadScaffoldFile(".github/workflows/prioritize.yml"), "prioritize"}, + {"scaffold/triage.yml", loadRenderedScaffoldCaller(".github/workflows/triage.yml"), "triage"}, + {"scaffold/code.yml", loadRenderedScaffoldCaller(".github/workflows/code.yml"), "code"}, + {"scaffold/review.yml", loadRenderedScaffoldCaller(".github/workflows/review.yml"), "review"}, + {"scaffold/fix.yml", loadRenderedScaffoldCaller(".github/workflows/fix.yml"), "fix"}, + {"scaffold/retro.yml", loadRenderedScaffoldCaller(".github/workflows/retro.yml"), "retro"}, + {"scaffold/prioritize.yml", loadRenderedScaffoldCaller(".github/workflows/prioritize.yml"), "prioritize"}, } // Also validate reusable-dispatch.yml's stage jobs. From 0a0561bce21e22455c39eba2145c8cf5a1313fd4 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 10 Jun 2026 19:01:14 +0300 Subject: [PATCH 02/30] feat(vendor): add manifest-driven cleanup and split analyze reporting Write vendor-manifest.yaml on --vendor installs so cleanup and analyze work without a local fullsend checkout. Workflows analyze stays embed-only; vendor layer reports presence, manifest alignment, and optional source alignment via admin analyze --fullsend-source. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...0046-vendored-installs-with-vendor-flag.md | 29 ++ internal/cli/admin.go | 21 +- internal/cli/admin_test.go | 3 +- internal/cli/github.go | 4 +- internal/cli/vendor.go | 60 ++--- internal/layers/vendorbinary.go | 193 +++++++++---- internal/layers/vendorbinary_test.go | 59 +++- internal/layers/workflows.go | 9 +- internal/layers/workflows_test.go | 36 ++- internal/scaffold/installfiles.go | 14 +- internal/scaffold/vendorcontent.go | 62 +---- internal/scaffold/vendorcontent_test.go | 33 --- internal/scaffold/vendormanifest.go | 254 ++++++++++++++++++ internal/scaffold/vendormanifest_test.go | 131 +++++++++ 14 files changed, 703 insertions(+), 205 deletions(-) delete mode 100644 internal/scaffold/vendorcontent_test.go create mode 100644 internal/scaffold/vendormanifest.go create mode 100644 internal/scaffold/vendormanifest_test.go diff --git a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md index 93d3cd0949..2be6c00e60 100644 --- a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md +++ b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md @@ -48,6 +48,35 @@ Source resolution (shared by binary and content) in `internal/binary`: Without `--vendor`, install removes stale vendored binary and content paths and renders thin callers with upstream `uses: fullsend-ai/fullsend/.../reusable-*.yml@v0`. +### Vendor manifest + +`--vendor` writes `vendor-manifest.yaml` listing every vendored path plus +`binary_path`: + +| Install mode | Manifest path | +|--------------|---------------| +| Per-org (`.fullsend` config repo) | `vendor-manifest.yaml` | +| Per-repo | `.fullsend/vendor-manifest.yaml` | + +The manifest is committed in the same batch as vendored content. Cleanup when +`--vendor` is off reads the manifest from the target repo (via forge API) and +deletes listed paths — no local fullsend checkout required. Legacy installs +without a manifest fall back to embed-derived path enumeration. + +### Analyze behavior + +Scaffold and vendored assets are reported separately: + +- **Workflows layer** — always checks embed-derived managed paths + (`ManagedPaths(false)`): thin callers, shim, `customized/` gitkeeps, and + `CODEOWNERS`. Vendored marker presence does not expand this list. +- **Vendor layer** — reports vendored binary/marker presence, manifest + alignment (missing paths, legacy installs without manifest), and optional + source alignment when `--fullsend-source` is passed to `fullsend admin analyze` + (or when the CLI version can resolve a source tree). + +Vendored misalignment surfaces under the **vendor** layer, not workflows. + ### Runtime: file-presence detection Reusable workflows detect vendored installs before sparse checkout: diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 62a5264406..91b9eabd2a 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1096,6 +1096,7 @@ func newUninstallCmd() *cobra.Command { } func newAnalyzeCmd() *cobra.Command { + var analyzeFullsendSource string cmd := &cobra.Command{ Use: "analyze ", Short: "Analyze fullsend installation status", @@ -1121,9 +1122,10 @@ func newAnalyzeCmd() *cobra.Command { printer.Header("Analyzing fullsend installation for " + org) printer.Blank() - return runAnalyze(ctx, client, printer, org) + return runAnalyze(ctx, client, printer, org, analyzeFullsendSource) }, } + cmd.Flags().StringVar(&analyzeFullsendSource, "fullsend-source", "", "fullsend source checkout for vendored alignment reporting (default: auto-detect or GitHub fetch)") return cmd } @@ -1191,7 +1193,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } else { dispatcher = gcf.NewProvisioner(gcf.Config{}, nil) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), "", dispatcher) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1544,7 +1546,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o }, gcf.NewLiveGCFClient(mintProject)) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), disp) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), "", disp) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1753,7 +1755,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, } // runAnalyze assesses the current installation state. -func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, org string) error { +func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, org, analyzeFullsendSource string) error { allRepos, err := client.ListOrgRepos(ctx, org) if err != nil { return fmt.Errorf("listing org repos: %w", err) @@ -1789,7 +1791,7 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o } dispatcher := gcf.NewProvisioner(gcf.Config{}, nil) - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, analyzeFullsendSource, dispatcher) if err := runPreflight(ctx, stack, layers.OpAnalyze, client, printer); err != nil { return err @@ -1800,6 +1802,12 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o } // buildLayerStack creates the ordered layer stack. +func newVendorLayer(org string, client forge.Client, printer *ui.Printer, vendor bool, vendorFn layers.VendorFunc, analyzeFullsendSource string) *layers.VendorBinaryLayer { + layer := layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendor, vendorFn) + layer.SetAnalyzeOptions(analyzeFullsendSource, version) + return layer +} + func buildLayerStack( org string, client forge.Client, @@ -1813,6 +1821,7 @@ func buildLayerStack( inferenceProvider inference.Provider, vendor bool, vendorFn layers.VendorFunc, + analyzeFullsendSource string, dispatcher dispatch.Dispatcher, ) *layers.Stack { dispatchLayer := layers.NewOIDCDispatchLayer(org, client, enrolledRepoIDs, dispatcher, printer) @@ -1830,7 +1839,7 @@ func buildLayerStack( return layers.NewStack( layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo), layers.NewWorkflowsLayer(org, client, printer, user, version, vendor), - layers.NewVendorBinaryLayer(org, forge.ConfigRepoName, client, printer, vendor, vendorFn), + newVendorLayer(org, client, printer, vendor, vendorFn, analyzeFullsendSource), layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(), layers.NewInferenceLayer(org, client, inferenceProvider, printer), dispatchLayer, diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 2efcb3da08..e435e964fd 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1099,6 +1099,7 @@ func TestBuildLayerStack_NilEnabledRepos_SkipsDisabledRepos(t *testing.T) { nil, // inferenceProvider false, // vendorBinary nil, // vendorFn + "", // analyzeFullsendSource nil, // dispatcher ) @@ -1133,7 +1134,7 @@ func TestBuildLayerStack_EmptyEnabledRepos_IncludesDisabledRepos(t *testing.T) { "test-org", nil, cfg, printer, "user", false, []string{}, // explicitly empty (not nil) - nil, nil, nil, false, nil, nil, + nil, nil, nil, false, nil, "", nil, ) // The enrollment layer should have disabled repos to reconcile. diff --git a/internal/cli/github.go b/internal/cli/github.go index ef323c311d..c7bc8e75f6 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -472,7 +472,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. vendorFn = makeVendorFunc(cfg.fullsendBinary, cfg.fullsendSource) } - stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, dispatcher) + stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, "", dispatcher) if cfg.dryRun { printer.Header("Dry run — analyzing what setup would do") @@ -508,7 +508,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) orgCfg.Dispatch.Mode = "oidc-mint" - stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, dispatcher) + stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, "", dispatcher) } if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index ec6f61f15d..3d06968fcf 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -112,6 +112,12 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return fmt.Errorf("collecting vendored content: %w", err) } + manifest := scaffold.NewVendorManifest(version, fullsendSource, destPath, scaffold.PathsFromInstallFiles(assets)) + manifestYAML, err := manifest.MarshalYAML() + if err != nil { + return fmt.Errorf("building vendor manifest: %w", err) + } + var files []forge.TreeFile for _, f := range assets { files = append(files, forge.TreeFile{ @@ -120,8 +126,13 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin Mode: f.Mode, }) } + files = append(files, forge.TreeFile{ + Path: scaffold.VendorManifestPath(pathPrefix), + Content: manifestYAML, + Mode: "100644", + }) - printer.StepStart(fmt.Sprintf("Uploading %d vendored content files", len(files))) + printer.StepStart(fmt.Sprintf("Uploading %d vendored content files", len(assets))) contentMsg := layers.VendorContentCommitMessage(version, pathPrefix, len(files)) committed, err := client.CommitFiles(ctx, owner, repo, contentMsg, files) if err != nil { @@ -147,21 +158,12 @@ func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer if perRepo { destPath = layers.VendoredBinaryPathPerRepo } - if err := removeStaleVendoredBinary(ctx, client, printer, owner, repo, destPath); err != nil { - return err - } - paths, err := scaffold.ManagedVendoredContentPaths(pathPrefix) + paths, err := scaffold.ResolveVendoredCleanupPaths(ctx, client, owner, repo, pathPrefix, destPath) if err != nil { - return fmt.Errorf("enumerating vendored content paths: %w", err) + return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - legacy, err := scaffold.LegacyFlatVendoredPaths(pathPrefix) - if err != nil { - return fmt.Errorf("enumerating legacy vendored paths: %w", err) - } - paths = append(paths, legacy...) - var removed int for _, path := range paths { _, err := client.GetFileContent(ctx, owner, repo, path) @@ -171,35 +173,29 @@ func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer } return fmt.Errorf("checking for vendored content at %s: %w", path, err) } + if path == destPath { + printer.StepStart("removing stale vendored binary") + } else { + printer.StepStart("removing stale vendored content") + } deleteMsg := layers.RemoveStaleContentCommitMessage(path) + if path == destPath { + deleteMsg = layers.RemoveStaleBinaryCommitMessage(path) + } if err := client.DeleteFile(ctx, owner, repo, path, deleteMsg); err != nil { + if path == destPath { + printer.StepFail("failed to remove vendored binary") + } else { + printer.StepFail("failed to remove vendored content") + } return fmt.Errorf("deleting vendored content at %s: %w", path, err) } removed++ } if removed > 0 { - printer.StepDone(fmt.Sprintf("Removed %d stale vendored content files", removed)) - } - return nil -} - -func removeStaleVendoredBinary(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, destPath string) error { - _, err := client.GetFileContent(ctx, owner, repo, destPath) - if err != nil { - if forge.IsNotFound(err) { - return nil - } - return fmt.Errorf("checking for vendored binary: %w", err) - } - - printer.StepStart("removing stale vendored binary") - deleteMsg := layers.RemoveStaleBinaryCommitMessage(destPath) - if err := client.DeleteFile(ctx, owner, repo, destPath, deleteMsg); err != nil { - printer.StepFail("failed to remove vendored binary") - return fmt.Errorf("deleting vendored binary: %w", err) + printer.StepDone(fmt.Sprintf("Removed %d stale vendored files", removed)) } - printer.StepDone("removed stale vendored binary") return nil } diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index b8e138fc00..16156a319e 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -3,7 +3,9 @@ package layers import ( "context" "fmt" + "strings" + "github.com/fullsend-ai/fullsend/internal/binary" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" @@ -17,12 +19,14 @@ type VendorFunc func(ctx context.Context, client forge.Client, printer *ui.Print // When enabled (--vendor), it calls VendorFunc to upload binary and content. // When disabled, it removes stale vendored assets from prior installs. type VendorBinaryLayer struct { - org string - repo string - client forge.Client - ui *ui.Printer - enabled bool - vendorFn VendorFunc + org string + repo string + client forge.Client + ui *ui.Printer + enabled bool + vendorFn VendorFunc + analyzeFullsendSource string + cliVersion string } // Compile-time check that VendorBinaryLayer implements Layer. @@ -40,6 +44,12 @@ func NewVendorBinaryLayer(org, repo string, client forge.Client, printer *ui.Pri } } +// SetAnalyzeOptions configures optional source-tree alignment during Analyze. +func (l *VendorBinaryLayer) SetAnalyzeOptions(fullsendSource, cliVersion string) { + l.analyzeFullsendSource = fullsendSource + l.cliVersion = cliVersion +} + func (l *VendorBinaryLayer) Name() string { return "vendor" } func (l *VendorBinaryLayer) binaryPath() string { @@ -49,6 +59,13 @@ func (l *VendorBinaryLayer) binaryPath() string { return VendoredBinaryPath } +func (l *VendorBinaryLayer) workflowPrefix() string { + if l.perRepo() { + return ".fullsend/" + } + return "" +} + func (l *VendorBinaryLayer) perRepo() bool { return l.repo != forge.ConfigRepoName } @@ -72,34 +89,10 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return l.vendorFn(ctx, l.client, l.ui, l.org, l.repo) } - path := l.binaryPath() - _, err := l.client.GetFileContent(ctx, l.org, l.repo, path) - if err != nil && !forge.IsNotFound(err) { - return fmt.Errorf("checking for vendored binary: %w", err) - } - if err == nil { - l.ui.StepStart("removing stale vendored binary") - deleteMsg := RemoveStaleBinaryCommitMessage(path) - if err := l.client.DeleteFile(ctx, l.org, l.repo, path, deleteMsg); err != nil { - l.ui.StepFail("failed to remove vendored binary") - return fmt.Errorf("deleting vendored binary: %w", err) - } - l.ui.StepDone("removed stale vendored binary") - } - - pathPrefix := "" - if l.perRepo() { - pathPrefix = ".fullsend/" - } - paths, err := scaffold.ManagedVendoredContentPaths(pathPrefix) + paths, err := scaffold.ResolveVendoredCleanupPaths(ctx, l.client, l.org, l.repo, l.workflowPrefix(), l.binaryPath()) if err != nil { - return fmt.Errorf("enumerating vendored content paths: %w", err) + return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - legacy, err := scaffold.LegacyFlatVendoredPaths(pathPrefix) - if err != nil { - return fmt.Errorf("enumerating legacy vendored paths: %w", err) - } - paths = append(paths, legacy...) var removed int for _, p := range paths { @@ -112,14 +105,21 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { } l.ui.StepStart("removing stale vendored content") deleteMsg := RemoveStaleContentCommitMessage(p) + if p == l.binaryPath() { + deleteMsg = RemoveStaleBinaryCommitMessage(p) + } if err := l.client.DeleteFile(ctx, l.org, l.repo, p, deleteMsg); err != nil { + if p == l.binaryPath() { + l.ui.StepFail("failed to remove vendored binary") + return fmt.Errorf("deleting vendored binary: %w", err) + } l.ui.StepFail("failed to remove vendored content") return fmt.Errorf("deleting vendored content at %s: %w", p, err) } removed++ } if removed > 0 { - l.ui.StepDone(fmt.Sprintf("removed %d stale vendored content files", removed)) + l.ui.StepDone(fmt.Sprintf("removed %d stale vendored files", removed)) } return nil } @@ -130,7 +130,6 @@ func (l *VendorBinaryLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} marker := scaffold.VendoredMarkerPath() - _, markerErr := l.client.GetFileContent(ctx, l.org, l.repo, marker) if markerErr != nil && !forge.IsNotFound(markerErr) { return nil, fmt.Errorf("checking vendored marker at %s: %w", marker, markerErr) @@ -143,34 +142,138 @@ func (l *VendorBinaryLayer) Analyze(ctx context.Context) (*LayerReport, error) { } hasBinary := binErr == nil + hasVendoredAssets := hasMarker || hasBinary + + if hasBinary { + report.Details = append(report.Details, fmt.Sprintf("vendored binary present at %s", l.binaryPath())) + } else { + report.Details = append(report.Details, "vendored binary absent") + } + if hasMarker { + report.Details = append(report.Details, "vendored content marker present") + } else { + report.Details = append(report.Details, "vendored content marker absent") + } + + manifestMisaligned := false + manifest, manifestFound, err := scaffold.ReadVendorManifest(ctx, l.client, l.org, l.repo, l.workflowPrefix()) + if err != nil { + return nil, err + } + if manifestFound { + report.Details = append(report.Details, fmt.Sprintf("vendor manifest present at %s", scaffold.VendorManifestPath(l.workflowPrefix()))) + missing, err := scaffold.ComparePathPresence(ctx, l.client, l.org, l.repo, manifest.Paths) + if err != nil { + return nil, err + } + if len(missing) > 0 { + manifestMisaligned = true + report.Details = append(report.Details, fmt.Sprintf("manifest alignment: %d missing path(s)", len(missing))) + for _, p := range missing { + report.WouldFix = append(report.WouldFix, "restore vendored path "+p) + } + } else { + report.Details = append(report.Details, "manifest alignment: ok") + } + if hasBinary || manifest.BinaryPath != "" { + _, err := l.client.GetFileContent(ctx, l.org, l.repo, manifest.BinaryPath) + if err != nil { + if forge.IsNotFound(err) { + manifestMisaligned = true + report.Details = append(report.Details, "manifest binary_path missing in repo") + report.WouldFix = append(report.WouldFix, "restore vendored binary at "+manifest.BinaryPath) + } else { + return nil, fmt.Errorf("checking manifest binary_path: %w", err) + } + } + } + } else if hasVendoredAssets { + manifestMisaligned = true + report.Details = append(report.Details, "legacy vendored install (no manifest)") + report.WouldFix = append(report.WouldFix, "re-run install with --vendor to write vendor-manifest.yaml") + } else { + report.Details = append(report.Details, "vendor manifest absent") + } + + sourceMisaligned := false + if err := l.reportSourceAlignment(ctx, report, &sourceMisaligned); err != nil { + return nil, err + } + switch { case l.enabled: - if hasBinary || hasMarker { + if hasVendoredAssets && !manifestMisaligned && !sourceMisaligned { report.Status = StatusInstalled - if hasBinary { - report.Details = append(report.Details, fmt.Sprintf("vendored binary present at %s", l.binaryPath())) - } - if hasMarker { - report.Details = append(report.Details, "vendored content marker present") - } + } else if hasVendoredAssets { + report.Status = StatusDegraded } else { report.Status = StatusNotInstalled report.WouldInstall = append(report.WouldInstall, "upload vendored binary and content") } - case hasBinary || hasMarker: + case hasVendoredAssets: report.Status = StatusDegraded if hasBinary { - report.Details = append(report.Details, fmt.Sprintf("stale vendored binary at %s", l.binaryPath())) report.WouldFix = append(report.WouldFix, "delete vendored binary") } if hasMarker { - report.Details = append(report.Details, "stale vendored content present") report.WouldFix = append(report.WouldFix, "delete vendored content") } default: report.Status = StatusInstalled - report.Details = append(report.Details, "no vendored assets present") + if len(report.Details) == 0 { + report.Details = append(report.Details, "no vendored assets present") + } } return report, nil } + +func (l *VendorBinaryLayer) reportSourceAlignment(ctx context.Context, report *LayerReport, misaligned *bool) error { + if l.analyzeFullsendSource == "" && l.cliVersion == "" { + report.Details = append(report.Details, "source alignment: skipped (no source tree)") + return nil + } + + root, err := binary.ResolveVendorRoot(l.analyzeFullsendSource, l.cliVersion) + if err != nil { + report.Details = append(report.Details, "source alignment: skipped (no source tree)") + return nil + } + if root.Cleanup != nil { + defer root.Cleanup() + } + + expectedFiles, err := scaffold.CollectVendoredAssets(root.Path, l.workflowPrefix()) + if err != nil { + return fmt.Errorf("collecting source vendored paths: %w", err) + } + expected := scaffold.PathsFromInstallFiles(expectedFiles) + + missing, err := scaffold.ComparePathPresence(ctx, l.client, l.org, l.repo, expected) + if err != nil { + return err + } + if len(missing) == 0 { + report.Details = append(report.Details, "source alignment: ok") + return nil + } + + *misaligned = true + report.Details = append(report.Details, fmt.Sprintf("source alignment: %d missing path(s)", len(missing))) + for _, p := range missing { + if !containsWouldFix(report.WouldFix, p) { + report.WouldFix = append(report.WouldFix, "sync vendored path "+p) + } + } + return nil +} + +func containsWouldFix(fixes []string, path string) bool { + suffix := path + for _, f := range fixes { + if strings.HasSuffix(f, suffix) { + return true + } + } + return false +} diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index 4ddd0e2d4a..dab448cbf1 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -145,8 +146,9 @@ func TestVendorBinaryLayer_Analyze_EnabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, "vendor", report.Name) - assert.Equal(t, StatusInstalled, report.Status) + assert.Equal(t, StatusDegraded, report.Status) assert.True(t, strings.Contains(strings.Join(report.Details, " "), "vendored binary present at")) + assert.True(t, strings.Contains(strings.Join(report.Details, " "), "legacy vendored install")) } func TestVendorBinaryLayer_Analyze_EnabledAbsent(t *testing.T) { @@ -172,7 +174,7 @@ func TestVendorBinaryLayer_Analyze_DisabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) - assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary at")) + assert.True(t, strings.Contains(strings.Join(report.Details, " "), "vendored binary present at")) assert.Contains(t, report.WouldFix, "delete vendored binary") } @@ -185,7 +187,54 @@ func TestVendorBinaryLayer_Analyze_DisabledAbsent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusInstalled, report.Status) - assert.Contains(t, report.Details, "no vendored assets present") + assert.Contains(t, report.Details, "vendored binary absent") +} + +func TestVendorBinaryLayer_Analyze_ManifestAligned(t *testing.T) { + manifest := scaffold.NewVendorManifest("0.4.0", "", "bin/fullsend", []string{ + ".defaults/action.yml", + ".github/workflows/reusable-triage.yml", + }) + manifestYAML, err := manifest.MarshalYAML() + require.NoError(t, err) + + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "test-org/.fullsend/bin/fullsend": []byte("binary-data"), + "test-org/.fullsend/.defaults/action.yml": []byte("marker"), + "test-org/.fullsend/.github/workflows/reusable-triage.yml": []byte("workflow"), + "test-org/.fullsend/vendor-manifest.yaml": manifestYAML, + }, + } + layer, _ := newVendorBinaryLayer(t, client, true, nil) + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusInstalled, report.Status) + assert.Contains(t, strings.Join(report.Details, " "), "manifest alignment: ok") +} + +func TestVendorBinaryLayer_Analyze_ManifestMissingPath(t *testing.T) { + manifest := scaffold.NewVendorManifest("0.4.0", "", "bin/fullsend", []string{ + ".defaults/action.yml", + ".github/workflows/reusable-triage.yml", + }) + manifestYAML, err := manifest.MarshalYAML() + require.NoError(t, err) + + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "test-org/.fullsend/bin/fullsend": []byte("binary-data"), + "test-org/.fullsend/.defaults/action.yml": []byte("marker"), + "test-org/.fullsend/vendor-manifest.yaml": manifestYAML, + }, + } + layer, _ := newVendorBinaryLayer(t, client, true, nil) + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusDegraded, report.Status) + assert.Contains(t, strings.Join(report.Details, " "), "manifest alignment: 1 missing path(s)") } func TestVendorBinaryLayer_Analyze_GetFileContentError(t *testing.T) { @@ -247,7 +296,7 @@ func TestVendorBinaryLayer_PerRepo_Analyze_EnabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) - assert.Equal(t, StatusInstalled, report.Status) + assert.Equal(t, StatusDegraded, report.Status) assert.True(t, strings.Contains(strings.Join(report.Details, " "), "vendored binary present at")) } @@ -264,7 +313,7 @@ func TestVendorBinaryLayer_PerRepo_Analyze_DisabledPresent(t *testing.T) { report, err := layer.Analyze(context.Background()) require.NoError(t, err) assert.Equal(t, StatusDegraded, report.Status) - assert.True(t, strings.Contains(strings.Join(report.Details, " "), "stale vendored binary at")) + assert.True(t, strings.Contains(strings.Join(report.Details, " "), "vendored binary present at")) } func TestVendorBinaryLayer_PerRepo_EnabledCallsVendorFn(t *testing.T) { diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 9c10ccb0e5..aaaf11f429 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -96,14 +96,7 @@ func (l *WorkflowsLayer) Uninstall(_ context.Context) error { return nil } func (l *WorkflowsLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} - vendored := l.vendored - if marker, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, scaffold.VendoredMarkerPath()); err == nil && len(marker) > 0 { - vendored = true - } else if !forge.IsNotFound(err) { - return nil, fmt.Errorf("checking vendored marker: %w", err) - } - - managed, err := scaffold.ManagedPaths(vendored, "") + managed, err := scaffold.ManagedPaths(false, "") if err != nil { return nil, err } diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index fa1db704e3..adec3d6cbf 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -195,6 +195,32 @@ func TestWorkflowsLayer_Analyze_NonePresent(t *testing.T) { assert.Len(t, report.WouldInstall, len(managed)+1) } +func TestWorkflowsLayer_Analyze_WithVendoredMarkerUsesEmbedOnly(t *testing.T) { + managed, err := scaffold.ManagedPaths(false, "") + require.NoError(t, err) + + fileContents := map[string][]byte{ + "test-org/.fullsend/CODEOWNERS": []byte("* @admin-user"), + "test-org/.fullsend/.defaults/action.yml": []byte("marker"), + "test-org/.fullsend/bin/fullsend": []byte("binary"), + "test-org/.fullsend/.github/workflows/reusable-triage.yml": []byte("reusable"), + } + for _, path := range managed { + fileContents["test-org/.fullsend/"+path] = []byte("content") + } + + client := &forge.FakeClient{FileContents: fileContents} + layer, _ := newWorkflowsLayer(t, client, true) + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + + assert.Equal(t, StatusInstalled, report.Status) + joined := strings.Join(report.Details, " ") + assert.NotContains(t, joined, ".defaults/action.yml") + assert.NotContains(t, joined, "reusable-triage.yml") +} + func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { client := &forge.FakeClient{ FileContents: map[string][]byte{ @@ -231,11 +257,11 @@ func TestManagedPathsMatchLayeredScaffold(t *testing.T) { } } -func TestManagedPathsVendoredIncludeContent(t *testing.T) { - managed, err := scaffold.ManagedPaths(true, "") +func TestManagedVendoredContentPathsFromEmbed(t *testing.T) { + paths, err := scaffold.ManagedVendoredContentPaths("") require.NoError(t, err) - assert.Contains(t, managed, ".github/workflows/reusable-triage.yml") - assert.Contains(t, managed, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") - assert.Contains(t, managed, scaffold.VendoredMarkerPath()) + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") + assert.Contains(t, paths, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") + assert.Contains(t, paths, scaffold.VendoredMarkerPath()) } diff --git a/internal/scaffold/installfiles.go b/internal/scaffold/installfiles.go index 08dfa14859..e46441a44a 100644 --- a/internal/scaffold/installfiles.go +++ b/internal/scaffold/installfiles.go @@ -84,10 +84,11 @@ func CollectPerRepoInstallFiles(vendored bool) ([]InstallFile, error) { return files, nil } -// ManagedPaths returns install-managed relative paths for analyze/sync. -func ManagedPaths(vendored bool, pathPrefix string) ([]string, error) { +// ManagedPaths returns embed-derived scaffold paths for analyze/sync. +// Vendored content is reported separately by the vendor layer. +func ManagedPaths(_ bool, pathPrefix string) ([]string, error) { opts := CollectInstallFilesOptions{ - RenderOptions: RenderOptionsForInstall(vendored, pathPrefix != ""), + RenderOptions: RenderOptionsForInstall(false, pathPrefix != ""), PathPrefix: pathPrefix, } files, err := CollectInstallFiles(opts) @@ -98,12 +99,5 @@ func ManagedPaths(vendored bool, pathPrefix string) ([]string, error) { for i, f := range files { paths[i] = f.Path } - if vendored { - vendoredPaths, err := ManagedVendoredContentPaths(pathPrefix) - if err != nil { - return nil, err - } - paths = append(paths, vendoredPaths...) - } return paths, nil } diff --git a/internal/scaffold/vendorcontent.go b/internal/scaffold/vendorcontent.go index 604ac3f97f..b6f3429cd9 100644 --- a/internal/scaffold/vendorcontent.go +++ b/internal/scaffold/vendorcontent.go @@ -55,68 +55,14 @@ func CollectVendoredAssets(root, workflowPrefix string) ([]InstallFile, error) { return files, nil } -// ManagedVendoredContentPaths returns install-managed paths written when --vendor is set. +// ManagedVendoredContentPaths returns embed-derived paths for the current vendor layout. func ManagedVendoredContentPaths(workflowPrefix string) ([]string, error) { - root, err := sourceRootForManagedPaths() - if err != nil { - return nil, err - } - files, err := CollectVendoredAssets(root, workflowPrefix) - if err != nil { - return nil, err - } - paths := make([]string, len(files)) - for i, f := range files { - paths[i] = f.Path - } - return paths, nil + return enumerateVendoredPaths(workflowPrefix) } -// LegacyFlatVendoredPaths lists pre-.defaults flat layout paths to remove on re-install. +// LegacyFlatVendoredPaths lists pre-.defaults flat layout paths for legacy cleanup. func LegacyFlatVendoredPaths(workflowPrefix string) ([]string, error) { - root, err := sourceRootForManagedPaths() - if err != nil { - return nil, err - } - return legacyFlatVendoredPathsFromRoot(root, workflowPrefix) -} - -func legacyFlatVendoredPathsFromRoot(root, workflowPrefix string) ([]string, error) { - var paths []string - add := func(p string) { paths = append(paths, p) } - - if err := walkVendoredUpstreamFromRoot(root, func(path string, _ []byte) error { - if isVendoredReusableWorkflow(path) { - add(workflowPrefix + path) - } - if isVendoredDefaultsInfra(path) { - add(path) // was at repo root, e.g. action.yml - } - return nil - }); err != nil { - return nil, err - } - - layeredRoot := filepath.Join(root, "internal", "scaffold", "fullsend-repo") - if err := walkLayeredFromRoot(layeredRoot, func(path string, _ []byte) error { - add(path) // was flat at repo root, e.g. agents/triage.md - return nil - }); err != nil { - return nil, err - } - - if workflowPrefix != "" { - add(workflowPrefix + "action.yml") - } - - return paths, nil -} - -func sourceRootForManagedPaths() (string, error) { - if root, err := moduleRootFromScaffold(); err == nil { - return root, nil - } - return "", fmt.Errorf("cannot enumerate vendored paths outside a fullsend checkout") + return enumerateLegacyFlatVendoredPaths(workflowPrefix) } func moduleRootFromScaffold() (string, error) { diff --git a/internal/scaffold/vendorcontent_test.go b/internal/scaffold/vendorcontent_test.go deleted file mode 100644 index 28f88b3758..0000000000 --- a/internal/scaffold/vendorcontent_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package scaffold - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCollectVendoredAssetsUsesDefaultsMirror(t *testing.T) { - root, err := moduleRootFromScaffold() - require.NoError(t, err) - - files, err := CollectVendoredAssets(root, "") - require.NoError(t, err) - - paths := make([]string, len(files)) - for i, f := range files { - paths[i] = f.Path - } - - assert.Contains(t, paths, ".defaults/action.yml") - assert.Contains(t, paths, ".defaults/.github/actions/mint-token/action.yml") - assert.Contains(t, paths, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") - assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") - assert.NotContains(t, paths, "action.yml") - assert.NotContains(t, paths, "agents/triage.md") - assert.NotContains(t, paths, ".defaults/.github/workflows/reusable-triage.yml") -} - -func TestVendoredMarkerPath(t *testing.T) { - assert.Equal(t, ".defaults/action.yml", VendoredMarkerPath()) -} diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go new file mode 100644 index 0000000000..0f26057312 --- /dev/null +++ b/internal/scaffold/vendormanifest.go @@ -0,0 +1,254 @@ +package scaffold + +import ( + "context" + "fmt" + "sort" + + "github.com/fullsend-ai/fullsend/internal/forge" + "gopkg.in/yaml.v3" +) + +const vendorManifestVersion = "1" + +// VendorManifest records paths written by a --vendor install for cleanup and analyze. +type VendorManifest struct { + Version string `yaml:"version"` + CLIVersion string `yaml:"cli_version,omitempty"` + SourceRef string `yaml:"source_ref,omitempty"` + BinaryPath string `yaml:"binary_path"` + Paths []string `yaml:"paths"` +} + +// VendorManifestPath returns the manifest path for the install mode. +func VendorManifestPath(workflowPrefix string) string { + if workflowPrefix == ".fullsend/" { + return ".fullsend/vendor-manifest.yaml" + } + return "vendor-manifest.yaml" +} + +// NewVendorManifest builds a manifest from install outputs. +func NewVendorManifest(cliVersion, sourceRef, binaryPath string, contentPaths []string) *VendorManifest { + paths := append([]string(nil), contentPaths...) + sort.Strings(paths) + return &VendorManifest{ + Version: vendorManifestVersion, + CLIVersion: cliVersion, + SourceRef: sourceRef, + BinaryPath: binaryPath, + Paths: paths, + } +} + +// MarshalYAML serializes the manifest. +func (m *VendorManifest) MarshalYAML() ([]byte, error) { + return yaml.Marshal(m) +} + +// ParseVendorManifest parses manifest YAML from the config repo. +func ParseVendorManifest(data []byte) (*VendorManifest, error) { + var m VendorManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing vendor manifest: %w", err) + } + if m.Version == "" { + return nil, fmt.Errorf("vendor manifest missing version") + } + if m.BinaryPath == "" { + return nil, fmt.Errorf("vendor manifest missing binary_path") + } + return &m, nil +} + +// CleanupPaths returns all repo paths to delete, including the manifest file. +func (m *VendorManifest) CleanupPaths(workflowPrefix string) []string { + seen := make(map[string]struct{}, len(m.Paths)+2) + add := func(p string) { + if p == "" { + return + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + } + + for _, p := range m.Paths { + add(p) + } + add(m.BinaryPath) + add(VendorManifestPath(workflowPrefix)) + + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out +} + +var vendoredReusableWorkflows = []string{ + "reusable-code.yml", + "reusable-dispatch.yml", + "reusable-fix.yml", + "reusable-prioritize.yml", + "reusable-retro.yml", + "reusable-review.yml", + "reusable-triage.yml", +} + +var vendoredDefaultsInfraPaths = []string{ + "action.yml", + ".github/actions/mint-token/action.yml", + ".github/actions/setup-gcp/action.yml", + ".github/actions/validate-enrollment/action.yml", +} + +// enumerateVendoredPaths returns embed-derived paths for a current --vendor install layout. +func enumerateVendoredPaths(workflowPrefix string) ([]string, error) { + seen := make(map[string]struct{}) + add := func(p string) { + if p != "" { + seen[p] = struct{}{} + } + } + + for _, name := range vendoredReusableWorkflows { + add(workflowPrefix + ".github/workflows/" + name) + } + for _, p := range vendoredDefaultsInfraPaths { + add(defaultsVendoredPrefix + p) + } + if err := WalkLayeredContent(func(path string, _ []byte) error { + add(defaultsVendoredPrefix + "internal/scaffold/fullsend-repo/" + path) + return nil + }); err != nil { + return nil, err + } + + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// enumerateLegacyFlatVendoredPaths returns pre-.defaults flat layout paths from embed. +func enumerateLegacyFlatVendoredPaths(workflowPrefix string) ([]string, error) { + seen := make(map[string]struct{}) + add := func(p string) { + if p != "" { + seen[p] = struct{}{} + } + } + + for _, name := range vendoredReusableWorkflows { + add(workflowPrefix + ".github/workflows/" + name) + } + for _, p := range vendoredDefaultsInfraPaths { + add(p) + } + if err := WalkLayeredContent(func(path string, _ []byte) error { + add(path) + return nil + }); err != nil { + return nil, err + } + if workflowPrefix != "" { + add(workflowPrefix + "action.yml") + } + + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// ReadVendorManifest loads the manifest from a repo when present. +func ReadVendorManifest(ctx context.Context, client forge.Client, owner, repo, workflowPrefix string) (*VendorManifest, bool, error) { + path := VendorManifestPath(workflowPrefix) + data, err := client.GetFileContent(ctx, owner, repo, path) + if err != nil { + if forge.IsNotFound(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("reading vendor manifest: %w", err) + } + m, err := ParseVendorManifest(data) + if err != nil { + return nil, true, err + } + return m, true, nil +} + +// ResolveVendoredCleanupPaths returns paths to delete when disabling --vendor. +// Prefers the committed manifest; falls back to embed enumeration for legacy installs. +// binaryPath is included when no manifest is present (per-org or per-repo default). +func ResolveVendoredCleanupPaths(ctx context.Context, client forge.Client, owner, repo, workflowPrefix, binaryPath string) ([]string, error) { + manifest, found, err := ReadVendorManifest(ctx, client, owner, repo, workflowPrefix) + if err != nil { + return nil, err + } + if found && manifest != nil { + return manifest.CleanupPaths(workflowPrefix), nil + } + + paths, err := enumerateVendoredPaths(workflowPrefix) + if err != nil { + return nil, err + } + legacy, err := enumerateLegacyFlatVendoredPaths(workflowPrefix) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(paths)+len(legacy)+1) + add := func(p string) { + if p != "" { + seen[p] = struct{}{} + } + } + for _, p := range paths { + add(p) + } + for _, p := range legacy { + add(p) + } + add(binaryPath) + + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// PathsFromInstallFiles extracts relative paths from install files. +func PathsFromInstallFiles(files []InstallFile) []string { + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + sort.Strings(paths) + return paths +} + +// ComparePathPresence checks which expected paths exist in the repo. +func ComparePathPresence(ctx context.Context, client forge.Client, owner, repo string, expected []string) (missing []string, err error) { + for _, path := range expected { + _, err := client.GetFileContent(ctx, owner, repo, path) + if err != nil { + if forge.IsNotFound(err) { + missing = append(missing, path) + continue + } + return nil, fmt.Errorf("checking %s: %w", path, err) + } + } + return missing, nil +} diff --git a/internal/scaffold/vendormanifest_test.go b/internal/scaffold/vendormanifest_test.go new file mode 100644 index 0000000000..ef855cfddc --- /dev/null +++ b/internal/scaffold/vendormanifest_test.go @@ -0,0 +1,131 @@ +package scaffold + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +func TestVendorManifestRoundTrip(t *testing.T) { + m := NewVendorManifest("0.4.0", "/src/fullsend", "bin/fullsend", []string{ + ".defaults/action.yml", + ".github/workflows/reusable-triage.yml", + }) + data, err := m.MarshalYAML() + require.NoError(t, err) + + parsed, err := ParseVendorManifest(data) + require.NoError(t, err) + assert.Equal(t, vendorManifestVersion, parsed.Version) + assert.Equal(t, "0.4.0", parsed.CLIVersion) + assert.Equal(t, "/src/fullsend", parsed.SourceRef) + assert.Equal(t, "bin/fullsend", parsed.BinaryPath) + assert.Equal(t, m.Paths, parsed.Paths) +} + +func TestVendorManifestCleanupPaths(t *testing.T) { + m := NewVendorManifest("dev", "", "bin/fullsend", []string{".defaults/action.yml"}) + paths := m.CleanupPaths("") + assert.Contains(t, paths, "bin/fullsend") + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, "vendor-manifest.yaml") +} + +func TestEnumerateVendoredPathsWithoutCheckout(t *testing.T) { + paths, err := enumerateVendoredPaths("") + require.NoError(t, err) + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") + assert.Contains(t, paths, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") +} + +func TestEnumerateVendoredPathsMatchesCollectInCheckout(t *testing.T) { + root, err := moduleRootFromScaffold() + if err != nil { + t.Skip("not in fullsend checkout") + } + + embedPaths, err := enumerateVendoredPaths("") + require.NoError(t, err) + + files, err := CollectVendoredAssets(root, "") + require.NoError(t, err) + collectPaths := PathsFromInstallFiles(files) + + assert.Equal(t, embedPaths, collectPaths) +} + +func TestResolveVendoredCleanupPathsUsesManifest(t *testing.T) { + m := NewVendorManifest("dev", "", "bin/fullsend", []string{".defaults/action.yml"}) + data, err := m.MarshalYAML() + require.NoError(t, err) + + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "org/.fullsend/vendor-manifest.yaml": data, + }, + } + + paths, err := ResolveVendoredCleanupPaths(context.Background(), client, "org", ".fullsend", "", "bin/fullsend") + require.NoError(t, err) + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, "vendor-manifest.yaml") +} + +func TestResolveVendoredCleanupPathsEmbedFallback(t *testing.T) { + client := &forge.FakeClient{FileContents: map[string][]byte{}} + paths, err := ResolveVendoredCleanupPaths(context.Background(), client, "org", ".fullsend", "", "bin/fullsend") + require.NoError(t, err) + assert.Contains(t, paths, "bin/fullsend") + assert.Contains(t, paths, ".defaults/action.yml") +} + +func TestVendoredReusableWorkflowsMatchRepo(t *testing.T) { + root, err := moduleRootFromScaffold() + if err != nil { + t.Skip("not in fullsend checkout") + } + + workflowDir := filepath.Join(root, ".github", "workflows") + entries, err := os.ReadDir(workflowDir) + require.NoError(t, err) + + onDisk := map[string]struct{}{} + for _, e := range entries { + name := e.Name() + if isVendoredReusableWorkflow(".github/workflows/" + name) { + onDisk[name] = struct{}{} + } + } + + assert.Len(t, onDisk, len(vendoredReusableWorkflows)) + for _, name := range vendoredReusableWorkflows { + assert.Contains(t, onDisk, name) + } +} + +func TestCollectVendoredAssetsUsesDefaultsMirror(t *testing.T) { + root, err := moduleRootFromScaffold() + require.NoError(t, err) + + files, err := CollectVendoredAssets(root, "") + require.NoError(t, err) + + paths := PathsFromInstallFiles(files) + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, ".defaults/.github/actions/mint-token/action.yml") + assert.Contains(t, paths, ".defaults/internal/scaffold/fullsend-repo/agents/triage.md") + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") + assert.NotContains(t, paths, "action.yml") + assert.NotContains(t, paths, "agents/triage.md") +} + +func TestVendoredMarkerPath(t *testing.T) { + assert.Equal(t, ".defaults/action.yml", VendoredMarkerPath()) +} From f19f1e3810138834c75a8e343f073ed168295acf Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 10 Jun 2026 19:11:22 +0300 Subject: [PATCH 03/30] fix: address remaining PR review nits for vendor work Consolidate thin-stage caller registry, reuse resolved source root for binary vendoring, reject oversized tar members during extraction, restore workflows scope comment, fix testing-workflows prose, and introduce InstallFiles as the canonical collector return type. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/testing-workflows.md | 7 +- internal/binary/download.go | 7 +- internal/binary/download_test.go | 566 ++------------------------- internal/cli/vendor.go | 2 +- internal/layers/workflows.go | 2 + internal/scaffold/installfiles.go | 11 +- internal/scaffold/render.go | 37 +- internal/scaffold/render_test.go | 24 ++ internal/scaffold/vendorcontent.go | 4 +- internal/scaffold/vendormanifest.go | 2 +- 10 files changed, 95 insertions(+), 567 deletions(-) diff --git a/docs/guides/dev/testing-workflows.md b/docs/guides/dev/testing-workflows.md index f386033e7f..088fa80abf 100644 --- a/docs/guides/dev/testing-workflows.md +++ b/docs/guides/dev/testing-workflows.md @@ -22,11 +22,10 @@ E2e uses `--vendor` so CI exercises the commit under test, not upstream `@v0`. After changing reusable workflows or agent content, re-run install (or `fullsend github setup`) with `--vendor` to refresh vendored files. `fullsend github sync-scaffold` updates thin caller templates and auto-detects -vendored vs layered mode from `action.yml` presence. +vendored vs layered mode from `.defaults/action.yml` presence. -Runtime detects vendored installs by `action.yml` presence (config repo root for -Runtime skips the upstream sparse checkout when `.defaults/action.yml` is present (vendored install) and stages content from `.defaults/` instead. -of sparse-checkouting upstream. +Runtime skips the upstream sparse checkout when `.defaults/action.yml` is +present (vendored install) and stages content from `.defaults/` instead. ## Layered installs: pin upstream ref diff --git a/internal/binary/download.go b/internal/binary/download.go index bd66610f42..fb39600324 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -231,10 +231,15 @@ func extractSourceTree(r io.Reader, destDir string) error { if err != nil { return fmt.Errorf("creating file %s: %w", rel, err) } - if _, err := io.Copy(f, io.LimitReader(tr, int64(maxDownloadSize)+1)); err != nil { + n, err := io.Copy(f, io.LimitReader(tr, int64(maxDownloadSize)+1)) + if err != nil { f.Close() return fmt.Errorf("extracting %s: %w", rel, err) } + if n > int64(maxDownloadSize) { + f.Close() + return fmt.Errorf("extracted file %s exceeds maximum size (%d bytes)", rel, maxDownloadSize) + } if err := f.Close(); err != nil { return fmt.Errorf("closing %s: %w", rel, err) } diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 8df988b32a..4b753ae7b0 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -4,577 +4,61 @@ import ( "archive/tar" "bytes" "compress/gzip" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "net/http" - "net/http/httptest" "os" "path/filepath" - "runtime" - "strings" - "sync/atomic" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type redirectTransport struct { - srvURL string - base http.RoundTripper -} - -func (t redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { - clone := req.Clone(req.Context()) - clone.URL.Scheme = "http" - clone.URL.Host = strings.TrimPrefix(strings.TrimPrefix(t.srvURL, "https://"), "http://") - if t.base == nil { - t.base = http.DefaultTransport - } - return t.base.RoundTrip(clone) -} +func TestExtractSourceTreeRejectsOversizedFile(t *testing.T) { + origMax := maxDownloadSize + maxDownloadSize = 64 + t.Cleanup(func() { maxDownloadSize = origMax }) -func withTestReleaseServer(t *testing.T, srv *httptest.Server) { - t.Helper() - origClient := HTTPClient - origBaseURL := ReleaseBaseURL - HTTPClient = &http.Client{ - Transport: redirectTransport{srvURL: srv.URL}, - Timeout: 120 * time.Second, - } - ReleaseBaseURL = srv.URL - t.Cleanup(func() { - HTTPClient = origClient - ReleaseBaseURL = origBaseURL - }) -} - -func TestExtractFullsendFromTarGz_PathTraversal(t *testing.T) { var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gw) + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) - content := []byte("malicious binary content") require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "../../../tmp/fullsend", - Size: int64(len(content)), - Mode: 0o755, + Name: "fullsend-repo/large.bin", Typeflag: tar.TypeReg, + Size: 128, + Mode: 0o644, })) - _, err := tw.Write(content) + _, err := tw.Write(bytes.Repeat([]byte("x"), 128)) require.NoError(t, err) require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) + require.NoError(t, gz.Close()) - destPath := filepath.Join(t.TempDir(), "fullsend") - err = ExtractFullsendFromTarGz(&buf, destPath) + dest := t.TempDir() + err = extractSourceTree(bytes.NewReader(buf.Bytes()), dest) assert.Error(t, err) - assert.Contains(t, err.Error(), "not found in archive") + assert.Contains(t, err.Error(), "exceeds maximum size") } -func TestExtractFullsendFromTarGz_ValidEntry(t *testing.T) { +func TestExtractSourceTreeExtractsSmallFile(t *testing.T) { var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gw) - - content := []byte("valid binary content") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend_0.4.0_linux_amd64/fullsend", - Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, - })) - _, err := tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - destPath := filepath.Join(t.TempDir(), "fullsend") - err = ExtractFullsendFromTarGz(&buf, destPath) - require.NoError(t, err) - - data, err := os.ReadFile(destPath) - require.NoError(t, err) - assert.Equal(t, "valid binary content", string(data)) -} - -func TestDownloadChecksumForAsset_ParsesLine(t *testing.T) { - body := "1b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014 fullsend_1.0.0_linux_arm64.tar.gz\n" + - "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 fullsend_1.0.0_linux_amd64.tar.gz\n" - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, body) - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - hash, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_amd64.tar.gz") - require.NoError(t, err) - assert.Equal(t, "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752", hash) -} - -func TestDownloadChecksumForAsset_AssetNotFound(t *testing.T) { - body := "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 fullsend_1.0.0_linux_amd64.tar.gz\n" - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, body) - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - _, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_arm64.tar.gz") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found in checksums.txt") -} - -func TestDownloadChecksumForAsset_InvalidHex(t *testing.T) { - body := "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ fullsend_1.0.0_linux_amd64.tar.gz\n" - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, body) - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - _, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_amd64.tar.gz") - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid hex hash") -} - -func TestDownloadReleaseBinary_ChecksumMismatch(t *testing.T) { - var tarBuf bytes.Buffer - gw := gzip.NewWriter(&tarBuf) - tw := tar.NewWriter(gw) - content := []byte("fake binary") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", - Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, - })) - _, err := tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" - checksumBody := fmt.Sprintf("%s fullsend_1.0.0_linux_amd64.tar.gz\n", wrongHash) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v1.0.0/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v1.0.0/fullsend_1.0.0_linux_amd64.tar.gz" { - w.Write(tarBuf.Bytes()) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - destPath := filepath.Join(t.TempDir(), "fullsend") - err = DownloadRelease("1.0.0", "amd64", destPath) - require.Error(t, err) - assert.Contains(t, err.Error(), "checksum mismatch") -} - -func TestDownloadReleaseBinary_ChecksumMatch(t *testing.T) { - var tarBuf bytes.Buffer - gw := gzip.NewWriter(&tarBuf) - tw := tar.NewWriter(gw) - content := []byte("good binary") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", - Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, - })) - _, err := tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - tarBytes := tarBuf.Bytes() - h := sha256.Sum256(tarBytes) - correctHash := hex.EncodeToString(h[:]) - checksumBody := fmt.Sprintf("%s fullsend_2.0.0_linux_amd64.tar.gz\n", correctHash) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v2.0.0/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v2.0.0/fullsend_2.0.0_linux_amd64.tar.gz" { - w.Write(tarBytes) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - destPath := filepath.Join(t.TempDir(), "fullsend") - err = DownloadRelease("2.0.0", "amd64", destPath) - require.NoError(t, err) - - data, err := os.ReadFile(destPath) - require.NoError(t, err) - assert.Equal(t, "good binary", string(data)) -} - -func TestDownloadRelease_Live(t *testing.T) { - if testing.Short() { - t.Skip("skipping download test in short mode") - } - - destPath := filepath.Join(t.TempDir(), "fullsend") - err := DownloadRelease("0.4.0", "amd64", destPath) - require.NoError(t, err) - - info, err := os.Stat(destPath) - require.NoError(t, err) - assert.True(t, info.Size() > 0) -} - -func TestCrossCompile_ProducesBinary(t *testing.T) { - if runtime.GOOS == "linux" { - t.Skip("cross-compilation test only meaningful on non-Linux hosts") - } - if testing.Short() { - t.Skip("skipping cross-compilation in short mode") - } - - tmpDir := t.TempDir() - binPath := filepath.Join(tmpDir, "fullsend") - err := CrossCompile(CrossCompileOpts{ - Version: "dev", - Arch: runtime.GOARCH, - DestPath: binPath, - VersionStamp: "-crosscompiled", - }) - require.NoError(t, err) - - info, err := os.Stat(binPath) - require.NoError(t, err) - assert.True(t, info.Size() > 0) -} - -func TestValidateLinuxBinary_RejectsNonELF(t *testing.T) { - tmp := filepath.Join(t.TempDir(), "not-elf") - require.NoError(t, os.WriteFile(tmp, []byte("#!/bin/sh\necho hello"), 0o755)) - err := ValidateLinuxBinary(tmp, "amd64") - require.Error(t, err) - assert.Contains(t, err.Error(), "not a valid ELF binary") -} - -func TestValidateLinuxBinary_RejectsMissing(t *testing.T) { - err := ValidateLinuxBinary("/tmp/nonexistent-fullsend-binary-12345", "amd64") - require.Error(t, err) -} - -func TestValidateLinuxBinary_AcceptsHostBinary(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("host binary is only ELF on Linux") - } - exe, err := os.Executable() - require.NoError(t, err) - assert.NoError(t, ValidateLinuxBinary(exe, runtime.GOARCH)) -} - -func TestResolveForVendor_DevNoCheckoutFails(t *testing.T) { - // Force no module by running from a temp dir without go.mod. - origDir, err := os.Getwd() - require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - _, err = ResolveForVendor(VendorOpts{Version: "dev", Arch: "amd64"}) - require.Error(t, err) - assert.Contains(t, err.Error(), "dev build") -} - -func TestResolveForVendor_NoLatestFallback(t *testing.T) { - var latestCalls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/releases/latest") { - latestCalls.Add(1) - } - http.NotFound(w, r) - })) - defer srv.Close() - - origClient := HTTPClient - origBaseURL := ReleaseBaseURL - HTTPClient = srv.Client() - ReleaseBaseURL = srv.URL - defer func() { - HTTPClient = origClient - ReleaseBaseURL = origBaseURL - }() - - origDir, err := os.Getwd() - require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - _, err = ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) - require.Error(t, err) - assert.Equal(t, int32(0), latestCalls.Load(), "vendor path must not call latest release API") - assert.NotContains(t, err.Error(), "latest") -} - -func TestResolveForVendor_ReleaseFallback(t *testing.T) { - var tarBuf bytes.Buffer - gw := gzip.NewWriter(&tarBuf) - tw := tar.NewWriter(gw) - content := []byte("release binary") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", - Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, - })) - _, err := tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - tarBytes := tarBuf.Bytes() - h := sha256.Sum256(tarBytes) - correctHash := hex.EncodeToString(h[:]) - checksumBody := fmt.Sprintf("%s fullsend_0.4.0_linux_amd64.tar.gz\n", correctHash) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v0.4.0/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v0.4.0/fullsend_0.4.0_linux_amd64.tar.gz" { - w.Write(tarBytes) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - origDir, err := os.Getwd() - require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - result, err := ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) - assert.Equal(t, SourceReleaseDownload, result.Source) - - data, err := os.ReadFile(result.Path) - require.NoError(t, err) - assert.Equal(t, "release binary", string(data)) -} - -func TestResolveForRun_PrefersReleaseBeforeCrossCompile(t *testing.T) { - // Build mock release assets. - var tarBuf bytes.Buffer - gw := gzip.NewWriter(&tarBuf) - tw := tar.NewWriter(gw) - content := []byte("release binary") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", - Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, - })) - _, err := tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - tarBytes := tarBuf.Bytes() - h := sha256.Sum256(tarBytes) - correctHash := hex.EncodeToString(h[:]) - checksumBody := fmt.Sprintf("%s fullsend_0.4.0_linux_amd64.tar.gz\n", correctHash) + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v0.4.0/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v0.4.0/fullsend_0.4.0_linux_amd64.tar.gz" { - w.Write(tarBytes) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - - origBaseURL := ReleaseBaseURL - ReleaseBaseURL = srv.URL - defer func() { ReleaseBaseURL = origBaseURL }() - - // Run from non-module dir — cross-compile would fail if attempted after release. - origDir, err := os.Getwd() - require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - result, err := ResolveForRun("0.4.0", "amd64") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) - assert.Equal(t, SourceReleaseDownload, result.Source) -} - -func TestDownloadRelease_ExceedsMaxSize(t *testing.T) { - origLimit := maxDownloadSize - maxDownloadSize = 512 - t.Cleanup(func() { maxDownloadSize = origLimit }) - - content := bytes.Repeat([]byte("x"), 2000) - - var tarBuf bytes.Buffer - gw, err := gzip.NewWriterLevel(&tarBuf, gzip.NoCompression) - require.NoError(t, err) - tw := tar.NewWriter(gw) + content := []byte("hello") require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", - Size: int64(len(content)), - Mode: 0o755, + Name: "fullsend-repo/README.md", Typeflag: tar.TypeReg, - })) - _, err = tw.Write(content) - require.NoError(t, err) - require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) - - tarBytes := tarBuf.Bytes() - h := sha256.Sum256(tarBytes) - checksumBody := fmt.Sprintf("%s fullsend_1.0.0_linux_amd64.tar.gz\n", hex.EncodeToString(h[:])) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v1.0.0/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v1.0.0/fullsend_1.0.0_linux_amd64.tar.gz" { - w.Write(tarBytes) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - withTestReleaseServer(t, srv) - - destPath := filepath.Join(t.TempDir(), "fullsend") - err = DownloadRelease("1.0.0", "amd64", destPath) - require.Error(t, err) - assert.Contains(t, err.Error(), "exceeds maximum size") -} - -func TestResolveForRun_CrossCompileFallback(t *testing.T) { - if testing.Short() { - t.Skip("skipping cross-compilation in short mode") - } - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - })) - defer srv.Close() - withTestReleaseServer(t, srv) - - result, err := ResolveForRun("0.4.0", "amd64") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) - assert.Equal(t, SourceCheckoutBuild, result.Source) -} - -func TestResolveForRun_LatestReleaseFallback(t *testing.T) { - var tarBuf bytes.Buffer - gw := gzip.NewWriter(&tarBuf) - tw := tar.NewWriter(gw) - content := []byte("latest release binary") - require.NoError(t, tw.WriteHeader(&tar.Header{ - Name: "fullsend", Size: int64(len(content)), - Mode: 0o755, - Typeflag: tar.TypeReg, + Mode: 0o644, })) _, err := tw.Write(content) require.NoError(t, err) require.NoError(t, tw.Close()) - require.NoError(t, gw.Close()) + require.NoError(t, gz.Close()) - tarBytes := tarBuf.Bytes() - h := sha256.Sum256(tarBytes) - correctHash := hex.EncodeToString(h[:]) - checksumBody := fmt.Sprintf("%s fullsend_9.9.9_linux_amd64.tar.gz\n", correctHash) + dest := t.TempDir() + require.NoError(t, extractSourceTree(bytes.NewReader(buf.Bytes()), dest)) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/repos/fullsend-ai/fullsend/releases/latest" { - fmt.Fprint(w, `{"tag_name":"v9.9.9"}`) - } else if r.URL.Path == "/v9.9.9/checksums.txt" { - fmt.Fprint(w, checksumBody) - } else if r.URL.Path == "/v9.9.9/fullsend_9.9.9_linux_amd64.tar.gz" { - w.Write(tarBytes) - } else { - http.NotFound(w, r) - } - })) - defer srv.Close() - withTestReleaseServer(t, srv) - - origDir, err := os.Getwd() + data, err := os.ReadFile(filepath.Join(dest, "README.md")) require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - result, err := ResolveForRun("dev", "amd64") - require.NoError(t, err) - t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) - assert.Equal(t, SourceReleaseDownload, result.Source) -} - -func TestResolveForRun_AllStrategiesFail(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - })) - defer srv.Close() - withTestReleaseServer(t, srv) - - origDir, err := os.Getwd() - require.NoError(t, err) - tmpDir := t.TempDir() - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - _, err = ResolveForRun("dev", "amd64") - require.Error(t, err) - assert.Contains(t, err.Error(), "all strategies failed") + assert.Equal(t, content, data) } - -func TestResolveExplicit_ValidatesELF(t *testing.T) { - tmp := filepath.Join(t.TempDir(), "not-elf") - require.NoError(t, os.WriteFile(tmp, []byte("not binary"), 0o644)) - err := ResolveExplicit(tmp, "amd64") - require.Error(t, err) -} - -// Ensure io is used in download tests. -var _ = io.Discard diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 3d06968fcf..3a147b1371 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -76,7 +76,7 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin printer.StepDone("Validated linux/amd64 ELF binary") } else { result, err := binary.ResolveForVendor(binary.VendorOpts{ - SourceDir: fullsendSource, + SourceDir: root.Path, Version: version, Arch: vendorArch, }) diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index aaaf11f429..186264f981 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -41,6 +41,8 @@ func (l *WorkflowsLayer) Name() string { return "workflows" } func (l *WorkflowsLayer) RequiredScopes(op Operation) []string { switch op { case OpInstall: + // Writing to .github/workflows/ paths requires the workflow scope. + // Without it, GitHub returns 404 (not 403), which is deeply confusing. return []string{"repo", "workflow"} case OpUninstall: return nil diff --git a/internal/scaffold/installfiles.go b/internal/scaffold/installfiles.go index e46441a44a..73bf793156 100644 --- a/internal/scaffold/installfiles.go +++ b/internal/scaffold/installfiles.go @@ -11,6 +11,9 @@ type InstallFile struct { Mode string } +// InstallFiles is the slice type returned by install collectors. +type InstallFiles []InstallFile + // CollectInstallFilesOptions controls which scaffold files are collected. type CollectInstallFilesOptions struct { RenderOptions @@ -18,8 +21,8 @@ type CollectInstallFilesOptions struct { } // CollectInstallFiles gathers scaffold files for org or per-repo installation. -func CollectInstallFiles(opts CollectInstallFilesOptions) ([]InstallFile, error) { - var files []InstallFile +func CollectInstallFiles(opts CollectInstallFilesOptions) (InstallFiles, error) { + var files InstallFiles err := WalkFullsendRepo(func(path string, content []byte) error { rendered, renderErr := RenderTemplate(path, content, opts.RenderOptions) if renderErr != nil { @@ -55,7 +58,7 @@ func customizedDirsForPrefix(prefix string) []string { } // CollectPerRepoInstallFiles gathers files for per-repo installation. -func CollectPerRepoInstallFiles(vendored bool) ([]InstallFile, error) { +func CollectPerRepoInstallFiles(vendored bool) (InstallFiles, error) { opts := RenderOptionsForInstall(vendored, true) shimRaw, err := PerRepoShimTemplate() @@ -67,7 +70,7 @@ func CollectPerRepoInstallFiles(vendored bool) ([]InstallFile, error) { return nil, fmt.Errorf("rendering per-repo shim: %w", err) } - files := []InstallFile{{ + files := InstallFiles{{ Path: ".github/workflows/fullsend.yaml", Content: shimRendered, Mode: "100644", diff --git a/internal/scaffold/render.go b/internal/scaffold/render.go index bd082ec210..d22644dc1a 100644 --- a/internal/scaffold/render.go +++ b/internal/scaffold/render.go @@ -19,7 +19,23 @@ func RenderOptionsForInstall(vendored, perRepo bool) RenderOptions { return RenderOptions{Vendored: vendored, PerRepo: perRepo} } +// thinStageWorkflows lists thin caller paths and their stage markers. Keep in sync +// with the # fullsend-stage comments embedded in each workflow template. +var thinStageWorkflows = []struct { + stage string + path string +}{ + {"triage", ".github/workflows/triage.yml"}, + {"code", ".github/workflows/code.yml"}, + {"review", ".github/workflows/review.yml"}, + {"fix", ".github/workflows/fix.yml"}, + {"retro", ".github/workflows/retro.yml"}, + {"prioritize", ".github/workflows/prioritize.yml"}, +} + // RenderTemplate applies vendoring-aware substitutions to scaffold templates. +// Substitutions are fixed string replacements (not text/template), so only +// compile-time constants are injected into workflow YAML. func RenderTemplate(path string, content []byte, opts RenderOptions) ([]byte, error) { out := string(content) @@ -38,23 +54,18 @@ func RenderTemplate(path string, content []byte, opts RenderOptions) ([]byte, er } func isThinStageCaller(path string) bool { - switch path { - case ".github/workflows/triage.yml", - ".github/workflows/code.yml", - ".github/workflows/review.yml", - ".github/workflows/fix.yml", - ".github/workflows/retro.yml", - ".github/workflows/prioritize.yml": - return true - default: - return false + for _, w := range thinStageWorkflows { + if path == w.path { + return true + } } + return false } func thinStageName(content string) (string, error) { - for _, stage := range []string{"triage", "code", "review", "fix", "retro", "prioritize"} { - if strings.Contains(content, "# fullsend-stage: "+stage) { - return stage, nil + for _, w := range thinStageWorkflows { + if strings.Contains(content, "# fullsend-stage: "+w.stage) { + return w.stage, nil } } return "", fmt.Errorf("could not determine thin caller stage") diff --git a/internal/scaffold/render_test.go b/internal/scaffold/render_test.go index 1c4a9de311..5c3c88bdde 100644 --- a/internal/scaffold/render_test.go +++ b/internal/scaffold/render_test.go @@ -118,3 +118,27 @@ func TestRenderDispatchPerRepoStagePathsIgnoresOtherRepos(t *testing.T) { rendered := RenderDispatchPerRepoStagePaths(input) assert.Equal(t, string(input), string(rendered)) } + +func TestThinStageWorkflowRegistryMatchesTemplates(t *testing.T) { + for _, w := range thinStageWorkflows { + raw, err := FullsendRepoFile(w.path) + require.NoError(t, err, w.path) + assert.Contains(t, string(raw), "# fullsend-stage: "+w.stage, w.path) + assert.True(t, isThinStageCaller(w.path), w.path) + stage, err := thinStageName(string(raw)) + require.NoError(t, err, w.path) + assert.Equal(t, w.stage, stage, w.path) + } +} + +func TestRenderAllThinCallersFreeOfPlaceholders(t *testing.T) { + for _, w := range thinStageWorkflows { + raw, err := FullsendRepoFile(w.path) + require.NoError(t, err, w.path) + for _, vendored := range []bool{false, true} { + rendered, err := RenderTemplate(w.path, raw, RenderOptions{Vendored: vendored}) + require.NoError(t, err, w.path) + assertFreeOfRenderPlaceholders(t, string(rendered)) + } + } +} diff --git a/internal/scaffold/vendorcontent.go b/internal/scaffold/vendorcontent.go index b6f3429cd9..1acb0d3866 100644 --- a/internal/scaffold/vendorcontent.go +++ b/internal/scaffold/vendorcontent.go @@ -13,8 +13,8 @@ const defaultsVendoredPrefix = ".defaults/" // CollectVendoredAssets gathers files for --vendor installs. // Upstream mirror content lives under .defaults/ (same layout as runtime sparse checkout). // Reusable workflows are written under workflowPrefix (.fullsend/ for per-repo, "" for per-org). -func CollectVendoredAssets(root, workflowPrefix string) ([]InstallFile, error) { - var files []InstallFile +func CollectVendoredAssets(root, workflowPrefix string) (InstallFiles, error) { + var files InstallFiles if err := walkVendoredUpstreamFromRoot(root, func(path string, content []byte) error { if isVendoredReusableWorkflow(path) { diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index 0f26057312..c89c1c3cfb 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -229,7 +229,7 @@ func ResolveVendoredCleanupPaths(ctx context.Context, client forge.Client, owner } // PathsFromInstallFiles extracts relative paths from install files. -func PathsFromInstallFiles(files []InstallFile) []string { +func PathsFromInstallFiles(files InstallFiles) []string { paths := make([]string, len(files)) for i, f := range files { paths[i] = f.Path From 32aaf9d0f5b637eda54911e6acb7d0ab671c9d55 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 10 Jun 2026 19:11:58 +0300 Subject: [PATCH 04/30] fix(binary): restore download tests dropped in prior commit Re-add the full download_test.go suite and append extractSourceTree size limit coverage. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/download_test.go | 567 +++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 4b753ae7b0..7974e7b078 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -4,14 +4,578 @@ import ( "archive/tar" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type redirectTransport struct { + srvURL string + base http.RoundTripper +} + +func (t redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + clone.URL.Host = strings.TrimPrefix(strings.TrimPrefix(t.srvURL, "https://"), "http://") + if t.base == nil { + t.base = http.DefaultTransport + } + return t.base.RoundTrip(clone) +} + +func withTestReleaseServer(t *testing.T, srv *httptest.Server) { + t.Helper() + origClient := HTTPClient + origBaseURL := ReleaseBaseURL + HTTPClient = &http.Client{ + Transport: redirectTransport{srvURL: srv.URL}, + Timeout: 120 * time.Second, + } + ReleaseBaseURL = srv.URL + t.Cleanup(func() { + HTTPClient = origClient + ReleaseBaseURL = origBaseURL + }) +} + +func TestExtractFullsendFromTarGz_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + content := []byte("malicious binary content") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "../../../tmp/fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destPath := filepath.Join(t.TempDir(), "fullsend") + err = ExtractFullsendFromTarGz(&buf, destPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in archive") +} + +func TestExtractFullsendFromTarGz_ValidEntry(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + content := []byte("valid binary content") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend_0.4.0_linux_amd64/fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destPath := filepath.Join(t.TempDir(), "fullsend") + err = ExtractFullsendFromTarGz(&buf, destPath) + require.NoError(t, err) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, "valid binary content", string(data)) +} + +func TestDownloadChecksumForAsset_ParsesLine(t *testing.T) { + body := "1b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014 fullsend_1.0.0_linux_arm64.tar.gz\n" + + "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 fullsend_1.0.0_linux_amd64.tar.gz\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + hash, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_amd64.tar.gz") + require.NoError(t, err) + assert.Equal(t, "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752", hash) +} + +func TestDownloadChecksumForAsset_AssetNotFound(t *testing.T) { + body := "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752 fullsend_1.0.0_linux_amd64.tar.gz\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + _, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_arm64.tar.gz") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found in checksums.txt") +} + +func TestDownloadChecksumForAsset_InvalidHex(t *testing.T) { + body := "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ fullsend_1.0.0_linux_amd64.tar.gz\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, body) + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + _, err := downloadChecksumForAsset("1.0.0", "fullsend_1.0.0_linux_amd64.tar.gz") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid hex hash") +} + +func TestDownloadReleaseBinary_ChecksumMismatch(t *testing.T) { + var tarBuf bytes.Buffer + gw := gzip.NewWriter(&tarBuf) + tw := tar.NewWriter(gw) + content := []byte("fake binary") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + checksumBody := fmt.Sprintf("%s fullsend_1.0.0_linux_amd64.tar.gz\n", wrongHash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0.0/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v1.0.0/fullsend_1.0.0_linux_amd64.tar.gz" { + w.Write(tarBuf.Bytes()) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + destPath := filepath.Join(t.TempDir(), "fullsend") + err = DownloadRelease("1.0.0", "amd64", destPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum mismatch") +} + +func TestDownloadReleaseBinary_ChecksumMatch(t *testing.T) { + var tarBuf bytes.Buffer + gw := gzip.NewWriter(&tarBuf) + tw := tar.NewWriter(gw) + content := []byte("good binary") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tarBytes := tarBuf.Bytes() + h := sha256.Sum256(tarBytes) + correctHash := hex.EncodeToString(h[:]) + checksumBody := fmt.Sprintf("%s fullsend_2.0.0_linux_amd64.tar.gz\n", correctHash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2.0.0/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v2.0.0/fullsend_2.0.0_linux_amd64.tar.gz" { + w.Write(tarBytes) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + destPath := filepath.Join(t.TempDir(), "fullsend") + err = DownloadRelease("2.0.0", "amd64", destPath) + require.NoError(t, err) + + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, "good binary", string(data)) +} + +func TestDownloadRelease_Live(t *testing.T) { + if testing.Short() { + t.Skip("skipping download test in short mode") + } + + destPath := filepath.Join(t.TempDir(), "fullsend") + err := DownloadRelease("0.4.0", "amd64", destPath) + require.NoError(t, err) + + info, err := os.Stat(destPath) + require.NoError(t, err) + assert.True(t, info.Size() > 0) +} + +func TestCrossCompile_ProducesBinary(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("cross-compilation test only meaningful on non-Linux hosts") + } + if testing.Short() { + t.Skip("skipping cross-compilation in short mode") + } + + tmpDir := t.TempDir() + binPath := filepath.Join(tmpDir, "fullsend") + err := CrossCompile(CrossCompileOpts{ + Version: "dev", + Arch: runtime.GOARCH, + DestPath: binPath, + VersionStamp: "-crosscompiled", + }) + require.NoError(t, err) + + info, err := os.Stat(binPath) + require.NoError(t, err) + assert.True(t, info.Size() > 0) +} + +func TestValidateLinuxBinary_RejectsNonELF(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "not-elf") + require.NoError(t, os.WriteFile(tmp, []byte("#!/bin/sh\necho hello"), 0o755)) + err := ValidateLinuxBinary(tmp, "amd64") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a valid ELF binary") +} + +func TestValidateLinuxBinary_RejectsMissing(t *testing.T) { + err := ValidateLinuxBinary("/tmp/nonexistent-fullsend-binary-12345", "amd64") + require.Error(t, err) +} + +func TestValidateLinuxBinary_AcceptsHostBinary(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("host binary is only ELF on Linux") + } + exe, err := os.Executable() + require.NoError(t, err) + assert.NoError(t, ValidateLinuxBinary(exe, runtime.GOARCH)) +} + +func TestResolveForVendor_DevNoCheckoutFails(t *testing.T) { + // Force no module by running from a temp dir without go.mod. + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + _, err = ResolveForVendor(VendorOpts{Version: "dev", Arch: "amd64"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "dev build") +} + +func TestResolveForVendor_NoLatestFallback(t *testing.T) { + var latestCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/releases/latest") { + latestCalls.Add(1) + } + http.NotFound(w, r) + })) + defer srv.Close() + + origClient := HTTPClient + origBaseURL := ReleaseBaseURL + HTTPClient = srv.Client() + ReleaseBaseURL = srv.URL + defer func() { + HTTPClient = origClient + ReleaseBaseURL = origBaseURL + }() + + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + _, err = ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) + require.Error(t, err) + assert.Equal(t, int32(0), latestCalls.Load(), "vendor path must not call latest release API") + assert.NotContains(t, err.Error(), "latest") +} + +func TestResolveForVendor_ReleaseFallback(t *testing.T) { + var tarBuf bytes.Buffer + gw := gzip.NewWriter(&tarBuf) + tw := tar.NewWriter(gw) + content := []byte("release binary") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tarBytes := tarBuf.Bytes() + h := sha256.Sum256(tarBytes) + correctHash := hex.EncodeToString(h[:]) + checksumBody := fmt.Sprintf("%s fullsend_0.4.0_linux_amd64.tar.gz\n", correctHash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0.4.0/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v0.4.0/fullsend_0.4.0_linux_amd64.tar.gz" { + w.Write(tarBytes) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + result, err := ResolveForVendor(VendorOpts{Version: "0.4.0", Arch: "amd64"}) + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) + assert.Equal(t, SourceReleaseDownload, result.Source) + + data, err := os.ReadFile(result.Path) + require.NoError(t, err) + assert.Equal(t, "release binary", string(data)) +} + +func TestResolveForRun_PrefersReleaseBeforeCrossCompile(t *testing.T) { + // Build mock release assets. + var tarBuf bytes.Buffer + gw := gzip.NewWriter(&tarBuf) + tw := tar.NewWriter(gw) + content := []byte("release binary") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tarBytes := tarBuf.Bytes() + h := sha256.Sum256(tarBytes) + correctHash := hex.EncodeToString(h[:]) + checksumBody := fmt.Sprintf("%s fullsend_0.4.0_linux_amd64.tar.gz\n", correctHash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0.4.0/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v0.4.0/fullsend_0.4.0_linux_amd64.tar.gz" { + w.Write(tarBytes) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + + origBaseURL := ReleaseBaseURL + ReleaseBaseURL = srv.URL + defer func() { ReleaseBaseURL = origBaseURL }() + + // Run from non-module dir — cross-compile would fail if attempted after release. + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + result, err := ResolveForRun("0.4.0", "amd64") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) + assert.Equal(t, SourceReleaseDownload, result.Source) +} + +func TestDownloadRelease_ExceedsMaxSize(t *testing.T) { + origLimit := maxDownloadSize + maxDownloadSize = 512 + t.Cleanup(func() { maxDownloadSize = origLimit }) + + content := bytes.Repeat([]byte("x"), 2000) + + var tarBuf bytes.Buffer + gw, err := gzip.NewWriterLevel(&tarBuf, gzip.NoCompression) + require.NoError(t, err) + tw := tar.NewWriter(gw) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err = tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tarBytes := tarBuf.Bytes() + h := sha256.Sum256(tarBytes) + checksumBody := fmt.Sprintf("%s fullsend_1.0.0_linux_amd64.tar.gz\n", hex.EncodeToString(h[:])) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0.0/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v1.0.0/fullsend_1.0.0_linux_amd64.tar.gz" { + w.Write(tarBytes) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + withTestReleaseServer(t, srv) + + destPath := filepath.Join(t.TempDir(), "fullsend") + err = DownloadRelease("1.0.0", "amd64", destPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum size") +} + +func TestResolveForRun_CrossCompileFallback(t *testing.T) { + if testing.Short() { + t.Skip("skipping cross-compilation in short mode") + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + withTestReleaseServer(t, srv) + + result, err := ResolveForRun("0.4.0", "amd64") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) + assert.Equal(t, SourceCheckoutBuild, result.Source) +} + +func TestResolveForRun_LatestReleaseFallback(t *testing.T) { + var tarBuf bytes.Buffer + gw := gzip.NewWriter(&tarBuf) + tw := tar.NewWriter(gw) + content := []byte("latest release binary") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend", + Size: int64(len(content)), + Mode: 0o755, + Typeflag: tar.TypeReg, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + tarBytes := tarBuf.Bytes() + h := sha256.Sum256(tarBytes) + correctHash := hex.EncodeToString(h[:]) + checksumBody := fmt.Sprintf("%s fullsend_9.9.9_linux_amd64.tar.gz\n", correctHash) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/fullsend-ai/fullsend/releases/latest" { + fmt.Fprint(w, `{"tag_name":"v9.9.9"}`) + } else if r.URL.Path == "/v9.9.9/checksums.txt" { + fmt.Fprint(w, checksumBody) + } else if r.URL.Path == "/v9.9.9/fullsend_9.9.9_linux_amd64.tar.gz" { + w.Write(tarBytes) + } else { + http.NotFound(w, r) + } + })) + defer srv.Close() + withTestReleaseServer(t, srv) + + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + result, err := ResolveForRun("dev", "amd64") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(result.TmpDir) }) + assert.Equal(t, SourceReleaseDownload, result.Source) +} + +func TestResolveForRun_AllStrategiesFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + withTestReleaseServer(t, srv) + + origDir, err := os.Getwd() + require.NoError(t, err) + tmpDir := t.TempDir() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + _, err = ResolveForRun("dev", "amd64") + require.Error(t, err) + assert.Contains(t, err.Error(), "all strategies failed") +} + +func TestResolveExplicit_ValidatesELF(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "not-elf") + require.NoError(t, os.WriteFile(tmp, []byte("not binary"), 0o644)) + err := ResolveExplicit(tmp, "amd64") + require.Error(t, err) +} + func TestExtractSourceTreeRejectsOversizedFile(t *testing.T) { origMax := maxDownloadSize maxDownloadSize = 64 @@ -62,3 +626,6 @@ func TestExtractSourceTreeExtractsSmallFile(t *testing.T) { require.NoError(t, err) assert.Equal(t, content, data) } + +// Ensure io is used in download tests. +var _ = io.Discard From b5baa698ec6168497ff658ee377fdd4f3573bb93 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 00:31:17 +0300 Subject: [PATCH 05/30] fix(vendor): batch stale cleanup and address review nits Delete vendored paths atomically via forge.DeleteFiles, reuse resolved source root for cross-compile, preserve extracted file modes, and tighten WouldFix deduplication to exact path matches. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/acquire.go | 65 +++++++++----- internal/binary/download.go | 6 +- internal/binary/download_test.go | 13 +++ internal/cli/vendor.go | 39 ++------ internal/forge/fake.go | 26 ++++++ internal/forge/forge.go | 5 ++ internal/forge/github/github.go | 128 +++++++++++++++++++++++++++ internal/forge/github/github_test.go | 57 ++++++++++++ internal/layers/vendor.go | 26 ++++++ internal/layers/vendorbinary.go | 43 ++++----- internal/layers/vendorbinary_test.go | 8 +- 11 files changed, 326 insertions(+), 90 deletions(-) diff --git a/internal/binary/acquire.go b/internal/binary/acquire.go index dd1dd4d92f..d0a84a8bd8 100644 --- a/internal/binary/acquire.go +++ b/internal/binary/acquire.go @@ -84,45 +84,62 @@ type VendorOpts struct { // ResolveForVendor obtains a Linux binary using the vendoring policy: // cross-compile from resolved source root → matching release (released CLI only) → fail. func ResolveForVendor(opts VendorOpts) (AcquireResult, error) { + root, rootErr := ResolveVendorRoot(opts.SourceDir, opts.Version) + if rootErr != nil { + return resolveForVendorWithoutRoot(opts, rootErr) + } + if root.Cleanup != nil { + defer root.Cleanup() + } + return ResolveForVendorFromRoot(root.Path, opts.Version, opts.Arch) +} + +// ResolveForVendorFromRoot cross-compiles from an already-resolved source tree, +// falling back to release download when cross-compilation is unavailable. +func ResolveForVendorFromRoot(rootPath, version, arch string) (AcquireResult, error) { tmpDir, err := os.MkdirTemp("", "fullsend-linux-*") if err != nil { return AcquireResult{}, fmt.Errorf("creating temp dir: %w", err) } binaryPath := filepath.Join(tmpDir, "fullsend") - root, rootErr := ResolveVendorRoot(opts.SourceDir, opts.Version) - if rootErr == nil { - if root.Cleanup != nil { - defer root.Cleanup() - } - fmt.Fprintf(os.Stderr, "Cross-compiling fullsend for linux/%s...\n", opts.Arch) - if ccErr := CrossCompile(CrossCompileOpts{ - Version: opts.Version, - Arch: opts.Arch, - DestPath: binaryPath, - VersionStamp: "-vendored", - SourceDir: root.Path, - }); ccErr == nil { - fmt.Fprintf(os.Stderr, "Cross-compiled fullsend for linux/%s\n", opts.Arch) - return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceCheckoutBuild}, nil - } else { - fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr) - } - } else { + fmt.Fprintf(os.Stderr, "Cross-compiling fullsend for linux/%s...\n", arch) + ccErr := CrossCompile(CrossCompileOpts{ + Version: version, + Arch: arch, + DestPath: binaryPath, + VersionStamp: "-vendored", + SourceDir: rootPath, + }) + if ccErr == nil { + fmt.Fprintf(os.Stderr, "Cross-compiled fullsend for linux/%s\n", arch) + return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceCheckoutBuild}, nil + } + fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr) + os.RemoveAll(tmpDir) + return resolveForVendorWithoutRoot(VendorOpts{Version: version, Arch: arch}, ccErr) +} + +func resolveForVendorWithoutRoot(opts VendorOpts, rootErr error) (AcquireResult, error) { + if rootErr != nil { fmt.Fprintf(os.Stderr, "WARNING: could not resolve source root: %v\n", rootErr) } if IsReleasedVersion(opts.Version) { + tmpDir, err := os.MkdirTemp("", "fullsend-linux-*") + if err != nil { + return AcquireResult{}, fmt.Errorf("creating temp dir: %w", err) + } + binaryPath := filepath.Join(tmpDir, "fullsend") fmt.Fprintf(os.Stderr, "Downloading fullsend %s for linux/%s from GitHub Release...\n", opts.Version, opts.Arch) - if dlErr := DownloadRelease(opts.Version, opts.Arch, binaryPath); dlErr == nil { + dlErr := DownloadRelease(opts.Version, opts.Arch, binaryPath) + if dlErr == nil { fmt.Fprintf(os.Stderr, "Downloaded fullsend for linux/%s\n", opts.Arch) return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceReleaseDownload}, nil - } else { - os.RemoveAll(tmpDir) - return AcquireResult{}, fmt.Errorf("cross-compilation unavailable and release download failed for v%s: %w", opts.Version, dlErr) } + os.RemoveAll(tmpDir) + return AcquireResult{}, fmt.Errorf("cross-compilation unavailable and release download failed for v%s: %w", opts.Version, dlErr) } - os.RemoveAll(tmpDir) return AcquireResult{}, fmt.Errorf("cannot vendor binary: not in fullsend source tree and CLI version %s is a dev build — use --fullsend-binary, --fullsend-source, run from a checkout, or use a released CLI", opts.Version) } diff --git a/internal/binary/download.go b/internal/binary/download.go index fb39600324..4ec21f6e0f 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -278,7 +278,11 @@ func copyDirContents(src, dst string) error { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } - return os.WriteFile(target, data, 0o644) + info, err := d.Info() + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) }) } diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 7974e7b078..360fddb3d9 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -627,5 +627,18 @@ func TestExtractSourceTreeExtractsSmallFile(t *testing.T) { assert.Equal(t, content, data) } +func TestCopyDirContentsPreservesMode(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + script := filepath.Join(src, "run.sh") + require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\n"), 0o755)) + + require.NoError(t, copyDirContents(src, dst)) + + info, err := os.Stat(filepath.Join(dst, "run.sh")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) +} + // Ensure io is used in download tests. var _ = io.Discard diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 3a147b1371..8a625bfcca 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -75,11 +75,7 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin source = binary.SourceExplicitPath printer.StepDone("Validated linux/amd64 ELF binary") } else { - result, err := binary.ResolveForVendor(binary.VendorOpts{ - SourceDir: root.Path, - Version: version, - Arch: vendorArch, - }) + result, err := binary.ResolveForVendorFromRoot(root.Path, version, vendorArch) if err != nil { printer.StepFail("Failed to obtain binary for vendoring") return err @@ -164,35 +160,12 @@ func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - var removed int - for _, path := range paths { - _, err := client.GetFileContent(ctx, owner, repo, path) - if err != nil { - if forge.IsNotFound(err) { - continue - } - return fmt.Errorf("checking for vendored content at %s: %w", path, err) - } - if path == destPath { - printer.StepStart("removing stale vendored binary") - } else { - printer.StepStart("removing stale vendored content") - } - deleteMsg := layers.RemoveStaleContentCommitMessage(path) - if path == destPath { - deleteMsg = layers.RemoveStaleBinaryCommitMessage(path) - } - if err := client.DeleteFile(ctx, owner, repo, path, deleteMsg); err != nil { - if path == destPath { - printer.StepFail("failed to remove vendored binary") - } else { - printer.StepFail("failed to remove vendored content") - } - return fmt.Errorf("deleting vendored content at %s: %w", path, err) - } - removed++ + printer.StepStart("removing stale vendored content") + removed, err := layers.DeleteVendoredPaths(ctx, client, owner, repo, paths) + if err != nil { + printer.StepFail("failed to remove vendored content") + return fmt.Errorf("deleting vendored content: %w", err) } - if removed > 0 { printer.StepDone(fmt.Sprintf("Removed %d stale vendored files", removed)) } diff --git a/internal/forge/fake.go b/internal/forge/fake.go index 28b136d5b7..05336328d3 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -382,6 +382,32 @@ func (f *FakeClient) DeleteFile(_ context.Context, owner, repo, path, message st return nil } +func (f *FakeClient) DeleteFiles(_ context.Context, owner, repo, message string, paths []string) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if e := f.err("DeleteFiles"); e != nil { + return 0, e + } + + var deleted int + for _, path := range paths { + key := owner + "/" + repo + "/" + path + if _, ok := f.FileContents[key]; !ok { + continue + } + delete(f.FileContents, key) + f.DeletedFiles = append(f.DeletedFiles, FileRecord{ + Owner: owner, + Repo: repo, + Path: path, + Message: message, + }) + deleted++ + } + return deleted, nil +} + func (f *FakeClient) CommitFiles(_ context.Context, owner, repo, message string, files []TreeFile) (bool, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/forge/forge.go b/internal/forge/forge.go index a8cc25bcc3..65d06cd331 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -161,6 +161,11 @@ type Client interface { GetFileContent(ctx context.Context, owner, repo, path string) ([]byte, error) DeleteFile(ctx context.Context, owner, repo, path, message string) error + // DeleteFiles atomically removes multiple paths in a single commit via the + // Git Trees API. Missing paths are skipped. Returns the number of paths + // removed, or (0, nil) when none of the paths exist. + DeleteFiles(ctx context.Context, owner, repo, message string, paths []string) (deleted int, err error) + // CommitFiles atomically commits multiple files to the repository's // default branch in a single commit. It is idempotent: if all files // already have the expected content and mode, no commit is created diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 2110cfe798..6664dda77d 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -748,6 +748,134 @@ func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message strin return true, nil } +// DeleteFiles atomically removes paths from the repository default branch. +func (c *LiveClient) DeleteFiles(ctx context.Context, owner, repo, message string, paths []string) (int, error) { + if len(paths) == 0 { + return 0, nil + } + + repoResp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s", owner, repo)) + if err != nil { + return 0, fmt.Errorf("get repo: %w", err) + } + var repoInfo struct { + DefaultBranch string `json:"default_branch"` + } + if err := decodeJSON(repoResp, &repoInfo); err != nil { + return 0, fmt.Errorf("decode repo info: %w", err) + } + + var commitSHA string + if err := c.retryOnTransient(ctx, "get branch ref", func() error { + refResp, refErr := c.get(ctx, fmt.Sprintf("/repos/%s/%s/git/ref/heads/%s", owner, repo, repoInfo.DefaultBranch)) + if refErr != nil { + return fmt.Errorf("get branch ref: %w", refErr) + } + var ref struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if decErr := decodeJSON(refResp, &ref); decErr != nil { + return fmt.Errorf("decode ref: %w", decErr) + } + commitSHA = ref.Object.SHA + return nil + }); err != nil { + return 0, err + } + + cResp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/git/commits/%s", owner, repo, commitSHA)) + if err != nil { + return 0, fmt.Errorf("get commit: %w", err) + } + var commitObj struct { + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + } + if err := decodeJSON(cResp, &commitObj); err != nil { + return 0, fmt.Errorf("decode commit: %w", err) + } + baseTreeSHA := commitObj.Tree.SHA + + treeResp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/git/trees/%s?recursive=1", owner, repo, baseTreeSHA)) + if err != nil { + return 0, fmt.Errorf("get tree: %w", err) + } + var existingTree struct { + Tree []struct { + Path string `json:"path"` + } `json:"tree"` + Truncated bool `json:"truncated"` + } + if err := decodeJSON(treeResp, &existingTree); err != nil { + return 0, fmt.Errorf("decode tree: %w", err) + } + if existingTree.Truncated { + return 0, fmt.Errorf("tree too large (truncated); cannot delete") + } + + existing := make(map[string]struct{}, len(existingTree.Tree)) + for _, entry := range existingTree.Tree { + existing[entry.Path] = struct{}{} + } + + var deleteEntries []map[string]any + for _, path := range paths { + if _, ok := existing[path]; !ok { + continue + } + deleteEntries = append(deleteEntries, map[string]any{ + "path": path, + "sha": nil, + }) + } + if len(deleteEntries) == 0 { + return 0, nil + } + + treePayload := map[string]any{ + "base_tree": baseTreeSHA, + "tree": deleteEntries, + } + newTreeResp, err := c.post(ctx, fmt.Sprintf("/repos/%s/%s/git/trees", owner, repo), treePayload) + if err != nil { + return 0, fmt.Errorf("create tree: %w", err) + } + var newTree struct { + SHA string `json:"sha"` + } + if err := decodeJSON(newTreeResp, &newTree); err != nil { + return 0, fmt.Errorf("decode new tree: %w", err) + } + + commitPayload := map[string]any{ + "message": message, + "tree": newTree.SHA, + "parents": []string{commitSHA}, + } + newCommitResp, err := c.post(ctx, fmt.Sprintf("/repos/%s/%s/git/commits", owner, repo), commitPayload) + if err != nil { + return 0, fmt.Errorf("create commit: %w", err) + } + var newCommit struct { + SHA string `json:"sha"` + } + if err := decodeJSON(newCommitResp, &newCommit); err != nil { + return 0, fmt.Errorf("decode new commit: %w", err) + } + + refPayload := map[string]string{"sha": newCommit.SHA} + refUpdateResp, err := c.patch(ctx, fmt.Sprintf("/repos/%s/%s/git/refs/heads/%s", owner, repo, repoInfo.DefaultBranch), refPayload) + if err != nil { + return 0, fmt.Errorf("update ref: %w", err) + } + refUpdateResp.Body.Close() + + return len(deleteEntries), nil +} + // blobSHA computes the Git blob object SHA-1 for the given content. func blobSHA(content []byte) string { h := sha1.New() diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 2d302159ad..7ad40c2b3b 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -1416,6 +1417,62 @@ func TestCommitFiles_Empty(t *testing.T) { assert.False(t, committed) } +func TestDeleteFiles_Empty(t *testing.T) { + client := New("token") + deleted, err := client.DeleteFiles(context.Background(), "org", "repo", "msg", nil) + require.NoError(t, err) + assert.Equal(t, 0, deleted) +} + +func TestDeleteFiles_Atomic(t *testing.T) { + var treeCreated bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/repos/org/repo": + json.NewEncoder(w).Encode(map[string]string{"default_branch": "main"}) + case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/ref/heads/main": + json.NewEncoder(w).Encode(map[string]any{"object": map[string]string{"sha": "commit"}}) + case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/commits/commit": + json.NewEncoder(w).Encode(map[string]any{"tree": map[string]string{"sha": "tree"}}) + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/repos/org/repo/git/trees/tree"): + json.NewEncoder(w).Encode(map[string]any{ + "tree": []map[string]string{ + {"path": "bin/fullsend", "sha": "abc"}, + {"path": ".defaults/action.yml", "sha": "def"}, + }, + "truncated": false, + }) + case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/trees": + treeCreated = true + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + entries := body["tree"].([]any) + require.Len(t, entries, 2) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha": "newtree"}) + case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha": "newcommit"}) + case r.Method == "PATCH" && r.URL.Path == "/repos/org/repo/git/refs/heads/main": + json.NewEncoder(w).Encode(map[string]any{}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := newTestClient(t, srv) + deleted, err := client.DeleteFiles(context.Background(), "org", "repo", "remove stale", []string{ + "bin/fullsend", + ".defaults/action.yml", + "missing.yml", + }) + require.NoError(t, err) + assert.Equal(t, 2, deleted) + assert.True(t, treeCreated) +} + func TestDeleteIssueComment(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "DELETE", r.Method) diff --git a/internal/layers/vendor.go b/internal/layers/vendor.go index 900239a476..39bba41822 100644 --- a/internal/layers/vendor.go +++ b/internal/layers/vendor.go @@ -117,3 +117,29 @@ func RemoveStaleContentCommitMessage(path string) string { }, "\n") return title + "\n\n" + body } + +// RemoveStaleVendoredAssetsCommitMessage returns title + body for batch stale deletion. +func RemoveStaleVendoredAssetsCommitMessage(paths []string) string { + title := "chore: remove stale vendored fullsend assets" + lines := []string{ + "Reason: --vendor not set; removing stale vendored binary and content", + fmt.Sprintf("Paths: %d", len(paths)), + } + for _, p := range paths { + lines = append(lines, fmt.Sprintf("- %s", p)) + } + return title + "\n\n" + strings.Join(lines, "\n") +} + +// DeleteVendoredPaths removes stale vendored paths in a single commit when possible. +func DeleteVendoredPaths(ctx context.Context, client forge.Client, owner, repo string, paths []string) (int, error) { + if len(paths) == 0 { + return 0, nil + } + msg := RemoveStaleVendoredAssetsCommitMessage(paths) + deleted, err := client.DeleteFiles(ctx, owner, repo, msg, paths) + if err != nil { + return 0, err + } + return deleted, nil +} diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index 16156a319e..7c8d4fc62b 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -3,7 +3,6 @@ package layers import ( "context" "fmt" - "strings" "github.com/fullsend-ai/fullsend/internal/binary" "github.com/fullsend-ai/fullsend/internal/forge" @@ -94,29 +93,11 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - var removed int - for _, p := range paths { - _, err := l.client.GetFileContent(ctx, l.org, l.repo, p) - if err != nil { - if forge.IsNotFound(err) { - continue - } - return fmt.Errorf("checking for vendored content at %s: %w", p, err) - } - l.ui.StepStart("removing stale vendored content") - deleteMsg := RemoveStaleContentCommitMessage(p) - if p == l.binaryPath() { - deleteMsg = RemoveStaleBinaryCommitMessage(p) - } - if err := l.client.DeleteFile(ctx, l.org, l.repo, p, deleteMsg); err != nil { - if p == l.binaryPath() { - l.ui.StepFail("failed to remove vendored binary") - return fmt.Errorf("deleting vendored binary: %w", err) - } - l.ui.StepFail("failed to remove vendored content") - return fmt.Errorf("deleting vendored content at %s: %w", p, err) - } - removed++ + l.ui.StepStart("removing stale vendored content") + removed, err := DeleteVendoredPaths(ctx, l.client, l.org, l.repo, paths) + if err != nil { + l.ui.StepFail("failed to remove vendored content") + return fmt.Errorf("deleting vendored content: %w", err) } if removed > 0 { l.ui.StepDone(fmt.Sprintf("removed %d stale vendored files", removed)) @@ -269,10 +250,16 @@ func (l *VendorBinaryLayer) reportSourceAlignment(ctx context.Context, report *L } func containsWouldFix(fixes []string, path string) bool { - suffix := path - for _, f := range fixes { - if strings.HasSuffix(f, suffix) { - return true + candidates := []string{ + "restore vendored path " + path, + "sync vendored path " + path, + "restore vendored binary at " + path, + } + for _, want := range candidates { + for _, f := range fixes { + if f == want { + return true + } } } return false diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index dab448cbf1..d9806d1ad6 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -91,8 +91,8 @@ func TestVendorBinaryLayer_DisabledDeletesBinary(t *testing.T) { assert.Equal(t, "test-org", client.DeletedFiles[0].Owner) assert.Equal(t, ".fullsend", client.DeletedFiles[0].Repo) assert.Equal(t, "bin/fullsend", client.DeletedFiles[0].Path) - assert.Contains(t, client.DeletedFiles[0].Message, "\n\n") - assert.Contains(t, client.DeletedFiles[0].Message, "Path: bin/fullsend") + assert.Contains(t, client.DeletedFiles[0].Message, "remove stale vendored fullsend assets") + assert.Contains(t, client.DeletedFiles[0].Message, "bin/fullsend") // File should no longer be in FileContents _, ok := client.FileContents["test-org/.fullsend/bin/fullsend"] @@ -117,14 +117,14 @@ func TestVendorBinaryLayer_DisabledDeleteError(t *testing.T) { "test-org/.fullsend/bin/fullsend": []byte("binary-data"), }, Errors: map[string]error{ - "DeleteFile": errors.New("permission denied"), + "DeleteFiles": errors.New("permission denied"), }, } layer, _ := newVendorBinaryLayer(t, client, false, nil) err := layer.Install(context.Background()) require.Error(t, err) - assert.Contains(t, err.Error(), "deleting vendored binary") + assert.Contains(t, err.Error(), "deleting vendored content") } func TestVendorBinaryLayer_Uninstall(t *testing.T) { From 8a9681e4e7bf46e6482b644260271aa953df0178 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 01:06:53 +0300 Subject: [PATCH 06/30] docs(vendor): note --vendor-fullsend-binary removal without alias Document intentional breaking change: old flag callers should use --vendor; only known usage was e2e, already updated in this branch. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/vendor.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 8a625bfcca..620f8f561a 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -16,6 +16,11 @@ import ( const vendorArch = binary.DefaultArch +// Vendor install flags replaced the removed --vendor-fullsend-binary flag (binary-only +// upload). There is no deprecation alias: use --vendor for the full vendored stack, or +// --vendor with --fullsend-binary for an explicit ELF. The only known caller of the old +// flag was our e2e suite, updated in this PR to --vendor. + func validateVendorFlags(vendor bool, fullsendBinary, fullsendSource string) error { if fullsendBinary != "" && !vendor { return fmt.Errorf("--fullsend-binary requires --vendor") From 0b50f96cb73bc280123c17639186d6123cfa6c5c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 03:14:54 +0300 Subject: [PATCH 07/30] fix(vendor): restore layer docs and normalize cleanup step messages Document VendorBinaryLayer legacy naming, restore Uninstall/Analyze comments, and use Title Case for stale-cleanup progress messages. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/vendor.go | 4 ++-- internal/layers/vendorbinary.go | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 620f8f561a..2213db1737 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -165,10 +165,10 @@ func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - printer.StepStart("removing stale vendored content") + printer.StepStart("Removing stale vendored content") removed, err := layers.DeleteVendoredPaths(ctx, client, owner, repo, paths) if err != nil { - printer.StepFail("failed to remove vendored content") + printer.StepFail("Failed to remove vendored content") return fmt.Errorf("deleting vendored content: %w", err) } if removed > 0 { diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index 7c8d4fc62b..eefb9a5603 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -14,6 +14,8 @@ import ( type VendorFunc func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) error // VendorBinaryLayer manages vendored binary and content assets. +// The type name retains "Binary" from when the layer only uploaded the CLI +// binary; it now vendors the full stack (workflows, actions, agent content). // // When enabled (--vendor), it calls VendorFunc to upload binary and content. // When disabled, it removes stale vendored assets from prior installs. @@ -93,10 +95,10 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return fmt.Errorf("resolving vendored cleanup paths: %w", err) } - l.ui.StepStart("removing stale vendored content") + l.ui.StepStart("Removing stale vendored content") removed, err := DeleteVendoredPaths(ctx, l.client, l.org, l.repo, paths) if err != nil { - l.ui.StepFail("failed to remove vendored content") + l.ui.StepFail("Failed to remove vendored content") return fmt.Errorf("deleting vendored content: %w", err) } if removed > 0 { @@ -105,8 +107,12 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return nil } +// Uninstall is a no-op. Vendored assets are removed when the config repo is +// deleted by ConfigRepoLayer, or when install runs without --vendor. func (l *VendorBinaryLayer) Uninstall(_ context.Context) error { return nil } +// Analyze reports vendored asset presence, manifest alignment, and optional +// source-tree alignment (via SetAnalyzeOptions). func (l *VendorBinaryLayer) Analyze(ctx context.Context) (*LayerReport, error) { report := &LayerReport{Name: l.Name()} From 1f678e729dd2879da8f3a6f9ee2e81c63e7e8654 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 03:21:24 +0300 Subject: [PATCH 08/30] fix(vendor): single-commit upload and address Bugbot findings Batch binary, content, and manifest in one CommitFiles call; validate manifest version on read; trim leading slash in extractSourceTree; wrap DeleteFiles ref PATCH in retryOnTransient. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/download.go | 2 +- internal/cli/vendor.go | 27 ++++++++++++------------ internal/cli/vendor_test.go | 17 ++++++++++----- internal/forge/github/github.go | 13 ++++++++---- internal/scaffold/vendormanifest.go | 4 ++-- internal/scaffold/vendormanifest_test.go | 6 ++++++ 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/internal/binary/download.go b/internal/binary/download.go index 4ec21f6e0f..4425ca2b0f 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -213,7 +213,7 @@ func extractSourceTree(r io.Reader, destDir string) error { if !strings.HasPrefix(clean+"/", rootPrefix) { continue } - rel := strings.TrimPrefix(clean, strings.TrimSuffix(rootPrefix, "/")) + rel := strings.TrimPrefix(clean, rootPrefix) if rel == "" || rel == "." { continue } diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 2213db1737..44a2dfe956 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -66,7 +66,6 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin var ( binPath string - source binary.Source tmpDir string ) @@ -77,7 +76,6 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return fmt.Errorf("validating --fullsend-binary: %w", err) } binPath = fullsendBinary - source = binary.SourceExplicitPath printer.StepDone("Validated linux/amd64 ELF binary") } else { result, err := binary.ResolveForVendorFromRoot(root.Path, version, vendorArch) @@ -87,7 +85,6 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin } tmpDir = result.TmpDir binPath = result.Path - source = result.Source } if tmpDir != "" { @@ -98,14 +95,14 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin if err != nil { return fmt.Errorf("stat binary: %w", err) } - - printer.StepStart(fmt.Sprintf("Uploading vendored binary to %s", destPath)) - binMsg := layers.VendorCommitMessage(source, version, destPath, info.Size()) - if err := layers.VendorBinary(ctx, client, owner, repo, destPath, binPath, binMsg); err != nil { - printer.StepFail("Failed to upload vendored binary") - return err + const maxVendoredBinarySize = 100 * 1024 * 1024 + if info.Size() > maxVendoredBinarySize { + return fmt.Errorf("binary is %d bytes, exceeds %d byte limit", info.Size(), maxVendoredBinarySize) + } + binData, err := os.ReadFile(binPath) + if err != nil { + return fmt.Errorf("reading binary: %w", err) } - printer.StepDone(fmt.Sprintf("Uploaded vendored binary (%d MB)", info.Size()/(1024*1024))) assets, err := scaffold.CollectVendoredAssets(root.Path, pathPrefix) if err != nil { @@ -119,7 +116,11 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return fmt.Errorf("building vendor manifest: %w", err) } - var files []forge.TreeFile + files := []forge.TreeFile{{ + Path: destPath, + Content: binData, + Mode: "100755", + }} for _, f := range assets { files = append(files, forge.TreeFile{ Path: f.Path, @@ -133,7 +134,7 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin Mode: "100644", }) - printer.StepStart(fmt.Sprintf("Uploading %d vendored content files", len(assets))) + printer.StepStart(fmt.Sprintf("Uploading vendored binary and %d content files", len(assets)+1)) contentMsg := layers.VendorContentCommitMessage(version, pathPrefix, len(files)) committed, err := client.CommitFiles(ctx, owner, repo, contentMsg, files) if err != nil { @@ -141,7 +142,7 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return fmt.Errorf("committing vendored content: %w", err) } if committed { - printer.StepDone(fmt.Sprintf("Uploaded %d vendored content files", len(files))) + printer.StepDone(fmt.Sprintf("Uploaded vendored binary and %d content files", len(assets))) } else { printer.StepDone("Vendored content up to date") } diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index 9ddfe2082f..4aeeff19a6 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -65,9 +65,15 @@ func TestAcquireAndVendor_ExplicitPath(t *testing.T) { key := "org/my-repo/" + layers.VendoredBinaryPathPerRepo require.Contains(t, client.FileContents, key) - require.NotEmpty(t, client.CreatedFiles) - assert.Contains(t, client.CreatedFiles[0].Message, "\n\n") - assert.Contains(t, client.CreatedFiles[0].Message, "Source: --fullsend-binary") + require.Len(t, client.CommittedFiles, 1) + commit := client.CommittedFiles[0] + assert.Contains(t, commit.Message, "\n\n") + assert.Contains(t, commit.Message, "Source: --vendor install") + var paths []string + for _, f := range commit.Files { + paths = append(paths, f.Path) + } + assert.Contains(t, paths, layers.VendoredBinaryPathPerRepo) } func TestAcquireAndVendor_CheckoutBuild(t *testing.T) { @@ -84,6 +90,7 @@ func TestAcquireAndVendor_CheckoutBuild(t *testing.T) { key := "org/" + forge.ConfigRepoName + "/" + layers.VendoredBinaryPath require.Contains(t, client.FileContents, key) - require.NotEmpty(t, client.CreatedFiles) - assert.Contains(t, client.CreatedFiles[0].Message, "cross-compiled from checkout") + require.Len(t, client.CommittedFiles, 1) + assert.Contains(t, client.CommittedFiles[0].Message, "\n\n") + assert.Contains(t, client.CommittedFiles[0].Message, "Source: --vendor install") } diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 6664dda77d..a4ec7ed91d 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -867,11 +867,16 @@ func (c *LiveClient) DeleteFiles(ctx context.Context, owner, repo, message strin } refPayload := map[string]string{"sha": newCommit.SHA} - refUpdateResp, err := c.patch(ctx, fmt.Sprintf("/repos/%s/%s/git/refs/heads/%s", owner, repo, repoInfo.DefaultBranch), refPayload) - if err != nil { - return 0, fmt.Errorf("update ref: %w", err) + if err := c.retryOnTransient(ctx, "update ref", func() error { + refUpdateResp, patchErr := c.patch(ctx, fmt.Sprintf("/repos/%s/%s/git/refs/heads/%s", owner, repo, repoInfo.DefaultBranch), refPayload) + if patchErr != nil { + return fmt.Errorf("update ref: %w", patchErr) + } + refUpdateResp.Body.Close() + return nil + }); err != nil { + return 0, err } - refUpdateResp.Body.Close() return len(deleteEntries), nil } diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index c89c1c3cfb..7782ddf934 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -52,8 +52,8 @@ func ParseVendorManifest(data []byte) (*VendorManifest, error) { if err := yaml.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("parsing vendor manifest: %w", err) } - if m.Version == "" { - return nil, fmt.Errorf("vendor manifest missing version") + if m.Version != vendorManifestVersion { + return nil, fmt.Errorf("unsupported vendor manifest version %q", m.Version) } if m.BinaryPath == "" { return nil, fmt.Errorf("vendor manifest missing binary_path") diff --git a/internal/scaffold/vendormanifest_test.go b/internal/scaffold/vendormanifest_test.go index ef855cfddc..39a9e547a5 100644 --- a/internal/scaffold/vendormanifest_test.go +++ b/internal/scaffold/vendormanifest_test.go @@ -29,6 +29,12 @@ func TestVendorManifestRoundTrip(t *testing.T) { assert.Equal(t, m.Paths, parsed.Paths) } +func TestParseVendorManifestRejectsUnknownVersion(t *testing.T) { + _, err := ParseVendorManifest([]byte("version: \"2\"\nbinary_path: bin/fullsend\npaths: []\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported vendor manifest version") +} + func TestVendorManifestCleanupPaths(t *testing.T) { m := NewVendorManifest("dev", "", "bin/fullsend", []string{".defaults/action.yml"}) paths := m.CleanupPaths("") From 1881e3b54dbb6463ec6d5edb1bdd2b0fead44e28 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 03:42:39 +0300 Subject: [PATCH 09/30] fix(forge): include mode and type in DeleteFiles tree entries Use the existing blob mode from the recursive tree and set type blob so deletion entries match GitHub Trees API expectations. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/github.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index a4ec7ed91d..28a88992a0 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -806,6 +806,7 @@ func (c *LiveClient) DeleteFiles(ctx context.Context, owner, repo, message strin var existingTree struct { Tree []struct { Path string `json:"path"` + Mode string `json:"mode"` } `json:"tree"` Truncated bool `json:"truncated"` } @@ -816,18 +817,24 @@ func (c *LiveClient) DeleteFiles(ctx context.Context, owner, repo, message strin return 0, fmt.Errorf("tree too large (truncated); cannot delete") } - existing := make(map[string]struct{}, len(existingTree.Tree)) + existing := make(map[string]string, len(existingTree.Tree)) for _, entry := range existingTree.Tree { - existing[entry.Path] = struct{}{} + existing[entry.Path] = entry.Mode } var deleteEntries []map[string]any for _, path := range paths { - if _, ok := existing[path]; !ok { + mode, ok := existing[path] + if !ok { continue } + if mode == "" { + mode = "100644" + } deleteEntries = append(deleteEntries, map[string]any{ "path": path, + "mode": mode, + "type": "blob", "sha": nil, }) } From 88ecef4c4dbb5b36c0eb633b154090c89de9e42a Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 03:57:48 +0300 Subject: [PATCH 10/30] test(forge): assert DeleteFiles tree entry mode and type Guard against regressions in delete-entry construction per review. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/github_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 7ad40c2b3b..acdc01d64f 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1437,8 +1437,8 @@ func TestDeleteFiles_Atomic(t *testing.T) { case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/repos/org/repo/git/trees/tree"): json.NewEncoder(w).Encode(map[string]any{ "tree": []map[string]string{ - {"path": "bin/fullsend", "sha": "abc"}, - {"path": ".defaults/action.yml", "sha": "def"}, + {"path": "bin/fullsend", "sha": "abc", "mode": "100755"}, + {"path": ".defaults/action.yml", "sha": "def", "mode": "100644"}, }, "truncated": false, }) @@ -1448,6 +1448,12 @@ func TestDeleteFiles_Atomic(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) entries := body["tree"].([]any) require.Len(t, entries, 2) + for _, raw := range entries { + entry := raw.(map[string]any) + assert.Equal(t, "blob", entry["type"]) + assert.NotEmpty(t, entry["mode"]) + assert.Nil(t, entry["sha"]) + } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"sha": "newtree"}) case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/commits": From 893d1af935a3f6fa398174a823b1a2a474b5a9f5 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 09:06:51 +0300 Subject: [PATCH 11/30] fix(vendor): address post-review findings from fullsend-ai-review Encode CommitFiles tree entries as base64 to preserve ELF binaries, add tar extract containment check, consolidate stale cleanup with a manifest/binary quick-check, and deduplicate cleanup between CLI and layer. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/download.go | 12 ++++++++ internal/cli/vendor.go | 16 +--------- internal/forge/github/github.go | 13 ++++---- internal/forge/github/github_test.go | 45 ++++++++++++++++++++++++++++ internal/layers/vendor.go | 36 ++++++++++++++++++++++ internal/layers/vendorbinary.go | 16 +--------- 6 files changed, 102 insertions(+), 36 deletions(-) diff --git a/internal/binary/download.go b/internal/binary/download.go index 4425ca2b0f..ce6558186b 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -176,6 +176,15 @@ func FetchSourceTree(version, destDir string) error { return extractSourceTree(bytes.NewReader(buf.Bytes()), destDir) } +func pathWithinDir(dir, target string) bool { + dir = filepath.Clean(dir) + target = filepath.Clean(target) + if target == dir { + return true + } + return strings.HasPrefix(target, dir+string(os.PathSeparator)) +} + func extractSourceTree(r io.Reader, destDir string) error { gz, err := gzip.NewReader(r) if err != nil { @@ -218,6 +227,9 @@ func extractSourceTree(r io.Reader, destDir string) error { continue } target := filepath.Join(tmpDir, rel) + if !pathWithinDir(tmpDir, target) { + return fmt.Errorf("extract path escapes destination: %s", rel) + } switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 44a2dfe956..85343a30ce 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -161,21 +161,7 @@ func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer destPath = layers.VendoredBinaryPathPerRepo } - paths, err := scaffold.ResolveVendoredCleanupPaths(ctx, client, owner, repo, pathPrefix, destPath) - if err != nil { - return fmt.Errorf("resolving vendored cleanup paths: %w", err) - } - - printer.StepStart("Removing stale vendored content") - removed, err := layers.DeleteVendoredPaths(ctx, client, owner, repo, paths) - if err != nil { - printer.StepFail("Failed to remove vendored content") - return fmt.Errorf("deleting vendored content: %w", err) - } - if removed > 0 { - printer.StepDone(fmt.Sprintf("Removed %d stale vendored files", removed)) - } - return nil + return layers.RemoveStaleVendoredAssets(ctx, client, printer, owner, repo, pathPrefix, destPath) } func vendorDryRunMessage(fullsendBinary, fullsendSource, destPath string) string { diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 9adc0c46b1..2206c5c163 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -684,17 +684,18 @@ func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message strin } // 5. Compute expected blob SHAs and filter to changed files. - var changedEntries []map[string]string + var changedEntries []map[string]any for _, f := range files { expectedSHA := blobSHA(f.Content) if info, ok := existing[f.Path]; ok && info.sha == expectedSHA && info.mode == f.Mode { continue } - changedEntries = append(changedEntries, map[string]string{ - "path": f.Path, - "mode": f.Mode, - "type": "blob", - "content": string(f.Content), + changedEntries = append(changedEntries, map[string]any{ + "path": f.Path, + "mode": f.Mode, + "type": "blob", + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString(f.Content), }) } diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index acdc01d64f..1dc8f3e410 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1303,6 +1303,51 @@ func TestCommitFiles_AllNew(t *testing.T) { assert.True(t, committed) } +func TestCommitFiles_BinaryUsesBase64Encoding(t *testing.T) { + binaryContent := []byte{0x7f, 0x45, 0x4c, 0x46, 0xff, 0xfe, 0x00} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/repos/org/repo": + json.NewEncoder(w).Encode(map[string]string{"default_branch": "main"}) + case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/ref/heads/main": + json.NewEncoder(w).Encode(map[string]any{"object": map[string]string{"sha": "abc123"}}) + case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/commits/abc123": + json.NewEncoder(w).Encode(map[string]any{"tree": map[string]string{"sha": "tree000"}}) + case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/trees/tree000": + json.NewEncoder(w).Encode(map[string]any{"tree": []any{}, "truncated": false}) + case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/trees": + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + entries := body["tree"].([]any) + require.Len(t, entries, 1) + entry := entries[0].(map[string]any) + assert.Equal(t, "base64", entry["encoding"]) + decoded, err := base64.StdEncoding.DecodeString(entry["content"].(string)) + require.NoError(t, err) + assert.Equal(t, binaryContent, decoded) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha": "newtree"}) + case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/commits": + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha": "newcommit"}) + case r.Method == "PATCH" && r.URL.Path == "/repos/org/repo/git/refs/heads/main": + json.NewEncoder(w).Encode(map[string]any{}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + client := newTestClient(t, srv) + committed, err := client.CommitFiles(context.Background(), "org", "repo", "vendor binary", []forge.TreeFile{ + {Path: "bin/fullsend", Content: binaryContent, Mode: "100755"}, + }) + require.NoError(t, err) + assert.True(t, committed) +} + func TestCommitFiles_AllUnchanged(t *testing.T) { content := []byte("existing content") existingSHA := blobSHA(content) diff --git a/internal/layers/vendor.go b/internal/layers/vendor.go index 39bba41822..178f7e623f 100644 --- a/internal/layers/vendor.go +++ b/internal/layers/vendor.go @@ -8,6 +8,8 @@ import ( "github.com/fullsend-ai/fullsend/internal/binary" "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/scaffold" + "github.com/fullsend-ai/fullsend/internal/ui" ) const ( @@ -143,3 +145,37 @@ func DeleteVendoredPaths(ctx context.Context, client forge.Client, owner, repo s } return deleted, nil } + +// RemoveStaleVendoredAssets deletes vendored assets when --vendor is not set. +// It skips work when neither the vendor manifest nor vendored binary exists. +func RemoveStaleVendoredAssets(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, workflowPrefix, binaryPath string) error { + manifestPath := scaffold.VendorManifestPath(workflowPrefix) + _, manifestErr := client.GetFileContent(ctx, owner, repo, manifestPath) + if manifestErr != nil && forge.IsNotFound(manifestErr) { + _, binErr := client.GetFileContent(ctx, owner, repo, binaryPath) + if binErr != nil && forge.IsNotFound(binErr) { + return nil + } + if binErr != nil { + return fmt.Errorf("checking vendored binary: %w", binErr) + } + } else if manifestErr != nil { + return fmt.Errorf("checking vendor manifest: %w", manifestErr) + } + + paths, err := scaffold.ResolveVendoredCleanupPaths(ctx, client, owner, repo, workflowPrefix, binaryPath) + if err != nil { + return fmt.Errorf("resolving vendored cleanup paths: %w", err) + } + + printer.StepStart("Removing stale vendored content") + removed, err := DeleteVendoredPaths(ctx, client, owner, repo, paths) + if err != nil { + printer.StepFail("Failed to remove vendored content") + return fmt.Errorf("deleting vendored content: %w", err) + } + if removed > 0 { + printer.StepDone(fmt.Sprintf("Removed %d stale vendored files", removed)) + } + return nil +} diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index eefb9a5603..0f5e9d11a8 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -90,21 +90,7 @@ func (l *VendorBinaryLayer) Install(ctx context.Context) error { return l.vendorFn(ctx, l.client, l.ui, l.org, l.repo) } - paths, err := scaffold.ResolveVendoredCleanupPaths(ctx, l.client, l.org, l.repo, l.workflowPrefix(), l.binaryPath()) - if err != nil { - return fmt.Errorf("resolving vendored cleanup paths: %w", err) - } - - l.ui.StepStart("Removing stale vendored content") - removed, err := DeleteVendoredPaths(ctx, l.client, l.org, l.repo, paths) - if err != nil { - l.ui.StepFail("Failed to remove vendored content") - return fmt.Errorf("deleting vendored content: %w", err) - } - if removed > 0 { - l.ui.StepDone(fmt.Sprintf("removed %d stale vendored files", removed)) - } - return nil + return RemoveStaleVendoredAssets(ctx, l.client, l.ui, l.org, l.repo, l.workflowPrefix(), l.binaryPath()) } // Uninstall is a no-op. Vendored assets are removed when the config repo is From b7b04f5a56696945a3a11c5be3c51a494dd5483a Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 10:25:49 +0300 Subject: [PATCH 12/30] docs: address review feedback on ADR 0046 and testing guide Clarify removed distribution-mode artifacts, drop e2e vendor line, and document action.yml source-build fallback. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/ADRs/0046-vendored-installs-with-vendor-flag.md | 5 ++++- docs/guides/dev/testing-workflows.md | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md index 2be6c00e60..2a033f885b 100644 --- a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md +++ b/docs/ADRs/0046-vendored-installs-with-vendor-flag.md @@ -91,7 +91,10 @@ onto the workspace root at job start (inline prepare step). Thin caller `uses:` paths are rendered at install/sync time (local `./...` when `--vendor`, upstream `@v0` when layered). -### What was removed +### What this PR removes + +These existed on earlier iterations of the distribution-mode branch and are +dropped in favor of `--vendor` plus runtime marker detection: - `distribution.mode` / `distribution.upstream.ref` in org and per-repo config - `--distribution-mode`, `--upstream-ref` CLI flags diff --git a/docs/guides/dev/testing-workflows.md b/docs/guides/dev/testing-workflows.md index bc90a3cea6..1290f36d79 100644 --- a/docs/guides/dev/testing-workflows.md +++ b/docs/guides/dev/testing-workflows.md @@ -12,6 +12,9 @@ There are independent version reference inputs that control different parts of t | `fullsend_ai_ref` | Which ref composite actions (`action.yml`) and defaults are loaded from at runtime | Passed as a `with:` input | | `fullsend_version` | Which fullsend CLI binary is installed | Passed as a `with:` input | +When no release exists for `fullsend_version`, `action.yml` falls back to cloning +and building from source at that ref (see the `install-method=source` path). + If `uses:`, `fullsend_ai_ref` and `fullsend_version` diverge, the workflows, agents and harnesses, and CLI diverge, potentially causing mismatch in behavior and failures. @@ -31,7 +34,6 @@ fullsend admin install "$ORG" \ # ... other flags ``` -E2e uses `--vendor` so CI exercises the commit under test, not upstream `@v0`. After changing reusable workflows or agent content, re-run install (or `fullsend github setup`) with `--vendor` to refresh vendored files. `fullsend github sync-scaffold` updates thin caller templates and auto-detects From 7d71e3825520a4c55bc1df235fd7aa386f471c86 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 10:35:35 +0300 Subject: [PATCH 13/30] chore: re-trigger fullsend-ai-review after doc fixes Empty commit to re-dispatch review; prior synchronize dispatch was cancelled. Signed-off-by: Barak Korren Co-authored-by: Cursor From d330766a0d6e78388fdd7515e0f7aa57ccb57bb5 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 10:54:53 +0300 Subject: [PATCH 14/30] fix(scaffold): include check-e2e-authorization in vendored infra paths Keep enumerateVendoredPaths aligned with CollectVendoredAssets after main added the composite action (#2106); fixes CI parity test. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/scaffold/vendormanifest.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index 7782ddf934..a825c2b09b 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -100,6 +100,7 @@ var vendoredReusableWorkflows = []string{ var vendoredDefaultsInfraPaths = []string{ "action.yml", + ".github/actions/check-e2e-authorization/action.yml", ".github/actions/mint-token/action.yml", ".github/actions/setup-gcp/action.yml", ".github/actions/validate-enrollment/action.yml", From 99ddc9da1f37e2233229301d4499d7d2b82b1889 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 11:16:52 +0300 Subject: [PATCH 15/30] docs(forge): note base64 encoding in CommitFiles comment Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/github.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 2206c5c163..04fb10abbf 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -599,6 +599,8 @@ func isTransientStatus(code int) bool { // CommitFiles atomically commits multiple files to the default branch // using the Git Trees/Blobs/Commits API. Returns (false, nil) when // all files already match the current tree (idempotent). +// Tree entries use base64 encoding so binary content (e.g. vendored ELF) +// is not corrupted by JSON UTF-8 replacement. func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message string, files []forge.TreeFile) (bool, error) { if len(files) == 0 { return false, nil From fed552c24ff5f62514997c69da0cf309e6c1221c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 13:28:14 +0300 Subject: [PATCH 16/30] fix(install): combine vendor commit with scaffold and retry enrollment dispatch GitHub Actions may return 422 when repo-maintenance is dispatched immediately after a separate vendor CommitFiles on a fresh .fullsend repo. Merge scaffold and vendored assets into one atomic commit and retry dispatch on indexing lag. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin.go | 55 ++++++++++++---- internal/cli/admin_test.go | 3 +- internal/cli/github.go | 33 +++++++--- internal/cli/vendor.go | 96 +++++++++++++++++++++++----- internal/layers/enrollment.go | 46 ++++++++++++- internal/layers/enrollment_test.go | 47 ++++++++++++++ internal/layers/vendorbinary.go | 13 ++++ internal/layers/vendorbinary_test.go | 16 +++++ internal/layers/workflows.go | 34 ++++++++-- internal/layers/workflows_test.go | 26 ++++++++ 10 files changed, 324 insertions(+), 45 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 91b9eabd2a..f47a776170 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -991,7 +991,19 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { "FULLSEND_GCP_WIF_PROVIDER": inferenceWIFProvider, } - printer.StepStart("Writing per-repo scaffold files") + var vendorAssetCount int + if vendor { + var vendorErr error + files, vendorAssetCount, vendorErr = appendVendorTreeFiles(printer, owner, repo, files, vendor, fullsendBinary, fullsendSource) + if vendorErr != nil { + return fmt.Errorf("collecting vendored assets: %w", vendorErr) + } + } + if vendorAssetCount > 0 { + printer.StepStart(fmt.Sprintf("Writing per-repo scaffold and vendored assets (%d content files)", vendorAssetCount)) + } else { + printer.StepStart("Writing per-repo scaffold files") + } committed, err := client.CommitFiles(ctx, owner, repo, fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version), files) if err != nil { @@ -999,7 +1011,11 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { return fmt.Errorf("committing scaffold files: %w", err) } if committed { - printer.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + if vendorAssetCount > 0 { + printer.StepDone(fmt.Sprintf("Wrote %d scaffold files and vendored binary (%d content files)", len(files), vendorAssetCount)) + } else { + printer.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + } } else { printer.StepDone("Scaffold up to date") } @@ -1022,11 +1038,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { } printer.StepDone(fmt.Sprintf("Set %d repository secrets", len(repoSecrets))) - if vendor { - if err := acquireAndVendor(ctx, client, printer, owner, repo, fullsendBinary, fullsendSource); err != nil { - return fmt.Errorf("vendoring assets: %w", err) - } - } else { + if !vendor { if err := removeStaleVendoredAssets(ctx, client, printer, owner, repo, true); err != nil { return err } @@ -1193,7 +1205,8 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or } else { dispatcher = gcf.NewProvisioner(gcf.Config{}, nil) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), "", dispatcher) + vendorFn, vendorCollect := vendorStackArgs(vendor, fullsendBinary, fullsendSource) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, vendorFn, vendorCollect, "", dispatcher) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1546,7 +1559,8 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o }, gcf.NewLiveGCFClient(mintProject)) } - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, makeVendorFunc(fullsendBinary, fullsendSource), "", disp) + vendorFn, vendorCollect := vendorStackArgs(vendor, fullsendBinary, fullsendSource) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, vendor, vendorFn, vendorCollect, "", disp) if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { return err @@ -1791,7 +1805,7 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o } dispatcher := gcf.NewProvisioner(gcf.Config{}, nil) - stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, analyzeFullsendSource, dispatcher) + stack := buildLayerStack(org, client, cfg, printer, user, privateRepo, nil, agentCreds, nil, inferenceProvider, false, nil, nil, analyzeFullsendSource, dispatcher) if err := runPreflight(ctx, stack, layers.OpAnalyze, client, printer); err != nil { return err @@ -1821,6 +1835,7 @@ func buildLayerStack( inferenceProvider inference.Provider, vendor bool, vendorFn layers.VendorFunc, + vendorCollect layers.VendorCollectFunc, analyzeFullsendSource string, dispatcher dispatch.Dispatcher, ) *layers.Stack { @@ -1838,8 +1853,8 @@ func buildLayerStack( return layers.NewStack( layers.NewConfigRepoLayer(org, client, cfg, printer, privateRepo), - layers.NewWorkflowsLayer(org, client, printer, user, version, vendor), - newVendorLayer(org, client, printer, vendor, vendorFn, analyzeFullsendSource), + workflowsLayer(org, client, printer, user, version, vendor, vendorCollect), + vendorLayer(org, client, printer, vendor, vendorFn, vendorCollect, analyzeFullsendSource), layers.NewSecretsLayer(org, client, agentCreds, printer).WithOIDCMode(), layers.NewInferenceLayer(org, client, inferenceProvider, printer), dispatchLayer, @@ -1847,6 +1862,22 @@ func buildLayerStack( ) } +func workflowsLayer(org string, client forge.Client, printer *ui.Printer, user, version string, vendor bool, vendorCollect layers.VendorCollectFunc) *layers.WorkflowsLayer { + layer := layers.NewWorkflowsLayer(org, client, printer, user, version, vendor) + if vendorCollect != nil { + layer = layer.WithVendorCollect(vendorCollect) + } + return layer +} + +func vendorLayer(org string, client forge.Client, printer *ui.Printer, vendor bool, vendorFn layers.VendorFunc, vendorCollect layers.VendorCollectFunc, analyzeFullsendSource string) *layers.VendorBinaryLayer { + layer := newVendorLayer(org, client, printer, vendor, vendorFn, analyzeFullsendSource) + if vendorCollect != nil { + layer.SetCombinedWithScaffold(true) + } + return layer +} + // installRequiredScopes is the set of OAuth scopes the install command // needs. Keep in sync with the union of RequiredScopes(OpInstall) across // all layers; TestCheckInstallScopes_SyncWithLayers asserts parity. diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index e435e964fd..3cc979f1e3 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1099,6 +1099,7 @@ func TestBuildLayerStack_NilEnabledRepos_SkipsDisabledRepos(t *testing.T) { nil, // inferenceProvider false, // vendorBinary nil, // vendorFn + nil, // vendorCollect "", // analyzeFullsendSource nil, // dispatcher ) @@ -1134,7 +1135,7 @@ func TestBuildLayerStack_EmptyEnabledRepos_IncludesDisabledRepos(t *testing.T) { "test-org", nil, cfg, printer, "user", false, []string{}, // explicitly empty (not nil) - nil, nil, nil, false, nil, "", nil, + nil, nil, nil, false, nil, nil, "", nil, ) // The enrollment layer should have disabled repos to reconcile. diff --git a/internal/cli/github.go b/internal/cli/github.go index c7bc8e75f6..cdf5d253da 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -281,7 +281,19 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui } printer.Blank() - printer.StepStart("Writing per-repo scaffold files") + var vendorAssetCount int + if cfg.vendor { + var vendorErr error + files, vendorAssetCount, vendorErr = appendVendorTreeFiles(printer, owner, repo, files, cfg.vendor, cfg.fullsendBinary, cfg.fullsendSource) + if vendorErr != nil { + return fmt.Errorf("collecting vendored assets: %w", vendorErr) + } + } + if vendorAssetCount > 0 { + printer.StepStart(fmt.Sprintf("Writing per-repo scaffold and vendored assets (%d content files)", vendorAssetCount)) + } else { + printer.StepStart("Writing per-repo scaffold files") + } committed, err := client.CommitFiles(ctx, owner, repo, fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version), files) if err != nil { @@ -289,7 +301,11 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui return fmt.Errorf("committing scaffold files: %w", err) } if committed { - printer.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + if vendorAssetCount > 0 { + printer.StepDone(fmt.Sprintf("Wrote %d scaffold files and vendored binary (%d content files)", len(files), vendorAssetCount)) + } else { + printer.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + } } else { printer.StepDone("Scaffold up to date") } @@ -312,11 +328,7 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui } printer.StepDone(fmt.Sprintf("Set %d repository secrets", len(repoSecrets))) - if cfg.vendor { - if err := acquireAndVendor(ctx, client, printer, owner, repo, cfg.fullsendBinary, cfg.fullsendSource); err != nil { - return fmt.Errorf("vendoring assets: %w", err) - } - } else { + if !cfg.vendor { if err := removeStaleVendoredAssets(ctx, client, printer, owner, repo, true); err != nil { return err } @@ -468,11 +480,12 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. dispatcher := &skipMintDispatcher{mintURL: cfg.mintURL} var vendorFn layers.VendorFunc + var vendorCollect layers.VendorCollectFunc if cfg.vendor { - vendorFn = makeVendorFunc(cfg.fullsendBinary, cfg.fullsendSource) + vendorFn, vendorCollect = vendorStackArgs(true, cfg.fullsendBinary, cfg.fullsendSource) } - stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, "", dispatcher) + stack := buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, vendorCollect, "", dispatcher) if cfg.dryRun { printer.Header("Dry run — analyzing what setup would do") @@ -508,7 +521,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. orgCfg = config.NewOrgConfig(repoNames, enabledRepos, roles, agents, inferenceProviderName) orgCfg.Dispatch.Mode = "oidc-mint" - stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, "", dispatcher) + stack = buildLayerStack(org, client, orgCfg, printer, user, privateRepo, enabledRepos, agentCreds, enrolledRepoIDs, inferenceProvider, cfg.vendor, vendorFn, vendorCollect, "", dispatcher) } if err := runPreflight(ctx, stack, layers.OpInstall, client, printer); err != nil { diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 85343a30ce..177b863af4 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -37,6 +37,11 @@ func addVendorFlags(cmd *cobra.Command, vendor *bool, fullsendBinary, fullsendSo cmd.Flags().StringVar(fullsendSource, "fullsend-source", "", "fullsend source checkout for content and cross-compile (default: auto-detect or GitHub fetch)") } +type vendorFileBundle struct { + files []forge.TreeFile + assetCount int +} + // makeVendorFunc returns a VendorFunc closure that uploads vendored assets. func makeVendorFunc(fullsendBinary, fullsendSource string) layers.VendorFunc { return func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) error { @@ -44,7 +49,38 @@ func makeVendorFunc(fullsendBinary, fullsendSource string) layers.VendorFunc { } } -func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) error { +// makeVendorCollectFunc returns a VendorCollectFunc for combined scaffold commits. +func makeVendorCollectFunc(fullsendBinary, fullsendSource string) layers.VendorCollectFunc { + return func(ctx context.Context, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { + bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + if err != nil { + return nil, 0, err + } + defer cleanup() + return bundle.files, bundle.assetCount, nil + } +} + +func vendorStackArgs(vendor bool, fullsendBinary, fullsendSource string) (layers.VendorFunc, layers.VendorCollectFunc) { + if !vendor { + return nil, nil + } + return makeVendorFunc(fullsendBinary, fullsendSource), makeVendorCollectFunc(fullsendBinary, fullsendSource) +} + +func appendVendorTreeFiles(printer *ui.Printer, owner, repo string, files []forge.TreeFile, vendor bool, fullsendBinary, fullsendSource string) ([]forge.TreeFile, int, error) { + if !vendor { + return files, 0, nil + } + bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + if err != nil { + return nil, 0, err + } + defer cleanup() + return append(files, bundle.files...), bundle.assetCount, nil +} + +func prepareVendorFiles(printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) (vendorFileBundle, func(), error) { perRepo := repo != forge.ConfigRepoName pathPrefix := "" if perRepo { @@ -58,10 +94,11 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin root, err := binary.ResolveVendorRoot(fullsendSource, version) if err != nil { printer.StepFail("Failed to resolve fullsend source") - return err + return vendorFileBundle{}, func() {}, err } + cleanupRoot := func() {} if root.Cleanup != nil { - defer root.Cleanup() + cleanupRoot = root.Cleanup } var ( @@ -73,7 +110,8 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin printer.StepStart(fmt.Sprintf("Using provided binary: %s", fullsendBinary)) if err := binary.ResolveExplicit(fullsendBinary, vendorArch); err != nil { printer.StepFail("Invalid --fullsend-binary") - return fmt.Errorf("validating --fullsend-binary: %w", err) + cleanupRoot() + return vendorFileBundle{}, func() {}, fmt.Errorf("validating --fullsend-binary: %w", err) } binPath = fullsendBinary printer.StepDone("Validated linux/amd64 ELF binary") @@ -81,39 +119,48 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin result, err := binary.ResolveForVendorFromRoot(root.Path, version, vendorArch) if err != nil { printer.StepFail("Failed to obtain binary for vendoring") - return err + cleanupRoot() + return vendorFileBundle{}, func() {}, err } tmpDir = result.TmpDir binPath = result.Path } - if tmpDir != "" { - defer os.RemoveAll(tmpDir) + cleanup := func() { + if tmpDir != "" { + os.RemoveAll(tmpDir) + } + cleanupRoot() } info, err := os.Stat(binPath) if err != nil { - return fmt.Errorf("stat binary: %w", err) + cleanup() + return vendorFileBundle{}, func() {}, fmt.Errorf("stat binary: %w", err) } const maxVendoredBinarySize = 100 * 1024 * 1024 if info.Size() > maxVendoredBinarySize { - return fmt.Errorf("binary is %d bytes, exceeds %d byte limit", info.Size(), maxVendoredBinarySize) + cleanup() + return vendorFileBundle{}, func() {}, fmt.Errorf("binary is %d bytes, exceeds %d byte limit", info.Size(), maxVendoredBinarySize) } binData, err := os.ReadFile(binPath) if err != nil { - return fmt.Errorf("reading binary: %w", err) + cleanup() + return vendorFileBundle{}, func() {}, fmt.Errorf("reading binary: %w", err) } assets, err := scaffold.CollectVendoredAssets(root.Path, pathPrefix) if err != nil { printer.StepFail("Failed to collect vendored content") - return fmt.Errorf("collecting vendored content: %w", err) + cleanup() + return vendorFileBundle{}, func() {}, fmt.Errorf("collecting vendored content: %w", err) } manifest := scaffold.NewVendorManifest(version, fullsendSource, destPath, scaffold.PathsFromInstallFiles(assets)) manifestYAML, err := manifest.MarshalYAML() if err != nil { - return fmt.Errorf("building vendor manifest: %w", err) + cleanup() + return vendorFileBundle{}, func() {}, fmt.Errorf("building vendor manifest: %w", err) } files := []forge.TreeFile{{ @@ -134,15 +181,25 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin Mode: "100644", }) - printer.StepStart(fmt.Sprintf("Uploading vendored binary and %d content files", len(assets)+1)) - contentMsg := layers.VendorContentCommitMessage(version, pathPrefix, len(files)) - committed, err := client.CommitFiles(ctx, owner, repo, contentMsg, files) + return vendorFileBundle{files: files, assetCount: len(assets)}, cleanup, nil +} + +func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, fullsendBinary, fullsendSource string) error { + bundle, cleanup, err := prepareVendorFiles(printer, owner, repo, fullsendBinary, fullsendSource) + if err != nil { + return err + } + defer cleanup() + + printer.StepStart(fmt.Sprintf("Uploading vendored binary and %d content files", bundle.assetCount+1)) + contentMsg := layers.VendorContentCommitMessage(version, vendorPathPrefix(owner, repo), len(bundle.files)) + committed, err := client.CommitFiles(ctx, owner, repo, contentMsg, bundle.files) if err != nil { printer.StepFail("Failed to upload vendored content") return fmt.Errorf("committing vendored content: %w", err) } if committed { - printer.StepDone(fmt.Sprintf("Uploaded vendored binary and %d content files", len(assets))) + printer.StepDone(fmt.Sprintf("Uploaded vendored binary and %d content files", bundle.assetCount)) } else { printer.StepDone("Vendored content up to date") } @@ -150,6 +207,13 @@ func acquireAndVendor(ctx context.Context, client forge.Client, printer *ui.Prin return nil } +func vendorPathPrefix(owner, repo string) string { + if repo != forge.ConfigRepoName { + return ".fullsend/" + } + return "" +} + func removeStaleVendoredAssets(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string, perRepo bool) error { pathPrefix := "" if perRepo { diff --git a/internal/layers/enrollment.go b/internal/layers/enrollment.go index ed31593774..cc7fbc1066 100644 --- a/internal/layers/enrollment.go +++ b/internal/layers/enrollment.go @@ -3,6 +3,7 @@ package layers import ( "context" "fmt" + "strings" "time" "github.com/fullsend-ai/fullsend/internal/forge" @@ -14,6 +15,10 @@ const ( // repoMaintenanceWorkflow is the workflow file that handles enrollment. repoMaintenanceWorkflow = "repo-maintenance.yml" + + workflowDispatchRetryAttempts = 12 + workflowDispatchRetryInitial = 3 * time.Second + workflowDispatchRetryMax = 15 * time.Second ) // EnrollmentLayer monitors workflow-driven enrollment of target repos. @@ -72,8 +77,7 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error { dispatchTime := time.Now().UTC().Add(-30 * time.Second) l.ui.StepStart("dispatching repo-maintenance workflow for enrollment") - err := l.client.DispatchWorkflow(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow, "main", nil) - if err != nil { + if err := l.dispatchRepoMaintenanceWithRetry(ctx); err != nil { return fmt.Errorf("dispatching repo-maintenance: %w", err) } l.ui.StepDone("dispatched repo-maintenance workflow") @@ -100,6 +104,44 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error { return nil } +func (l *EnrollmentLayer) dispatchRepoMaintenanceWithRetry(ctx context.Context) error { + delay := workflowDispatchRetryInitial + var lastErr error + + for attempt := range workflowDispatchRetryAttempts { + if attempt > 0 { + l.ui.StepInfo(fmt.Sprintf("workflow dispatch not ready, retrying in %s (attempt %d/%d)", delay, attempt+1, workflowDispatchRetryAttempts)) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + delay += workflowDispatchRetryInitial + if delay > workflowDispatchRetryMax { + delay = workflowDispatchRetryMax + } + } + + lastErr = l.client.DispatchWorkflow(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow, "main", nil) + if lastErr == nil { + return nil + } + if !isWorkflowDispatchNotReady(lastErr) { + return lastErr + } + } + + return lastErr +} + +func isWorkflowDispatchNotReady(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "422") && strings.Contains(msg, "workflow_dispatch") +} + // awaitWorkflowRun polls for a repo-maintenance workflow run created after // dispatchTime and waits for it to complete. func (l *EnrollmentLayer) awaitWorkflowRun(ctx context.Context, dispatchTime time.Time) (*forge.WorkflowRun, error) { diff --git a/internal/layers/enrollment_test.go b/internal/layers/enrollment_test.go index db56277baa..fd2810279e 100644 --- a/internal/layers/enrollment_test.go +++ b/internal/layers/enrollment_test.go @@ -118,6 +118,53 @@ func TestEnrollmentLayer_Install_NoRepos(t *testing.T) { assert.Contains(t, output, "no repositories to reconcile") } +func TestEnrollmentLayer_Install_DispatchRetry(t *testing.T) { + now := time.Now().UTC() + client := &dispatchRetryClient{ + FakeClient: forge.FakeClient{ + WorkflowRuns: map[string]*forge.WorkflowRun{ + "test-org/.fullsend/repo-maintenance.yml": { + ID: 1, + Status: "completed", + Conclusion: "success", + CreatedAt: now.Add(time.Minute).Format(time.RFC3339), + HTMLURL: "https://github.com/test-org/.fullsend/actions/runs/1", + }, + }, + }, + failUntil: 2, + } + repos := []string{"repo-a"} + layer, buf := newEnrollmentLayer(t, client, repos, nil) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Equal(t, 3, client.attempts) + output := buf.String() + assert.Contains(t, output, "retrying") + assert.Contains(t, output, "dispatched repo-maintenance workflow") +} + +type dispatchRetryClient struct { + forge.FakeClient + failUntil int + attempts int +} + +func (c *dispatchRetryClient) DispatchWorkflow(_ context.Context, _, _, _, _ string, _ map[string]string) error { + c.attempts++ + if c.attempts <= c.failUntil { + return fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 422 Workflow does not have 'workflow_dispatch' trigger") + } + return nil +} + +func TestIsWorkflowDispatchNotReady(t *testing.T) { + assert.True(t, isWorkflowDispatchNotReady(fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 422 Workflow does not have 'workflow_dispatch' trigger"))) + assert.False(t, isWorkflowDispatchNotReady(fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 403 Forbidden"))) + assert.False(t, isWorkflowDispatchNotReady(nil)) +} + func TestEnrollmentLayer_Install_DispatchError(t *testing.T) { client := &forge.FakeClient{ Errors: map[string]error{ diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index 0f5e9d11a8..cab2c25983 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -13,6 +13,10 @@ import ( // VendorFunc uploads vendored binary and content when --vendor is set. type VendorFunc func(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo string) error +// VendorCollectFunc gathers vendored tree files without committing. +// Used to combine scaffold and vendor assets in a single CommitFiles call. +type VendorCollectFunc func(ctx context.Context, printer *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) + // VendorBinaryLayer manages vendored binary and content assets. // The type name retains "Binary" from when the layer only uploaded the CLI // binary; it now vendors the full stack (workflows, actions, agent content). @@ -26,6 +30,7 @@ type VendorBinaryLayer struct { ui *ui.Printer enabled bool vendorFn VendorFunc + combinedWithScaffold bool analyzeFullsendSource string cliVersion string } @@ -51,6 +56,11 @@ func (l *VendorBinaryLayer) SetAnalyzeOptions(fullsendSource, cliVersion string) l.cliVersion = cliVersion } +// SetCombinedWithScaffold marks vendored assets as already committed by WorkflowsLayer. +func (l *VendorBinaryLayer) SetCombinedWithScaffold(combined bool) { + l.combinedWithScaffold = combined +} + func (l *VendorBinaryLayer) Name() string { return "vendor" } func (l *VendorBinaryLayer) binaryPath() string { @@ -84,6 +94,9 @@ func (l *VendorBinaryLayer) RequiredScopes(op Operation) []string { // Install either vendors assets (when enabled) or removes stale ones. func (l *VendorBinaryLayer) Install(ctx context.Context) error { if l.enabled { + if l.combinedWithScaffold { + return nil + } if l.vendorFn == nil { return fmt.Errorf("vendor function not configured") } diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index d9806d1ad6..0cd3f5d665 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -36,6 +36,22 @@ func TestVendorBinaryLayer_RequiredScopes(t *testing.T) { assert.Nil(t, layer.RequiredScopes(OpAnalyze)) } +func TestVendorBinaryLayer_CombinedWithScaffold_SkipsVendorFn(t *testing.T) { + client := &forge.FakeClient{} + called := false + vendorFn := func(ctx context.Context, c forge.Client, p *ui.Printer, owner, repo string) error { + called = true + return nil + } + + layer, _ := newVendorBinaryLayer(t, client, true, vendorFn) + layer.SetCombinedWithScaffold(true) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.False(t, called, "vendor function should be skipped when combined with scaffold") +} + func TestVendorBinaryLayer_EnabledCallsVendorFn(t *testing.T) { client := &forge.FakeClient{} called := false diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 186264f981..fd1ccd49af 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -20,6 +20,7 @@ type WorkflowsLayer struct { authenticatedUser string version string vendored bool + vendorCollect VendorCollectFunc } var _ Layer = (*WorkflowsLayer)(nil) @@ -36,6 +37,12 @@ func NewWorkflowsLayer(org string, client forge.Client, printer *ui.Printer, use } } +// WithVendorCollect configures combined scaffold+vendor commits for --vendor installs. +func (l *WorkflowsLayer) WithVendorCollect(fn VendorCollectFunc) *WorkflowsLayer { + l.vendorCollect = fn + return l +} + func (l *WorkflowsLayer) Name() string { return "workflows" } func (l *WorkflowsLayer) RequiredScopes(op Operation) []string { @@ -77,15 +84,34 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { Mode: "100644", }) - l.ui.StepStart("Writing scaffold files") - committed, err := l.client.CommitFiles(ctx, l.org, forge.ConfigRepoName, - fmt.Sprintf("chore: update fullsend-%s scaffold", l.version), files) + vendorAssetCount := 0 + if l.vendored && l.vendorCollect != nil { + vendorFiles, count, err := l.vendorCollect(ctx, l.ui, l.org, forge.ConfigRepoName) + if err != nil { + return fmt.Errorf("collecting vendored assets: %w", err) + } + files = append(files, vendorFiles...) + vendorAssetCount = count + } + + commitMsg := fmt.Sprintf("chore: update fullsend-%s scaffold", l.version) + if vendorAssetCount > 0 { + commitMsg = fmt.Sprintf("chore: update fullsend-%s scaffold with vendored assets", l.version) + l.ui.StepStart(fmt.Sprintf("Writing scaffold and vendored assets (%d content files)", vendorAssetCount)) + } else { + l.ui.StepStart("Writing scaffold files") + } + committed, err := l.client.CommitFiles(ctx, l.org, forge.ConfigRepoName, commitMsg, files) if err != nil { l.ui.StepFail("Failed to write scaffold files") return fmt.Errorf("committing scaffold files: %w", err) } if committed { - l.ui.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + if vendorAssetCount > 0 { + l.ui.StepDone(fmt.Sprintf("Wrote %d scaffold files and vendored binary (%d content files)", len(files), vendorAssetCount)) + } else { + l.ui.StepDone(fmt.Sprintf("Wrote %d files", len(files))) + } } else { l.ui.StepDone("Scaffold up to date") } diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index adec3d6cbf..97318d32e2 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -75,6 +75,32 @@ func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { assert.NotContains(t, triageContent, "fullsend_ai_repo:") } +func TestWorkflowsLayer_Install_CombinedVendorCommit(t *testing.T) { + client := forge.NewFakeClient() + collectFn := func(_ context.Context, _ *ui.Printer, owner, repo string) ([]forge.TreeFile, int, error) { + assert.Equal(t, "test-org", owner) + assert.Equal(t, forge.ConfigRepoName, repo) + return []forge.TreeFile{ + {Path: "bin/fullsend", Content: []byte("bin"), Mode: "100755"}, + {Path: ".defaults/action.yml", Content: []byte("marker"), Mode: "100644"}, + }, 1, nil + } + layer := NewWorkflowsLayer("test-org", client, ui.New(&bytes.Buffer{}), "admin-user", "test-version", true) + layer = layer.WithVendorCollect(collectFn) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CommittedFiles, 1) + paths := make(map[string]struct{}) + for _, f := range client.CommittedFiles[0].Files { + paths[f.Path] = struct{}{} + } + assert.Contains(t, paths, ".github/workflows/triage.yml") + assert.Contains(t, paths, "bin/fullsend") + assert.Contains(t, paths, ".defaults/action.yml") +} + func TestWorkflowsLayer_Install_VendoredUsesLocalReusablePaths(t *testing.T) { client := forge.NewFakeClient() layer, _ := newWorkflowsLayer(t, client, true) From 1d3da39b15c1b3c40ce11336d3bfc9e706d87cbf Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 14:31:20 +0300 Subject: [PATCH 17/30] fix(install): wait for workflow registration and activate repo-maintenance Poll GitHub until repo-maintenance.yml is active before dispatch, re-touch config.yaml after scaffold so the push trigger can run enrollment when dispatch is still rejected, and fall back to awaiting a push-triggered run. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/fake.go | 23 ++++++++++++ internal/forge/forge.go | 9 +++++ internal/forge/github/github.go | 25 +++++++++++++ internal/forge/github/github_test.go | 23 ++++++++++++ internal/layers/enrollment.go | 56 ++++++++++++++++++++++++++-- internal/layers/enrollment_test.go | 41 ++++++++++++++++++++ internal/layers/workflows.go | 21 +++++++++++ internal/layers/workflows_test.go | 16 ++++++++ 8 files changed, 210 insertions(+), 4 deletions(-) diff --git a/internal/forge/fake.go b/internal/forge/fake.go index 9bb9c4daf5..e151209872 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -105,6 +105,7 @@ type FakeClient struct { Repos []Repository FileContents map[string][]byte // key: "owner/repo/path" WorkflowRuns map[string]*WorkflowRun // key: "owner/repo/workflow" + Workflows map[string]*Workflow // key: "owner/repo/workflow" AuthenticatedUser string OrgPlan string // plan name returned by GetOrgPlan (default: "free") Installations []Installation @@ -681,6 +682,28 @@ func (f *FakeClient) GetRepoVariable(_ context.Context, owner, repo, name string return "", false, nil } +func (f *FakeClient) GetWorkflow(_ context.Context, owner, repo, workflowFile string) (*Workflow, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if e := f.err("GetWorkflow"); e != nil { + return nil, e + } + + key := owner + "/" + repo + "/" + workflowFile + if f.Workflows != nil { + if wf, ok := f.Workflows[key]; ok { + return wf, nil + } + } + + return &Workflow{ + Name: workflowFile, + Path: ".github/workflows/" + workflowFile, + State: "active", + }, nil +} + func (f *FakeClient) GetLatestWorkflowRun(_ context.Context, owner, repo, workflowFile string) (*WorkflowRun, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 297ad6eda3..3a17d5dddf 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -52,6 +52,14 @@ type WorkflowRun struct { CreatedAt string } +// Workflow represents a workflow definition registered with the forge. +type Workflow struct { + ID int + Name string + Path string + State string // "active", "disabled", etc. +} + // Annotation represents a check-run annotation (e.g. from ::notice:: or // ::warning:: workflow commands). type Annotation struct { @@ -240,6 +248,7 @@ type Client interface { GetOrgVariableRepos(ctx context.Context, org, name string) ([]int64, error) // CI/Workflow operations + GetWorkflow(ctx context.Context, owner, repo, workflowFile string) (*Workflow, error) GetLatestWorkflowRun(ctx context.Context, owner, repo, workflowFile string) (*WorkflowRun, error) GetWorkflowRun(ctx context.Context, owner, repo string, runID int) (*WorkflowRun, error) DispatchWorkflow(ctx context.Context, owner, repo, workflowFile, ref string, inputs map[string]string) error diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 04fb10abbf..992b10875a 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -1413,6 +1413,31 @@ func (c *LiveClient) GetRepoVariable(ctx context.Context, owner, repo, name stri return result.Value, true, nil } +// GetWorkflow returns a workflow definition by filename (e.g. repo-maintenance.yml). +func (c *LiveClient) GetWorkflow(ctx context.Context, owner, repo, workflowFile string) (*forge.Workflow, error) { + resp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/actions/workflows/%s", owner, repo, workflowFile)) + if err != nil { + return nil, fmt.Errorf("get workflow %s: %w", workflowFile, err) + } + + var wf struct { + ID int `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` + } + if err := decodeJSON(resp, &wf); err != nil { + return nil, fmt.Errorf("decode workflow %s: %w", workflowFile, err) + } + + return &forge.Workflow{ + ID: wf.ID, + Name: wf.Name, + Path: wf.Path, + State: wf.State, + }, nil +} + // GetLatestWorkflowRun returns the most recent workflow run for a workflow file. func (c *LiveClient) GetLatestWorkflowRun(ctx context.Context, owner, repo, workflowFile string) (*forge.WorkflowRun, error) { resp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/runs?per_page=1", owner, repo, workflowFile)) diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 1dc8f3e410..1d6cfd280a 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -489,6 +489,29 @@ func TestCreateOrUpdateRepoVariable_FallbackToPost(t *testing.T) { require.NoError(t, err) } +func TestGetWorkflow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/repos/owner/repo/actions/workflows/repo-maintenance.yml", r.URL.Path) + + json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "name": "Repo Maintenance", + "path": ".github/workflows/repo-maintenance.yml", + "state": "active", + }) + })) + defer srv.Close() + + client := newTestClient(t, srv) + wf, err := client.GetWorkflow(context.Background(), "owner", "repo", "repo-maintenance.yml") + require.NoError(t, err) + assert.Equal(t, 42, wf.ID) + assert.Equal(t, "Repo Maintenance", wf.Name) + assert.Equal(t, ".github/workflows/repo-maintenance.yml", wf.Path) + assert.Equal(t, "active", wf.State) +} + func TestGetLatestWorkflowRun(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "GET", r.Method) diff --git a/internal/layers/enrollment.go b/internal/layers/enrollment.go index cc7fbc1066..27486d9046 100644 --- a/internal/layers/enrollment.go +++ b/internal/layers/enrollment.go @@ -16,7 +16,10 @@ const ( // repoMaintenanceWorkflow is the workflow file that handles enrollment. repoMaintenanceWorkflow = "repo-maintenance.yml" - workflowDispatchRetryAttempts = 12 + workflowRegistrationMaxWait = 5 * time.Minute + workflowRegistrationPoll = 5 * time.Second + + workflowDispatchRetryAttempts = 24 workflowDispatchRetryInitial = 3 * time.Second workflowDispatchRetryMax = 15 * time.Second ) @@ -77,14 +80,25 @@ func (l *EnrollmentLayer) Install(ctx context.Context) error { dispatchTime := time.Now().UTC().Add(-30 * time.Second) l.ui.StepStart("dispatching repo-maintenance workflow for enrollment") - if err := l.dispatchRepoMaintenanceWithRetry(ctx); err != nil { - return fmt.Errorf("dispatching repo-maintenance: %w", err) + if err := l.awaitWorkflowRegistration(ctx); err != nil { + return fmt.Errorf("waiting for repo-maintenance workflow: %w", err) + } + dispatchErr := l.dispatchRepoMaintenanceWithRetry(ctx) + if dispatchErr != nil { + if !isWorkflowDispatchNotReady(dispatchErr) { + return fmt.Errorf("dispatching repo-maintenance: %w", dispatchErr) + } + l.ui.StepWarn(fmt.Sprintf("workflow dispatch failed (%v); waiting for push-triggered run", dispatchErr)) + } else { + l.ui.StepDone("dispatched repo-maintenance workflow") } - l.ui.StepDone("dispatched repo-maintenance workflow") // Wait for the workflow run to complete. run, err := l.awaitWorkflowRun(ctx, dispatchTime) if err != nil { + if dispatchErr != nil { + return fmt.Errorf("dispatching repo-maintenance: %w", dispatchErr) + } l.ui.StepWarn(fmt.Sprintf("could not confirm enrollment: %v", err)) l.ui.StepInfo("check the repo-maintenance workflow in .fullsend for results") return nil // non-fatal — enrollment may still succeed @@ -134,6 +148,40 @@ func (l *EnrollmentLayer) dispatchRepoMaintenanceWithRetry(ctx context.Context) return lastErr } +func (l *EnrollmentLayer) awaitWorkflowRegistration(ctx context.Context) error { + deadline := time.Now().Add(workflowRegistrationMaxWait) + attempt := 0 + + for { + attempt++ + wf, err := l.client.GetWorkflow(ctx, l.org, forge.ConfigRepoName, repoMaintenanceWorkflow) + if err == nil && wf.State == "active" { + if attempt > 1 { + l.ui.StepInfo(fmt.Sprintf("repo-maintenance workflow registered (state: active, attempt %d)", attempt)) + } + return nil + } + if err != nil && !forge.IsNotFound(err) { + return fmt.Errorf("checking repo-maintenance workflow registration: %w", err) + } + + if time.Now().After(deadline) { + state := "not found" + if wf != nil { + state = wf.State + } + return fmt.Errorf("repo-maintenance workflow not ready after %s (last state: %s)", workflowRegistrationMaxWait, state) + } + + l.ui.StepInfo(fmt.Sprintf("waiting for repo-maintenance workflow registration (attempt %d)...", attempt)) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(workflowRegistrationPoll): + } + } +} + func isWorkflowDispatchNotReady(err error) bool { if err == nil { return false diff --git a/internal/layers/enrollment_test.go b/internal/layers/enrollment_test.go index fd2810279e..7935cbe6e2 100644 --- a/internal/layers/enrollment_test.go +++ b/internal/layers/enrollment_test.go @@ -415,3 +415,44 @@ func TestEnrollmentLayer_Analyze_PerRepoGuardCheckError(t *testing.T) { assert.Contains(t, report.Details[0], "all 1 repos failed guard check") assert.Contains(t, report.Details[1], "guard check failed, skipped") } + +func TestEnrollmentLayer_Install_WorkflowRegistrationWait(t *testing.T) { + now := time.Now().UTC() + client := ®istrationWaitClient{ + FakeClient: forge.FakeClient{ + WorkflowRuns: map[string]*forge.WorkflowRun{ + "test-org/.fullsend/repo-maintenance.yml": { + ID: 1, + Status: "completed", + Conclusion: "success", + CreatedAt: now.Add(time.Minute).Format(time.RFC3339), + }, + }, + }, + activeAfter: 2, + } + layer, buf := newEnrollmentLayer(t, client, []string{"repo-a"}, nil) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Equal(t, 2, client.getAttempts) + assert.Contains(t, buf.String(), "waiting for repo-maintenance workflow registration") +} + +type registrationWaitClient struct { + forge.FakeClient + activeAfter int + getAttempts int +} + +func (c *registrationWaitClient) GetWorkflow(_ context.Context, _, _, _ string) (*forge.Workflow, error) { + c.getAttempts++ + if c.getAttempts < c.activeAfter { + return nil, forge.ErrNotFound + } + return &forge.Workflow{ + Name: repoMaintenanceWorkflow, + Path: ".github/workflows/" + repoMaintenanceWorkflow, + State: "active", + }, nil +} diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index fd1ccd49af..255b3dc2f2 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -116,6 +116,27 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { l.ui.StepDone("Scaffold up to date") } + if committed { + if err := l.activateRepoMaintenance(ctx); err != nil { + l.ui.StepWarn(fmt.Sprintf("could not activate repo-maintenance workflow: %v", err)) + } + } + + return nil +} + +func (l *WorkflowsLayer) activateRepoMaintenance(ctx context.Context) error { + content, err := l.client.GetFileContent(ctx, l.org, forge.ConfigRepoName, configFilePath) + if err != nil { + return fmt.Errorf("reading %s: %w", configFilePath, err) + } + + l.ui.StepStart("Activating repo-maintenance workflow") + if err := l.client.CreateOrUpdateFile(ctx, l.org, forge.ConfigRepoName, configFilePath, "chore: activate fullsend workflows", content); err != nil { + l.ui.StepFail("Failed to activate repo-maintenance workflow") + return fmt.Errorf("writing %s: %w", configFilePath, err) + } + l.ui.StepDone("Activated repo-maintenance workflow") return nil } diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index 97318d32e2..9f940a84c6 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -52,6 +52,22 @@ func TestWorkflowsLayer_Install_WritesAllFiles(t *testing.T) { assert.Contains(t, paths, ".github/workflows/repo-maintenance.yml") assert.Contains(t, paths, "CODEOWNERS") assert.Contains(t, paths["CODEOWNERS"], "admin-user") + + require.Len(t, client.CreatedFiles, 0, "config activation requires config.yaml in repo") +} + +func TestWorkflowsLayer_Install_ActivatesRepoMaintenance(t *testing.T) { + client := forge.NewFakeClient() + client.FileContents["test-org/.fullsend/config.yaml"] = []byte("repos: {}\n") + layer, buf := newWorkflowsLayer(t, client, false) + + err := layer.Install(context.Background()) + require.NoError(t, err) + + require.Len(t, client.CreatedFiles, 1) + assert.Equal(t, "config.yaml", client.CreatedFiles[0].Path) + assert.Equal(t, "chore: activate fullsend workflows", client.CreatedFiles[0].Message) + assert.Contains(t, buf.String(), "Activated repo-maintenance workflow") } func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { From 73dea4523fc7e7d3a7b5b62ffeff8d783f6ca4dd Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 11 Jun 2026 15:05:26 +0300 Subject: [PATCH 18/30] fix(forge): write text files as UTF-8 in CommitFiles, blob API for binary Tree entries with encoding:base64 stored base64 text literally on GitHub, corrupting YAML workflows and vendor-manifest.yaml. Restore UTF-8 inline content for text and upload binary via the Git Blob API instead. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/github.go | 55 +++++++++++++++++++++++----- internal/forge/github/github_test.go | 24 +++++++++--- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 992b10875a..269874b864 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/fullsend-ai/fullsend/internal/forge" "golang.org/x/crypto/nacl/box" @@ -599,8 +600,8 @@ func isTransientStatus(code int) bool { // CommitFiles atomically commits multiple files to the default branch // using the Git Trees/Blobs/Commits API. Returns (false, nil) when // all files already match the current tree (idempotent). -// Tree entries use base64 encoding so binary content (e.g. vendored ELF) -// is not corrupted by JSON UTF-8 replacement. +// Text files are embedded as UTF-8 tree content. Binary files (e.g. +// vendored ELF) are uploaded via the Git Blob API and referenced by SHA. func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message string, files []forge.TreeFile) (bool, error) { if len(files) == 0 { return false, nil @@ -689,16 +690,32 @@ func (c *LiveClient) CommitFiles(ctx context.Context, owner, repo, message strin var changedEntries []map[string]any for _, f := range files { expectedSHA := blobSHA(f.Content) - if info, ok := existing[f.Path]; ok && info.sha == expectedSHA && info.mode == f.Mode { + info, exists := existing[f.Path] + if exists && info.sha == expectedSHA && info.mode == f.Mode { continue } - changedEntries = append(changedEntries, map[string]any{ - "path": f.Path, - "mode": f.Mode, - "type": "blob", - "encoding": "base64", - "content": base64.StdEncoding.EncodeToString(f.Content), - }) + + entry := map[string]any{ + "path": f.Path, + "mode": f.Mode, + "type": "blob", + } + if utf8.Valid(f.Content) { + entry["content"] = string(f.Content) + } else { + blobSHAValue := expectedSHA + if exists && info.sha == expectedSHA { + blobSHAValue = info.sha + } else { + createdSHA, err := c.createBlob(ctx, owner, repo, f.Content) + if err != nil { + return false, fmt.Errorf("create blob for %s: %w", f.Path, err) + } + blobSHAValue = createdSHA + } + entry["sha"] = blobSHAValue + } + changedEntries = append(changedEntries, entry) } if len(changedEntries) == 0 { @@ -899,6 +916,24 @@ func blobSHA(content []byte) string { return fmt.Sprintf("%x", h.Sum(nil)) } +func (c *LiveClient) createBlob(ctx context.Context, owner, repo string, content []byte) (string, error) { + payload := map[string]string{ + "content": base64.StdEncoding.EncodeToString(content), + "encoding": "base64", + } + resp, err := c.post(ctx, fmt.Sprintf("/repos/%s/%s/git/blobs", owner, repo), payload) + if err != nil { + return "", fmt.Errorf("create blob: %w", err) + } + var blob struct { + SHA string `json:"sha"` + } + if err := decodeJSON(resp, &blob); err != nil { + return "", fmt.Errorf("decode blob: %w", err) + } + return blob.SHA, nil +} + // GetFileContent retrieves the content of a file from a repository. func (c *LiveClient) GetFileContent(ctx context.Context, owner, repo, path string) ([]byte, error) { resp, err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, path)) diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 1d6cfd280a..4b575fb8f9 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1290,6 +1290,11 @@ func TestCommitFiles_AllNew(t *testing.T) { assert.Equal(t, "tree000", body["base_tree"]) entries := body["tree"].([]any) assert.Len(t, entries, 2) + for _, raw := range entries { + entry := raw.(map[string]any) + assert.NotContains(t, entry, "encoding") + assert.IsType(t, "", entry["content"]) + } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"sha": "newtree"}) @@ -1326,8 +1331,9 @@ func TestCommitFiles_AllNew(t *testing.T) { assert.True(t, committed) } -func TestCommitFiles_BinaryUsesBase64Encoding(t *testing.T) { +func TestCommitFiles_BinaryUsesBlobAPI(t *testing.T) { binaryContent := []byte{0x7f, 0x45, 0x4c, 0x46, 0xff, 0xfe, 0x00} + blobSHAValue := blobSHA(binaryContent) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -1339,16 +1345,24 @@ func TestCommitFiles_BinaryUsesBase64Encoding(t *testing.T) { json.NewEncoder(w).Encode(map[string]any{"tree": map[string]string{"sha": "tree000"}}) case r.Method == "GET" && r.URL.Path == "/repos/org/repo/git/trees/tree000": json.NewEncoder(w).Encode(map[string]any{"tree": []any{}, "truncated": false}) + case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/blobs": + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "base64", body["encoding"]) + decoded, err := base64.StdEncoding.DecodeString(body["content"]) + require.NoError(t, err) + assert.Equal(t, binaryContent, decoded) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"sha": blobSHAValue}) case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/trees": var body map[string]any require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) entries := body["tree"].([]any) require.Len(t, entries, 1) entry := entries[0].(map[string]any) - assert.Equal(t, "base64", entry["encoding"]) - decoded, err := base64.StdEncoding.DecodeString(entry["content"].(string)) - require.NoError(t, err) - assert.Equal(t, binaryContent, decoded) + assert.Equal(t, blobSHAValue, entry["sha"]) + assert.NotContains(t, entry, "content") + assert.NotContains(t, entry, "encoding") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"sha": "newtree"}) case r.Method == "POST" && r.URL.Path == "/repos/org/repo/git/commits": From d1baca8c8277f3d82213fde5f8f243c4eecb9c20 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 14 Jun 2026 20:20:25 +0300 Subject: [PATCH 19/30] fix(docs): renumber vendored-install ADR to 0047 after main merge Main added ADR 0046 for host-side API server design; resolve the number collision and fix the installation guide link path. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/ADRs/0035-layered-content-resolution.md | 2 +- ...-flag.md => 0047-vendored-installs-with-vendor-flag.md} | 7 ++++--- docs/architecture.md | 4 ++-- docs/guides/dev/testing-workflows.md | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) rename docs/ADRs/{0046-vendored-installs-with-vendor-flag.md => 0047-vendored-installs-with-vendor-flag.md} (95%) diff --git a/docs/ADRs/0035-layered-content-resolution.md b/docs/ADRs/0035-layered-content-resolution.md index 6f1e03a1da..ba86c0a181 100644 --- a/docs/ADRs/0035-layered-content-resolution.md +++ b/docs/ADRs/0035-layered-content-resolution.md @@ -65,7 +65,7 @@ caller-controlled ref), copies them into the main dirs (`agents/`, `skills/`, etc.), then copies customizations on top so override files replace upstream defaults. When `--vendor` has committed upstream mirror content under `.defaults/`, the sparse checkout is skipped (see -[ADR 0046](0046-vendored-installs-with-vendor-flag.md)). The workflow inspects `install_mode` to resolve the correct +[ADR 0047](0047-vendored-installs-with-vendor-flag.md)). The workflow inspects `install_mode` to resolve the correct customization base: - `per-org`: reads from `customized/` diff --git a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md similarity index 95% rename from docs/ADRs/0046-vendored-installs-with-vendor-flag.md rename to docs/ADRs/0047-vendored-installs-with-vendor-flag.md index 2a033f885b..a8caef4095 100644 --- a/docs/ADRs/0046-vendored-installs-with-vendor-flag.md +++ b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md @@ -1,5 +1,5 @@ --- -title: "46. Vendored installs with --vendor" +title: "47. Vendored installs with --vendor" status: Accepted relates_to: - testing-agents @@ -9,7 +9,7 @@ topics: - workflows --- -# ADR 0046: Vendored installs with `--vendor` +# ADR 0047: Vendored installs with `--vendor` ## Status @@ -109,7 +109,8 @@ dropped in favor of `--vendor` plus runtime marker detection: ## References -- [Installation guide](../guides/getting-started/installation.md) +- [Installation guide](../reference/installation.md) - [Testing workflows](../guides/dev/testing-workflows.md) - ADR 0031 (reusable workflows for distribution) - ADR 0033 (per-repo installation mode) +- ADR 0035 (layered content resolution) diff --git a/docs/architecture.md b/docs/architecture.md index 87e8b2178c..3dd0e82284 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Infrastructure platform choice and configuration are specified in the adopting o - Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)). - Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos. - Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants. -- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). **`--vendor`** ([ADR 0046](ADRs/0046-vendored-installs-with-vendor-flag.md)) commits workflows and agent content at install time; layered installs (default) fetch upstream at runtime. +- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)). **`--vendor`** ([ADR 0047](ADRs/0047-vendored-installs-with-vendor-flag.md)) commits workflows and agent content at install time; layered installs (default) fetch upstream at runtime. - Event-driven stage dispatch: eliminate `workflow_dispatch` + `gh workflow run` fan-out from `dispatch.yml` in favor of synchronous `workflow_call` so the dispatched run stays linked to the caller ([ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)). **Open questions:** @@ -348,7 +348,7 @@ See [ADR 0003](ADRs/0003-org-config-repo-convention.md) for the config repo conv harness, policies, scripts) are provided at runtime via sparse checkout of `fullsend-ai/fullsend@v0`, or from vendored files when `--vendor` was used at install (detected via `.defaults/action.yml` — see - [ADR 0046](ADRs/0046-vendored-installs-with-vendor-flag.md)). The + [ADR 0047](ADRs/0047-vendored-installs-with-vendor-flag.md)). The scaffold installs only org-specific files and a `customized/` directory for org overrides. Org files in `customized/` overwrite upstream defaults at runtime ([ADR 0035](ADRs/0035-layered-content-resolution.md)). diff --git a/docs/guides/dev/testing-workflows.md b/docs/guides/dev/testing-workflows.md index 1290f36d79..d274c627c6 100644 --- a/docs/guides/dev/testing-workflows.md +++ b/docs/guides/dev/testing-workflows.md @@ -42,7 +42,7 @@ vendored vs layered mode from `.defaults/action.yml` presence. Runtime skips the upstream sparse checkout when `.defaults/action.yml` is present (vendored install) and stages content from `.defaults/` instead. -See [ADR 0046](../../ADRs/0046-vendored-installs-with-vendor-flag.md) for the +See [ADR 0047](../../ADRs/0047-vendored-installs-with-vendor-flag.md) for the full distribution model. ## Layered installs: pin upstream ref From 47e61b611fc983af9c8518733dc7289b38243fb4 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 14 Jun 2026 20:20:31 +0300 Subject: [PATCH 20/30] fix: address review feedback on dispatch retry and vendor docs Match workflow_dispatch-not-ready errors via APIError status code instead of fragile string parsing; update stale vendored assets wording and cross-reference ADR 0035 in the vendor install ADR. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/cli-internals.md | 2 +- internal/layers/enrollment.go | 9 +++++++-- internal/layers/enrollment_test.go | 12 ++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 91dbaf0b5f..1a724126df 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -258,7 +258,7 @@ Linux binary resolution for `fullsend run` and vendoring lives in `internal/bina | `ResolveForVendor` | Cross-compile → matching release (released CLI only) → fail (no latest) | | `ResolveExplicit` | Validate linux/{arch} ELF for `--fullsend-binary` | -Vendoring commit messages use title + body (upload and stale delete). `admin analyze` reports stale vendored binaries at `bin/fullsend` or `.fullsend/bin/fullsend` without install-intent flags. +Vendoring commit messages use title + body (upload and stale delete). `admin analyze` reports stale vendored assets at `bin/fullsend` or `.fullsend/bin/fullsend` without install-intent flags. --- diff --git a/internal/layers/enrollment.go b/internal/layers/enrollment.go index 0cca756b74..9dd6d23a3c 100644 --- a/internal/layers/enrollment.go +++ b/internal/layers/enrollment.go @@ -2,12 +2,14 @@ package layers import ( "context" + "errors" "fmt" "strings" "time" "github.com/fullsend-ai/fullsend/internal/config" "github.com/fullsend-ai/fullsend/internal/forge" + gh "github.com/fullsend-ai/fullsend/internal/forge/github" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -190,8 +192,11 @@ func isWorkflowDispatchNotReady(err error) bool { if err == nil { return false } - msg := err.Error() - return strings.Contains(msg, "422") && strings.Contains(msg, "workflow_dispatch") + var apiErr *gh.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 422 { + return false + } + return strings.Contains(apiErr.Message, "workflow_dispatch") } // awaitWorkflowRun polls for a repo-maintenance workflow run created after diff --git a/internal/layers/enrollment_test.go b/internal/layers/enrollment_test.go index 62c89c284a..bd1a1e6b0e 100644 --- a/internal/layers/enrollment_test.go +++ b/internal/layers/enrollment_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/forge" + gh "github.com/fullsend-ai/fullsend/internal/forge/github" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -160,8 +161,15 @@ func (c *dispatchRetryClient) DispatchWorkflow(_ context.Context, _, _, _, _ str } func TestIsWorkflowDispatchNotReady(t *testing.T) { - assert.True(t, isWorkflowDispatchNotReady(fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 422 Workflow does not have 'workflow_dispatch' trigger"))) - assert.False(t, isWorkflowDispatchNotReady(fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 403 Forbidden"))) + dispatchNotReady := fmt.Errorf("dispatch workflow repo-maintenance.yml: %w", &gh.APIError{ + StatusCode: 422, + Message: "Workflow does not have 'workflow_dispatch' trigger", + }) + assert.True(t, isWorkflowDispatchNotReady(dispatchNotReady)) + assert.False(t, isWorkflowDispatchNotReady(fmt.Errorf("dispatch workflow repo-maintenance.yml: %w", &gh.APIError{ + StatusCode: 403, + Message: "Forbidden", + }))) assert.False(t, isWorkflowDispatchNotReady(nil)) } From 368890ee6b0fbb91cbb99b97aec612c96742d4ec Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 14 Jun 2026 20:24:39 +0300 Subject: [PATCH 21/30] fix(test): wrap dispatch retry stub errors as APIError Align the enrollment dispatch retry test fake with real GitHub client error wrapping so isWorkflowDispatchNotReady matches on status code. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/layers/enrollment_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/layers/enrollment_test.go b/internal/layers/enrollment_test.go index bd1a1e6b0e..d123bd2853 100644 --- a/internal/layers/enrollment_test.go +++ b/internal/layers/enrollment_test.go @@ -155,7 +155,10 @@ type dispatchRetryClient struct { func (c *dispatchRetryClient) DispatchWorkflow(_ context.Context, _, _, _, _ string, _ map[string]string) error { c.attempts++ if c.attempts <= c.failUntil { - return fmt.Errorf("dispatch workflow repo-maintenance.yml: github api: 422 Workflow does not have 'workflow_dispatch' trigger") + return fmt.Errorf("dispatch workflow repo-maintenance.yml: %w", &gh.APIError{ + StatusCode: 422, + Message: "Workflow does not have 'workflow_dispatch' trigger", + }) } return nil } From 5e3d93296b8b8c0ca47ab75cf4ab4615878fa8a6 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 17:37:12 +0300 Subject: [PATCH 22/30] fix(vendor): harden vendoring and address PR review findings Sanitize manifest cleanup paths, skip symlinks during asset collection, cap aggregate tar extraction size, and add tests for previously uncovered vendor paths. Restore hidden --vendor-fullsend-binary alias, fix per-repo vendored marker detection in reusable workflows, and improve repo-maintenance activation messaging. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/reusable-code.yml | 3 +- .github/workflows/reusable-fix.yml | 2 +- .github/workflows/reusable-prioritize.yml | 2 +- .github/workflows/reusable-retro.yml | 2 +- .github/workflows/reusable-review.yml | 2 +- .github/workflows/reusable-triage.yml | 2 +- internal/binary/download.go | 6 ++ internal/binary/download_test.go | 40 ++++++++++++ internal/cli/admin.go | 1 + internal/cli/github.go | 1 + internal/cli/vendor.go | 17 ++++- internal/cli/vendor_test.go | 24 ++++++++ internal/layers/vendor_test.go | 21 +++++++ internal/layers/vendorbinary.go | 4 +- internal/layers/vendorbinary_test.go | 56 +++++++++++++++++ internal/layers/workflows.go | 7 ++- internal/scaffold/vendorcontent.go | 8 ++- internal/scaffold/vendormanifest.go | 52 +++++++++++++++- internal/scaffold/vendormanifest_test.go | 75 +++++++++++++++++++++++ 19 files changed, 309 insertions(+), 16 deletions(-) diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index 4c38f65817..d9efccd7f5 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -56,7 +56,8 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + # Keep in sync with --vendor marker paths (see internal/scaffold/vendorcontent.go VendoredMarkerPath). + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index 2da6630929..89d59392b1 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -68,7 +68,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index 19fe39c37e..8cfac73fbc 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -58,7 +58,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index 9e76086005..805d71a0cd 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index c1f86195ef..7bb502af5b 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index aa51989b37..1070ea3170 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@v6 - name: Checkout upstream defaults - if: hashFiles('.defaults/action.yml') == '' + if: hashFiles('.defaults/action.yml', '.fullsend/.defaults/action.yml') == '' uses: actions/checkout@v6 with: repository: fullsend-ai/fullsend diff --git a/internal/binary/download.go b/internal/binary/download.go index ce6558186b..840401f2f7 100644 --- a/internal/binary/download.go +++ b/internal/binary/download.go @@ -200,6 +200,7 @@ func extractSourceTree(r io.Reader, destDir string) error { tr := tar.NewReader(gz) var rootPrefix string + var totalExtracted int64 for { hdr, err := tr.Next() if err == io.EOF { @@ -252,6 +253,11 @@ func extractSourceTree(r io.Reader, destDir string) error { f.Close() return fmt.Errorf("extracted file %s exceeds maximum size (%d bytes)", rel, maxDownloadSize) } + totalExtracted += n + if totalExtracted > int64(maxDownloadSize) { + f.Close() + return fmt.Errorf("aggregate extracted size exceeds maximum (%d bytes)", maxDownloadSize) + } if err := f.Close(); err != nil { return fmt.Errorf("closing %s: %w", rel, err) } diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 360fddb3d9..90e8dce2f7 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -640,5 +640,45 @@ func TestCopyDirContentsPreservesMode(t *testing.T) { assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) } +func TestPathWithinDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "extract") + require.NoError(t, os.MkdirAll(dir, 0o755)) + + assert.True(t, pathWithinDir(dir, dir)) + assert.True(t, pathWithinDir(dir, filepath.Join(dir, "nested", "file.txt"))) + assert.False(t, pathWithinDir(dir, filepath.Join(filepath.Dir(dir), "escape.txt"))) + assert.False(t, pathWithinDir(dir, "/etc/passwd")) +} + +func TestExtractSourceTreeAggregateSizeLimit(t *testing.T) { + origMax := maxDownloadSize + maxDownloadSize = 512 + t.Cleanup(func() { maxDownloadSize = origMax }) + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + chunk := bytes.Repeat([]byte("x"), 300) + for i := range 3 { + name := fmt.Sprintf("fullsend-repo/part-%d.bin", i) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Typeflag: tar.TypeReg, + Size: int64(len(chunk)), + Mode: 0o644, + })) + _, err := tw.Write(chunk) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + dest := t.TempDir() + err := extractSourceTree(bytes.NewReader(buf.Bytes()), dest) + assert.Error(t, err) + assert.Contains(t, err.Error(), "aggregate extracted size exceeds maximum") +} + // Ensure io is used in download tests. var _ = io.Discard diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 07c928df66..fd89751a42 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -274,6 +274,7 @@ Inference authentication: if err := appsetup.ValidateAppSet(appSet); err != nil { return fmt.Errorf("invalid --app-set: %w", err) } + applyDeprecatedVendorBinaryFlag(cmd, &vendor) if err := validateVendorFlags(vendor, fullsendBinary, fullsendSource); err != nil { return err } diff --git a/internal/cli/github.go b/internal/cli/github.go index 5d3a7a2d7e..ff0e9bdd85 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -91,6 +91,7 @@ values (mint URL, WIF provider, project ID) are provided as flags.`, if err := appsetup.ValidateAppSet(cfg.appSet); err != nil { return fmt.Errorf("invalid --app-set: %w", err) } + applyDeprecatedVendorBinaryFlag(cmd, &cfg.vendor) if err := validateVendorFlags(cfg.vendor, cfg.fullsendBinary, cfg.fullsendSource); err != nil { return err } diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 177b863af4..074151e66f 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -17,10 +17,18 @@ import ( const vendorArch = binary.DefaultArch // Vendor install flags replaced the removed --vendor-fullsend-binary flag (binary-only -// upload). There is no deprecation alias: use --vendor for the full vendored stack, or -// --vendor with --fullsend-binary for an explicit ELF. The only known caller of the old -// flag was our e2e suite, updated in this PR to --vendor. +// upload). A hidden --vendor-fullsend-binary alias sets --vendor and prints a deprecation +// warning for external automation still using the old flag. +func applyDeprecatedVendorBinaryFlag(cmd *cobra.Command, vendor *bool) { + if f := cmd.Flags().Lookup("vendor-fullsend-binary"); f != nil && f.Changed { + legacy, err := cmd.Flags().GetBool("vendor-fullsend-binary") + if err == nil && legacy { + fmt.Fprintln(cmd.ErrOrStderr(), "warning: --vendor-fullsend-binary is deprecated; use --vendor") + *vendor = true + } + } +} func validateVendorFlags(vendor bool, fullsendBinary, fullsendSource string) error { if fullsendBinary != "" && !vendor { return fmt.Errorf("--fullsend-binary requires --vendor") @@ -35,6 +43,9 @@ func addVendorFlags(cmd *cobra.Command, vendor *bool, fullsendBinary, fullsendSo cmd.Flags().BoolVar(vendor, "vendor", false, "vendor binary, reusable workflows, actions, and agent content for CI") cmd.Flags().StringVar(fullsendBinary, "fullsend-binary", "", "path to a Linux fullsend binary to upload when vendoring (default: auto-resolve)") cmd.Flags().StringVar(fullsendSource, "fullsend-source", "", "fullsend source checkout for content and cross-compile (default: auto-detect or GitHub fetch)") + var legacyVendorBinary bool + cmd.Flags().BoolVar(&legacyVendorBinary, "vendor-fullsend-binary", false, "deprecated: use --vendor") + _ = cmd.Flags().MarkHidden("vendor-fullsend-binary") } type vendorFileBundle struct { diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index 4aeeff19a6..d444a72ee6 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -94,3 +94,27 @@ func TestAcquireAndVendor_CheckoutBuild(t *testing.T) { assert.Contains(t, client.CommittedFiles[0].Message, "\n\n") assert.Contains(t, client.CommittedFiles[0].Message, "Source: --vendor install") } + +func TestVendorStackArgs(t *testing.T) { + vendorFn, collectFn := vendorStackArgs(false, "", "") + assert.Nil(t, vendorFn) + assert.Nil(t, collectFn) + + vendorFn, collectFn = vendorStackArgs(true, "", "") + assert.NotNil(t, vendorFn) + assert.NotNil(t, collectFn) +} + +func TestVendorPathPrefix(t *testing.T) { + assert.Equal(t, "", vendorPathPrefix("org", forge.ConfigRepoName)) + assert.Equal(t, ".fullsend/", vendorPathPrefix("org", "my-repo")) +} + +func TestApplyDeprecatedVendorBinaryFlag(t *testing.T) { + cmd := newInstallCmd() + require.NoError(t, cmd.ParseFlags([]string{"--vendor-fullsend-binary"})) + + var vendor bool + applyDeprecatedVendorBinaryFlag(cmd, &vendor) + assert.True(t, vendor) +} diff --git a/internal/layers/vendor_test.go b/internal/layers/vendor_test.go index 4d9e448903..c76c805600 100644 --- a/internal/layers/vendor_test.go +++ b/internal/layers/vendor_test.go @@ -67,3 +67,24 @@ func TestVendorCommitMessage_ReleaseTitle(t *testing.T) { msg := VendorCommitMessage(binary.SourceReleaseDownload, "v0.4.0", "bin/fullsend", 100) assert.True(t, strings.HasPrefix(msg, "chore: vendor fullsend v0.4.0 binary from release")) } + +func TestVendorContentCommitMessage(t *testing.T) { + msg := VendorContentCommitMessage("0.4.0", ".fullsend/", 42) + require.Contains(t, msg, "\n\n") + assert.Contains(t, msg, "CLI version: 0.4.0") + assert.Contains(t, msg, "Prefix: .fullsend/") + assert.Contains(t, msg, "Files: 42") +} + +func TestRemoveStaleContentCommitMessage(t *testing.T) { + msg := RemoveStaleContentCommitMessage(".defaults/action.yml") + require.Contains(t, msg, "\n\n") + assert.Contains(t, msg, "Path: .defaults/action.yml") +} + +func TestRemoveStaleVendoredAssetsCommitMessage(t *testing.T) { + msg := RemoveStaleVendoredAssetsCommitMessage([]string{"bin/fullsend", ".defaults/action.yml"}) + require.Contains(t, msg, "\n\n") + assert.Contains(t, msg, "Paths: 2") + assert.Contains(t, msg, "- bin/fullsend") +} diff --git a/internal/layers/vendorbinary.go b/internal/layers/vendorbinary.go index cab2c25983..4ffd42a08d 100644 --- a/internal/layers/vendorbinary.go +++ b/internal/layers/vendorbinary.go @@ -150,7 +150,7 @@ func (l *VendorBinaryLayer) Analyze(ctx context.Context) (*LayerReport, error) { report.Details = append(report.Details, fmt.Sprintf("vendor manifest present at %s", scaffold.VendorManifestPath(l.workflowPrefix()))) missing, err := scaffold.ComparePathPresence(ctx, l.client, l.org, l.repo, manifest.Paths) if err != nil { - return nil, err + return nil, fmt.Errorf("checking manifest paths: %w", err) } if len(missing) > 0 { manifestMisaligned = true @@ -237,7 +237,7 @@ func (l *VendorBinaryLayer) reportSourceAlignment(ctx context.Context, report *L missing, err := scaffold.ComparePathPresence(ctx, l.client, l.org, l.repo, expected) if err != nil { - return err + return fmt.Errorf("checking source alignment paths: %w", err) } if len(missing) == 0 { report.Details = append(report.Details, "source alignment: ok") diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index 2b74b34c2a..05c495f635 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/fullsend-ai/fullsend/internal/binary" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" @@ -349,3 +350,58 @@ func TestVendorBinaryLayer_PerRepo_EnabledCallsVendorFn(t *testing.T) { require.NoError(t, err) assert.True(t, called, "vendor function should have been called with per-repo args") } + +func TestVendorBinaryLayer_SetAnalyzeOptions_SourceAlignmentOk(t *testing.T) { + modRoot, err := binary.ModuleRoot() + if err != nil { + t.Skip("not in fullsend checkout") + } + + expectedFiles, err := scaffold.CollectVendoredAssets(modRoot, "") + require.NoError(t, err) + + contents := map[string][]byte{ + "test-org/.fullsend/bin/fullsend": []byte("binary"), + } + for _, f := range expectedFiles { + contents["test-org/.fullsend/"+f.Path] = f.Content + } + + layer, _ := newVendorBinaryLayer(t, &forge.FakeClient{FileContents: contents}, true, nil) + layer.SetAnalyzeOptions("", "dev") + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Contains(t, strings.Join(report.Details, " "), "source alignment: ok") +} + +func TestVendorBinaryLayer_SetAnalyzeOptions_SourceAlignmentMissing(t *testing.T) { + modRoot, err := binary.ModuleRoot() + if err != nil { + t.Skip("not in fullsend checkout") + } + + expectedFiles, err := scaffold.CollectVendoredAssets(modRoot, "") + require.NoError(t, err) + require.NotEmpty(t, expectedFiles) + + contents := map[string][]byte{ + "test-org/.fullsend/bin/fullsend": []byte("binary"), + } + // Omit all vendored content paths. + + layer, _ := newVendorBinaryLayer(t, &forge.FakeClient{FileContents: contents}, true, nil) + layer.SetAnalyzeOptions("", "dev") + + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Equal(t, StatusDegraded, report.Status) + assert.Contains(t, strings.Join(report.Details, " "), "source alignment:") +} + +func TestVendorBinaryLayer_SetAnalyzeOptions_SkippedWithoutSource(t *testing.T) { + layer, _ := newVendorBinaryLayer(t, &forge.FakeClient{}, true, nil) + report, err := layer.Analyze(context.Background()) + require.NoError(t, err) + assert.Contains(t, strings.Join(report.Details, " "), "source alignment: skipped") +} diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 8d9921387f..5ed3810526 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -122,7 +122,9 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { if committed { if err := l.activateRepoMaintenance(ctx); err != nil { - l.ui.StepWarn(fmt.Sprintf("could not activate repo-maintenance workflow: %v", err)) + l.ui.StepWarn(fmt.Sprintf( + "repo-maintenance workflow was not activated automatically (%v); manually run repo-maintenance.yml once from %s/%s", + err, l.org, forge.ConfigRepoName)) } } @@ -135,6 +137,9 @@ func (l *WorkflowsLayer) activateRepoMaintenance(ctx context.Context) error { return fmt.Errorf("reading %s: %w", configFilePath, err) } + // GitHub only registers workflow_dispatch handlers after a push touching workflow + // files. Re-writing config.yaml unchanged triggers that push scan without changing + // org configuration content. l.ui.StepStart("Activating repo-maintenance workflow") if err := l.client.CreateOrUpdateFile(ctx, l.org, forge.ConfigRepoName, configFilePath, "chore: activate fullsend workflows", content); err != nil { l.ui.StepFail("Failed to activate repo-maintenance workflow") diff --git a/internal/scaffold/vendorcontent.go b/internal/scaffold/vendorcontent.go index 1acb0d3866..9580ca762b 100644 --- a/internal/scaffold/vendorcontent.go +++ b/internal/scaffold/vendorcontent.go @@ -93,6 +93,9 @@ func walkVendoredUpstreamFromRoot(root string, fn func(path string, content []by if d.IsDir() { return nil } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } rel, err := filepath.Rel(root, path) if err != nil { return err @@ -124,6 +127,9 @@ func walkLayeredFromRoot(layeredRoot string, fn func(path string, content []byte if d.IsDir() { return nil } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } rel, err := filepath.Rel(layeredRoot, path) if err != nil { return err @@ -155,7 +161,7 @@ func isVendoredDefaultsInfra(path string) bool { if strings.HasPrefix(path, ".github/actions/") { return true } - if strings.HasPrefix(path, ".github/scripts/") && path != ".github/scripts/prepare-agent-workspace.sh" { + if strings.HasPrefix(path, ".github/scripts/") { return true } return false diff --git a/internal/scaffold/vendormanifest.go b/internal/scaffold/vendormanifest.go index a825c2b09b..47c79a62b3 100644 --- a/internal/scaffold/vendormanifest.go +++ b/internal/scaffold/vendormanifest.go @@ -3,7 +3,9 @@ package scaffold import ( "context" "fmt" + "path/filepath" "sort" + "strings" "github.com/fullsend-ai/fullsend/internal/forge" "gopkg.in/yaml.v3" @@ -58,9 +60,47 @@ func ParseVendorManifest(data []byte) (*VendorManifest, error) { if m.BinaryPath == "" { return nil, fmt.Errorf("vendor manifest missing binary_path") } + if !isSafeVendoredRepoPath(m.BinaryPath) { + return nil, fmt.Errorf("vendor manifest binary_path %q is not allowed", m.BinaryPath) + } + for _, p := range m.Paths { + if p == "" { + return nil, fmt.Errorf("vendor manifest contains empty path") + } + if !isSafeVendoredRepoPath(p) { + return nil, fmt.Errorf("vendor manifest path %q is not allowed", p) + } + } return &m, nil } +// isSafeVendoredRepoPath rejects path traversal and paths outside vendored layouts. +func isSafeVendoredRepoPath(path string) bool { + if path == "" { + return false + } + p := filepath.ToSlash(filepath.Clean(path)) + if p == "." || strings.HasPrefix(p, "/") || strings.Contains(p, "..") { + return false + } + if p == "action.yml" || p == "vendor-manifest.yaml" { + return true + } + if strings.HasPrefix(p, "bin/") { + return true + } + if strings.HasPrefix(p, ".defaults/") || strings.HasPrefix(p, ".fullsend/") { + return true + } + if strings.HasPrefix(p, ".github/workflows/reusable-") && strings.HasSuffix(p, ".yml") { + return true + } + if strings.HasPrefix(p, ".github/actions/") { + return true + } + return false +} + // CleanupPaths returns all repo paths to delete, including the manifest file. func (m *VendorManifest) CleanupPaths(workflowPrefix string) []string { seen := make(map[string]struct{}, len(m.Paths)+2) @@ -75,10 +115,16 @@ func (m *VendorManifest) CleanupPaths(workflowPrefix string) []string { } for _, p := range m.Paths { - add(p) + if isSafeVendoredRepoPath(p) { + add(p) + } + } + if isSafeVendoredRepoPath(m.BinaryPath) { + add(m.BinaryPath) + } + if manifestPath := VendorManifestPath(workflowPrefix); isSafeVendoredRepoPath(manifestPath) { + add(manifestPath) } - add(m.BinaryPath) - add(VendorManifestPath(workflowPrefix)) out := make([]string, 0, len(seen)) for p := range seen { diff --git a/internal/scaffold/vendormanifest_test.go b/internal/scaffold/vendormanifest_test.go index 39a9e547a5..6deb1ea78a 100644 --- a/internal/scaffold/vendormanifest_test.go +++ b/internal/scaffold/vendormanifest_test.go @@ -43,6 +43,81 @@ func TestVendorManifestCleanupPaths(t *testing.T) { assert.Contains(t, paths, "vendor-manifest.yaml") } +func TestVendorManifestCleanupPathsRejectsUnsafePaths(t *testing.T) { + m := &VendorManifest{ + Version: vendorManifestVersion, + BinaryPath: "../../../etc/passwd", + Paths: []string{ + ".defaults/action.yml", + "../../secret", + ".github/workflows/reusable-triage.yml", + }, + } + paths := m.CleanupPaths("") + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") + assert.NotContains(t, paths, "../../../etc/passwd") + assert.NotContains(t, paths, "../../secret") +} + +func TestParseVendorManifestRejectsUnsafePaths(t *testing.T) { + _, err := ParseVendorManifest([]byte(`version: "1" +binary_path: bin/fullsend +paths: + - "../../etc/passwd" +`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed") +} + +func TestComparePathPresence(t *testing.T) { + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "org/.fullsend/.defaults/action.yml": []byte("ok"), + }, + } + missing, err := ComparePathPresence(context.Background(), client, "org", ".fullsend", + []string{".defaults/action.yml", ".github/workflows/reusable-triage.yml"}) + require.NoError(t, err) + assert.Equal(t, []string{".github/workflows/reusable-triage.yml"}, missing) +} + +func TestManagedVendoredContentPaths(t *testing.T) { + paths, err := ManagedVendoredContentPaths(".fullsend/") + require.NoError(t, err) + assert.Contains(t, paths, ".defaults/action.yml") + assert.Contains(t, paths, ".fullsend/.github/workflows/reusable-triage.yml") +} + +func TestLegacyFlatVendoredPaths(t *testing.T) { + paths, err := LegacyFlatVendoredPaths("") + require.NoError(t, err) + assert.Contains(t, paths, "action.yml") + assert.Contains(t, paths, ".github/workflows/reusable-triage.yml") +} + +func TestVendoredDefaultsInfraPathsMatchPredicate(t *testing.T) { + for _, p := range vendoredDefaultsInfraPaths { + assert.True(t, isVendoredDefaultsInfra(p), "hardcoded path %q not matched by isVendoredDefaultsInfra", p) + } + + root, err := moduleRootFromScaffold() + if err != nil { + t.Skip("not in fullsend checkout") + } + + var walked []string + err = walkVendoredUpstreamFromRoot(root, func(path string, _ []byte) error { + if isVendoredDefaultsInfra(path) && !isVendoredReusableWorkflow(path) { + walked = append(walked, path) + } + return nil + }) + require.NoError(t, err) + + assert.ElementsMatch(t, vendoredDefaultsInfraPaths, walked) +} + func TestEnumerateVendoredPathsWithoutCheckout(t *testing.T) { paths, err := enumerateVendoredPaths("") require.NoError(t, err) From ecf5175b2560c9ff68e72b8e37a6a9bda6f37cae Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 17:45:37 +0300 Subject: [PATCH 23/30] test(vendor): cover appendVendorTreeFiles and VendorBinary helpers Exercise vendor collect/append paths and binary upload helpers to raise patch coverage toward the codecov threshold. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/vendor_test.go | 50 ++++++++++++++++++++++++++++++++++ internal/layers/vendor_test.go | 37 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index d444a72ee6..b8d12a2f10 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -47,6 +47,56 @@ func TestVendorDryRunMessage(t *testing.T) { msg := vendorDryRunMessage("/tmp/fullsend", "", layers.VendoredBinaryPathPerRepo) assert.Contains(t, msg, "/tmp/fullsend") assert.Contains(t, msg, layers.VendoredBinaryPathPerRepo) + + msg = vendorDryRunMessage("/tmp/fullsend", "/tmp/src", layers.VendoredBinaryPathPerRepo) + assert.Contains(t, msg, "content from /tmp/src") + + msg = vendorDryRunMessage("", "/tmp/src", layers.VendoredBinaryPath) + assert.Contains(t, msg, "Would cross-compile from /tmp/src") + + msg = vendorDryRunMessage("", "", layers.VendoredBinaryPath) + assert.True(t, strings.Contains(msg, "Would cross-compile and upload") || + strings.Contains(msg, "Would download release") || + strings.Contains(msg, "Would fail: dev CLI")) +} + +func TestAppendVendorTreeFiles_Disabled(t *testing.T) { + files := []forge.TreeFile{{Path: "shim.yaml", Content: []byte("x")}} + out, count, err := appendVendorTreeFiles(ui.New(nil), "org", "my-repo", files, false, "", "") + require.NoError(t, err) + assert.Equal(t, files, out) + assert.Equal(t, 0, count) +} + +func TestAppendVendorTreeFiles_Enabled(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs Linux ELF binary") + } + exe, err := os.Executable() + require.NoError(t, err) + + files := []forge.TreeFile{{Path: "shim.yaml", Content: []byte("x")}} + var buf strings.Builder + out, count, err := appendVendorTreeFiles(ui.New(&buf), "org", "my-repo", files, true, exe, "") + require.NoError(t, err) + assert.Greater(t, len(out), len(files)) + assert.Greater(t, count, 0) +} + +func TestMakeVendorCollectFunc(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs Linux ELF binary") + } + exe, err := os.Executable() + require.NoError(t, err) + + var buf strings.Builder + fn := makeVendorCollectFunc(exe, "") + require.NotNil(t, fn) + files, count, err := fn(context.Background(), ui.New(&buf), "org", "my-repo") + require.NoError(t, err) + assert.NotEmpty(t, files) + assert.Greater(t, count, 0) } func TestAcquireAndVendor_ExplicitPath(t *testing.T) { diff --git a/internal/layers/vendor_test.go b/internal/layers/vendor_test.go index c76c805600..c5a74eea06 100644 --- a/internal/layers/vendor_test.go +++ b/internal/layers/vendor_test.go @@ -1,6 +1,9 @@ package layers import ( + "context" + "os" + "path/filepath" "strings" "testing" @@ -8,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fullsend-ai/fullsend/internal/binary" + "github.com/fullsend-ai/fullsend/internal/forge" ) func TestVendorCommitMessage_HasTitleAndBody(t *testing.T) { @@ -88,3 +92,36 @@ func TestRemoveStaleVendoredAssetsCommitMessage(t *testing.T) { assert.Contains(t, msg, "Paths: 2") assert.Contains(t, msg, "- bin/fullsend") } + +func TestVendorBinary_Upload(t *testing.T) { + dir := t.TempDir() + binPath := filepath.Join(dir, "fullsend") + require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755)) + + client := &forge.FakeClient{} + err := VendorBinary(context.Background(), client, "org", forge.ConfigRepoName, VendoredBinaryPath, binPath, "chore: vendor binary") + require.NoError(t, err) + + key := "org/" + forge.ConfigRepoName + "/" + VendoredBinaryPath + assert.Contains(t, client.FileContents, key) +} + +func TestVendorBinary_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + err := VendorBinary(context.Background(), &forge.FakeClient{}, "org", forge.ConfigRepoName, VendoredBinaryPath, dir, "msg") + require.Error(t, err) + assert.Contains(t, err.Error(), "is a directory") +} + +func TestDeleteVendoredPaths(t *testing.T) { + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "org/.fullsend/bin/fullsend": []byte("x"), + "org/.fullsend/.defaults/action.yml": []byte("y"), + }, + } + removed, err := DeleteVendoredPaths(context.Background(), client, "org", forge.ConfigRepoName, + []string{"bin/fullsend", ".defaults/action.yml"}) + require.NoError(t, err) + assert.Equal(t, 2, removed) +} From 4c360c848627aa1ed08ab858b475a2ea4ea0968e Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 18:08:20 +0300 Subject: [PATCH 24/30] test(vendor): raise PR patch coverage above 80% threshold Add installfiles, vendorroot, forge fake, and vendor CLI/layer tests covering manifest validation, sync-scaffold vendored detection, and vendor collect error paths. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/vendorroot_test.go | 60 +++++++++++++++++ internal/cli/github_test.go | 44 +++++++++++++ internal/cli/vendor_test.go | 19 ++++++ internal/forge/fake_test.go | 35 ++++++++++ internal/layers/vendor_test.go | 6 ++ internal/layers/vendorbinary_test.go | 7 ++ internal/layers/workflows_test.go | 20 ++++++ internal/scaffold/installfiles_test.go | 84 ++++++++++++++++++++++++ internal/scaffold/vendormanifest_test.go | 60 +++++++++++++++++ 9 files changed, 335 insertions(+) create mode 100644 internal/binary/vendorroot_test.go create mode 100644 internal/scaffold/installfiles_test.go diff --git a/internal/binary/vendorroot_test.go b/internal/binary/vendorroot_test.go new file mode 100644 index 0000000000..b5eeedd502 --- /dev/null +++ b/internal/binary/vendorroot_test.go @@ -0,0 +1,60 @@ +package binary + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateSourceRoot_RejectsMissingModule(t *testing.T) { + dir := t.TempDir() + err := ValidateSourceRoot(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "go.mod") +} + +func TestValidateSourceRoot_AcceptsCheckout(t *testing.T) { + root, err := ModuleRoot() + if err != nil { + t.Skip("not in fullsend checkout") + } + require.NoError(t, ValidateSourceRoot(root)) +} + +func TestResolveVendorRoot_ExplicitSource(t *testing.T) { + root, err := ModuleRoot() + if err != nil { + t.Skip("not in fullsend checkout") + } + + got, err := ResolveVendorRoot(root, "dev") + require.NoError(t, err) + assert.Equal(t, root, got.Path) + assert.Nil(t, got.Cleanup) +} + +func TestResolveVendorRoot_FromModuleRoot(t *testing.T) { + if _, err := ModuleRoot(); err != nil { + t.Skip("not in fullsend checkout") + } + + got, err := ResolveVendorRoot("", "dev") + require.NoError(t, err) + assert.DirExists(t, got.Path) + assert.Contains(t, filepath.Join(got.Path, "go.mod"), "go.mod") +} + +func TestResolveVendorRoot_DevBuildOutsideCheckout(t *testing.T) { + dir := t.TempDir() + prev, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { _ = os.Chdir(prev) }) + + _, err = ResolveVendorRoot("", "dev") + require.Error(t, err) + assert.Contains(t, err.Error(), "dev build") +} diff --git a/internal/cli/github_test.go b/internal/cli/github_test.go index 027fbedae1..9dc92e9562 100644 --- a/internal/cli/github_test.go +++ b/internal/cli/github_test.go @@ -156,6 +156,19 @@ func TestGitHubSetupCmd_PerRepoDryRun(t *testing.T) { require.NoError(t, err) } +func TestGitHubSetupCmd_PerRepoDryRun_Vendor(t *testing.T) { + t.Setenv("GH_TOKEN", "test-token") + cmd := newRootCmd() + cmd.SetArgs([]string{"github", "setup", "acme/widget", + "--mint-url", "https://mint-test-abc123.run.app", + "--inference-project", "my-project", + "--inference-wif-provider", "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc", + "--dry-run", + "--vendor"}) + err := cmd.Execute() + require.NoError(t, err) +} + func TestGitHubSetupCmd_PerRepoRequiresInferenceProject(t *testing.T) { t.Setenv("GH_TOKEN", "test-token") cmd := newRootCmd() @@ -478,6 +491,37 @@ func TestRunGitHubSyncScaffold_CommitsFiles(t *testing.T) { require.NotEmpty(t, client.CommittedFiles, "expected scaffold files to be committed") } +func TestRunGitHubSyncScaffold_VendoredMarker(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {Name: ".fullsend", FullName: "acme/.fullsend"}, + } + client.AuthenticatedUser = "testuser" + client.FileContents = map[string][]byte{ + "acme/.fullsend/.defaults/action.yml": []byte("marker"), + "acme/.fullsend/config.yaml": []byte("repos: {}\n"), + } + printer := ui.New(&discardWriter{}) + + err := runGitHubSyncScaffold(context.Background(), client, printer, "acme") + require.NoError(t, err) + require.NotEmpty(t, client.CommittedFiles) +} + +func TestRunGitHubSyncScaffold_InvalidConfig(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{{Name: ".fullsend", FullName: "acme/.fullsend"}} + client.AuthenticatedUser = "testuser" + client.FileContents = map[string][]byte{ + "acme/.fullsend/config.yaml": []byte("not: valid: yaml: ["), + } + printer := ui.New(&discardWriter{}) + + err := runGitHubSyncScaffold(context.Background(), client, printer, "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing config.yaml") +} + // --- parseTarget tests --- func TestParseTarget_Org(t *testing.T) { diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index b8d12a2f10..06854ed5ad 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -99,6 +99,12 @@ func TestMakeVendorCollectFunc(t *testing.T) { assert.Greater(t, count, 0) } +func TestMakeVendorCollectFunc_InvalidBinary(t *testing.T) { + fn := makeVendorCollectFunc("/nonexistent/fullsend", "") + _, _, err := fn(context.Background(), ui.New(&strings.Builder{}), "org", "my-repo") + require.Error(t, err) +} + func TestAcquireAndVendor_ExplicitPath(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("needs Linux ELF binary") @@ -160,6 +166,19 @@ func TestVendorPathPrefix(t *testing.T) { assert.Equal(t, ".fullsend/", vendorPathPrefix("org", "my-repo")) } +func TestMakeVendorFunc(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs Linux ELF binary") + } + exe, err := os.Executable() + require.NoError(t, err) + + fn := makeVendorFunc(exe, "") + require.NotNil(t, fn) + err = fn(context.Background(), &forge.FakeClient{}, ui.New(&strings.Builder{}), "org", "my-repo") + require.NoError(t, err) +} + func TestApplyDeprecatedVendorBinaryFlag(t *testing.T) { cmd := newInstallCmd() require.NoError(t, cmd.ParseFlags([]string{"--vendor-fullsend-binary"})) diff --git a/internal/forge/fake_test.go b/internal/forge/fake_test.go index 42bdf4ac63..f860a3600c 100644 --- a/internal/forge/fake_test.go +++ b/internal/forge/fake_test.go @@ -73,6 +73,41 @@ func TestFakeClient_CreateFileOnBranch(t *testing.T) { assert.Equal(t, "feature", fc.CreatedFiles[0].Branch) } +func TestFakeClient_DeleteFiles(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{ + FileContents: map[string][]byte{ + "owner/repo/a.txt": []byte("a"), + "owner/repo/b.txt": []byte("b"), + }, + } + + deleted, err := fc.DeleteFiles(ctx, "owner", "repo", "cleanup", []string{"a.txt", "missing.txt", "b.txt"}) + require.NoError(t, err) + assert.Equal(t, 2, deleted) + assert.Len(t, fc.DeletedFiles, 2) + _, ok := fc.FileContents["owner/repo/a.txt"] + assert.False(t, ok) +} + +func TestFakeClient_GetWorkflow(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{ + Workflows: map[string]*Workflow{ + "owner/repo/ci.yml": {Name: "CI", Path: ".github/workflows/ci.yml", State: "active"}, + }, + } + + wf, err := fc.GetWorkflow(ctx, "owner", "repo", "ci.yml") + require.NoError(t, err) + assert.Equal(t, "CI", wf.Name) + + wf, err = fc.GetWorkflow(ctx, "owner", "repo", "other.yml") + require.NoError(t, err) + assert.Equal(t, "other.yml", wf.Name) + assert.Equal(t, "active", wf.State) +} + func TestFakeClient_GetFileContent(t *testing.T) { ctx := context.Background() diff --git a/internal/layers/vendor_test.go b/internal/layers/vendor_test.go index c5a74eea06..98b3737a04 100644 --- a/internal/layers/vendor_test.go +++ b/internal/layers/vendor_test.go @@ -125,3 +125,9 @@ func TestDeleteVendoredPaths(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, removed) } + +func TestVendorCommitMessage_UnknownSource(t *testing.T) { + msg := VendorCommitMessage(binary.Source(99), "dev", "bin/fullsend", 512) + assert.Contains(t, msg, "chore: vendor fullsend binary for development") + assert.Contains(t, msg, "Path: bin/fullsend") +} diff --git a/internal/layers/vendorbinary_test.go b/internal/layers/vendorbinary_test.go index 05c495f635..a82573a3d9 100644 --- a/internal/layers/vendorbinary_test.go +++ b/internal/layers/vendorbinary_test.go @@ -405,3 +405,10 @@ func TestVendorBinaryLayer_SetAnalyzeOptions_SkippedWithoutSource(t *testing.T) require.NoError(t, err) assert.Contains(t, strings.Join(report.Details, " "), "source alignment: skipped") } + +func TestContainsWouldFix(t *testing.T) { + fixes := []string{"restore vendored path foo", "sync vendored path bar"} + assert.True(t, containsWouldFix(fixes, "foo")) + assert.True(t, containsWouldFix(fixes, "bar")) + assert.False(t, containsWouldFix(fixes, "baz")) +} diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index e16a05bce5..5772c3965a 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -52,6 +52,13 @@ func TestWorkflowsLayer_Name(t *testing.T) { assert.Equal(t, "workflows", layer.Name()) } +func TestWorkflowsLayer_RequiredScopes(t *testing.T) { + layer, _ := newWorkflowsLayer(t, forge.NewFakeClient(), false) + assert.Equal(t, []string{"repo", "workflow"}, layer.RequiredScopes(OpInstall)) + assert.Nil(t, layer.RequiredScopes(OpUninstall)) + assert.Equal(t, []string{"repo"}, layer.RequiredScopes(OpAnalyze)) +} + func TestWorkflowsLayer_Install_WritesAllFiles(t *testing.T) { client := forge.NewFakeClient() layer, _ := newWorkflowsLayer(t, client, false) @@ -96,6 +103,19 @@ func TestWorkflowsLayer_Install_ActivatesRepoMaintenance(t *testing.T) { assert.Contains(t, buf.String(), "Activated repo-maintenance workflow") } +func TestWorkflowsLayer_Install_ActivateRepoMaintenanceFailure(t *testing.T) { + client := forge.NewFakeClient() + client.FileContents["test-org/.fullsend/config.yaml"] = []byte("repos: {}\n") + client.Errors = map[string]error{ + "CreateOrUpdateFile": errors.New("branch protected"), + } + layer, buf := newWorkflowsLayer(t, client, false) + + err := layer.Install(context.Background()) + require.NoError(t, err) + assert.Contains(t, buf.String(), "repo-maintenance workflow was not activated automatically") +} + func TestWorkflowsLayer_Install_TriageWorkflowContent(t *testing.T) { client := forge.NewFakeClient() layer, _ := newWorkflowsLayer(t, client, false) diff --git a/internal/scaffold/installfiles_test.go b/internal/scaffold/installfiles_test.go new file mode 100644 index 0000000000..e59626774e --- /dev/null +++ b/internal/scaffold/installfiles_test.go @@ -0,0 +1,84 @@ +package scaffold + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectInstallFiles_PerOrg(t *testing.T) { + files, err := CollectInstallFiles(CollectInstallFilesOptions{ + RenderOptions: RenderOptionsForInstall(false, false), + }) + require.NoError(t, err) + require.NotEmpty(t, files) + + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + assert.Contains(t, paths, ".github/workflows/triage.yml") + assert.Contains(t, paths, "customized/agents/.gitkeep") +} + +func TestCollectInstallFiles_PerRepoPrefix(t *testing.T) { + files, err := CollectInstallFiles(CollectInstallFilesOptions{ + RenderOptions: RenderOptionsForInstall(false, true), + PathPrefix: ".fullsend/", + }) + require.NoError(t, err) + require.NotEmpty(t, files) + + found := false + for _, f := range files { + if f.Path == ".fullsend/.github/workflows/triage.yml" { + found = true + break + } + } + assert.True(t, found, "expected per-repo prefixed triage workflow") +} + +func TestCollectPerRepoInstallFiles(t *testing.T) { + files, err := CollectPerRepoInstallFiles(false) + require.NoError(t, err) + require.NotEmpty(t, files) + assert.Equal(t, ".github/workflows/fullsend.yaml", files[0].Path) +} + +func TestManagedPaths(t *testing.T) { + paths, err := ManagedPaths(false, "") + require.NoError(t, err) + assert.Contains(t, paths, ".github/workflows/triage.yml") +} + +func TestCollectInstallFiles_Vendored(t *testing.T) { + files, err := CollectInstallFiles(CollectInstallFilesOptions{ + RenderOptions: RenderOptionsForInstall(true, false), + }) + require.NoError(t, err) + require.NotEmpty(t, files) + + var triage string + for _, f := range files { + if f.Path == ".github/workflows/triage.yml" { + triage = string(f.Content) + break + } + } + require.NotEmpty(t, triage) + assert.NotContains(t, triage, "__UPSTREAM_REF__") +} + +func TestCollectPerRepoInstallFiles_Vendored(t *testing.T) { + files, err := CollectPerRepoInstallFiles(true) + require.NoError(t, err) + require.NotEmpty(t, files) + assert.Contains(t, string(files[0].Content), "reusable-") +} + +func TestCustomizedDirsForPrefix(t *testing.T) { + assert.Contains(t, customizedDirsForPrefix(""), "customized/agents") + assert.Contains(t, customizedDirsForPrefix(".fullsend/"), ".fullsend/customized/agents") +} diff --git a/internal/scaffold/vendormanifest_test.go b/internal/scaffold/vendormanifest_test.go index 6deb1ea78a..341559abd4 100644 --- a/internal/scaffold/vendormanifest_test.go +++ b/internal/scaffold/vendormanifest_test.go @@ -2,6 +2,7 @@ package scaffold import ( "context" + "errors" "os" "path/filepath" "testing" @@ -43,6 +44,13 @@ func TestVendorManifestCleanupPaths(t *testing.T) { assert.Contains(t, paths, "vendor-manifest.yaml") } +func TestVendorManifestCleanupPaths_PerRepo(t *testing.T) { + m := NewVendorManifest("dev", "", ".fullsend/bin/fullsend", []string{".fullsend/.defaults/action.yml"}) + paths := m.CleanupPaths(".fullsend/") + assert.Contains(t, paths, ".fullsend/vendor-manifest.yaml") + assert.Contains(t, paths, ".fullsend/bin/fullsend") +} + func TestVendorManifestCleanupPathsRejectsUnsafePaths(t *testing.T) { m := &VendorManifest{ Version: vendorManifestVersion, @@ -60,6 +68,12 @@ func TestVendorManifestCleanupPathsRejectsUnsafePaths(t *testing.T) { assert.NotContains(t, paths, "../../secret") } +func TestParseVendorManifestRejectsMissingBinaryPath(t *testing.T) { + _, err := ParseVendorManifest([]byte("version: \"1\"\npaths: []\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing binary_path") +} + func TestParseVendorManifestRejectsUnsafePaths(t *testing.T) { _, err := ParseVendorManifest([]byte(`version: "1" binary_path: bin/fullsend @@ -82,6 +96,17 @@ func TestComparePathPresence(t *testing.T) { assert.Equal(t, []string{".github/workflows/reusable-triage.yml"}, missing) } +func TestComparePathPresence_GetFileContentError(t *testing.T) { + client := &forge.FakeClient{ + Errors: map[string]error{ + "GetFileContent": errors.New("network down"), + }, + } + _, err := ComparePathPresence(context.Background(), client, "org", ".fullsend", []string{".defaults/action.yml"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "checking .defaults/action.yml") +} + func TestManagedVendoredContentPaths(t *testing.T) { paths, err := ManagedVendoredContentPaths(".fullsend/") require.NoError(t, err) @@ -118,6 +143,36 @@ func TestVendoredDefaultsInfraPathsMatchPredicate(t *testing.T) { assert.ElementsMatch(t, vendoredDefaultsInfraPaths, walked) } +func TestReadVendorManifest(t *testing.T) { + m := NewVendorManifest("dev", "", "bin/fullsend", []string{".defaults/action.yml"}) + data, err := m.MarshalYAML() + require.NoError(t, err) + + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "org/.fullsend/vendor-manifest.yaml": data, + }, + } + + got, found, err := ReadVendorManifest(context.Background(), client, "org", ".fullsend", "") + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, m.BinaryPath, got.BinaryPath) +} + +func TestReadVendorManifest_ParseError(t *testing.T) { + client := &forge.FakeClient{ + FileContents: map[string][]byte{ + "org/.fullsend/vendor-manifest.yaml": []byte("version: \"1\"\nbinary_path: ../bad\npaths:\n - ../bad\n"), + }, + } + + _, found, err := ReadVendorManifest(context.Background(), client, "org", ".fullsend", "") + require.True(t, found) + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed") +} + func TestEnumerateVendoredPathsWithoutCheckout(t *testing.T) { paths, err := enumerateVendoredPaths("") require.NoError(t, err) @@ -210,3 +265,8 @@ func TestCollectVendoredAssetsUsesDefaultsMirror(t *testing.T) { func TestVendoredMarkerPath(t *testing.T) { assert.Equal(t, ".defaults/action.yml", VendoredMarkerPath()) } + +func TestVendorManifestPath(t *testing.T) { + assert.Equal(t, "vendor-manifest.yaml", VendorManifestPath("")) + assert.Equal(t, ".fullsend/vendor-manifest.yaml", VendorManifestPath(".fullsend/")) +} From ac64c91dddce497dc1067df7b3b9f53183d3132e Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 18:21:48 +0300 Subject: [PATCH 25/30] test(cli): cover admin per-repo vendor dry-run path Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 9a1aff2125..bc6d4c7ffa 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1651,6 +1651,19 @@ func TestInstallCmd_PerRepoAcceptsValidWIFProvider(t *testing.T) { require.NoError(t, err) } +func TestInstallCmd_PerRepoDryRun_Vendor(t *testing.T) { + t.Setenv("GH_TOKEN", "test-token") + cmd := newRootCmd() + cmd.SetArgs([]string{"admin", "install", "acme/widget", + "--mint-url", "https://mint-test-abc123.run.app", + "--inference-project", "my-project", + "--inference-wif-provider", "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc", + "--dry-run", + "--vendor"}) + err := cmd.Execute() + require.NoError(t, err) +} + func TestFilterSlugsByAppSet(t *testing.T) { tests := []struct { name string From 387968a4b6660136d3e0c7cb1fc10a3b26d128f6 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 22:02:35 +0300 Subject: [PATCH 26/30] test(cli): cover runDryRun, runAnalyze, and per-org setup dry-run Raise PR patch coverage above the codecov threshold and address ADR/review wording for sync-scaffold auto-detection vs --vendor flags. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...0047-vendored-installs-with-vendor-flag.md | 6 ++- internal/binary/vendorroot.go | 2 +- internal/cli/admin_test.go | 41 +++++++++++++++++++ internal/cli/github_test.go | 23 +++++++++++ internal/cli/vendor.go | 2 + internal/layers/workflows.go | 2 + 6 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/ADRs/0047-vendored-installs-with-vendor-flag.md b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md index a8caef4095..ad78ad28b3 100644 --- a/docs/ADRs/0047-vendored-installs-with-vendor-flag.md +++ b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md @@ -30,8 +30,10 @@ vendored files without `config.yaml` distribution settings. ### Install-time: `--vendor` -`fullsend admin install`, `fullsend github setup`, and -`fullsend github sync-scaffold` accept: +`fullsend admin install` and `fullsend github setup` accept `--vendor` and related +flags. `fullsend github sync-scaffold` does **not** take `--vendor`; it +auto-detects vendored mode from the presence of `.defaults/action.yml` in +the config repo and rewrites scaffold files accordingly. | Flag | Purpose | |------|---------| diff --git a/internal/binary/vendorroot.go b/internal/binary/vendorroot.go index 8569522797..486db3b558 100644 --- a/internal/binary/vendorroot.go +++ b/internal/binary/vendorroot.go @@ -63,7 +63,7 @@ func ResolveVendorRoot(sourceDir, version string) (VendorRoot, error) { } if !IsReleasedVersion(version) { - return VendorRoot{}, fmt.Errorf("cannot resolve fullsend source: not in a checkout and CLI version %s is a dev build — use --fullsend-source, run from a checkout, or use a released CLI", version) + return VendorRoot{}, fmt.Errorf("cannot resolve fullsend source: not in a checkout and CLI version %s is a dev build; use --fullsend-source, run from a checkout, or use a released CLI", version) } tmpDir, err := os.MkdirTemp("", "fullsend-source-*") diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index bc6d4c7ffa..d5ee8caee9 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1664,6 +1664,47 @@ func TestInstallCmd_PerRepoDryRun_Vendor(t *testing.T) { require.NoError(t, err) } +func TestRunDryRun_WithDiscoveredRepos(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "testuser" + discovered := []forge.Repository{ + {Name: forge.ConfigRepoName, FullName: "testorg/" + forge.ConfigRepoName, DefaultBranch: "main"}, + {Name: "myrepo", FullName: "testorg/myrepo", DefaultBranch: "main"}, + } + client.Repos = discovered + + var buf bytes.Buffer + printer := ui.New(&buf) + err := runDryRun( + context.Background(), client, printer, "testorg", + []string{"myrepo"}, + config.DefaultAgentRoles(), + nil, + "", + true, + "https://mint.example.com/v1/token", + discovered, + true, + "", + "", + ) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Layer: vendor") +} + +func TestRunAnalyze_WithFakeClient(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "testuser" + client.Repos = []forge.Repository{ + {Name: forge.ConfigRepoName, FullName: "testorg/" + forge.ConfigRepoName}, + } + + var buf bytes.Buffer + err := runAnalyze(context.Background(), client, ui.New(&buf), "testorg", "") + require.NoError(t, err) + assert.Contains(t, buf.String(), "Layer:") +} + func TestFilterSlugsByAppSet(t *testing.T) { tests := []struct { name string diff --git a/internal/cli/github_test.go b/internal/cli/github_test.go index 9dc92e9562..62a3deecab 100644 --- a/internal/cli/github_test.go +++ b/internal/cli/github_test.go @@ -522,6 +522,29 @@ func TestRunGitHubSyncScaffold_InvalidConfig(t *testing.T) { assert.Contains(t, err.Error(), "parsing config.yaml") } +func TestRunGitHubSetupPerOrg_DryRun(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "testuser" + client.Repos = []forge.Repository{ + {Name: forge.ConfigRepoName, FullName: "acme/" + forge.ConfigRepoName}, + {Name: "widget", FullName: "acme/widget"}, + } + var buf strings.Builder + err := runGitHubSetupPerOrg(context.Background(), client, ui.New(&buf), githubSetupConfig{ + target: "acme", + mintURL: "https://mint.example.com/v1/token", + agents: strings.Join(config.DefaultAgentRoles(), ","), + inferenceProject: "my-project", + inferenceWIFProvider: "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/github-oidc", + dryRun: true, + enrollNone: true, + skipAppSetup: true, + vendor: true, + }) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Layer: vendor") +} + // --- parseTarget tests --- func TestParseTarget_Org(t *testing.T) { diff --git a/internal/cli/vendor.go b/internal/cli/vendor.go index 074151e66f..960c064ff4 100644 --- a/internal/cli/vendor.go +++ b/internal/cli/vendor.go @@ -168,6 +168,8 @@ func prepareVendorFiles(printer *ui.Printer, owner, repo, fullsendBinary, fullse } manifest := scaffold.NewVendorManifest(version, fullsendSource, destPath, scaffold.PathsFromInstallFiles(assets)) + // Manifest is built locally from collected assets; ParseVendorManifest validates + // paths when reading a committed manifest from the repo. manifestYAML, err := manifest.MarshalYAML() if err != nil { cleanup() diff --git a/internal/layers/workflows.go b/internal/layers/workflows.go index 5ed3810526..7b6a88dc36 100644 --- a/internal/layers/workflows.go +++ b/internal/layers/workflows.go @@ -85,6 +85,8 @@ func (l *WorkflowsLayer) Install(ctx context.Context) error { }) vendorAssetCount := 0 + // Vendored marker paths must stay aligned with reusable workflow hashFiles + // checks (see .github workflows and scaffold.VendoredMarkerPath). if l.vendored && l.vendorCollect != nil { vendorFiles, count, err := l.vendorCollect(ctx, l.ui, l.org, forge.ConfigRepoName) if err != nil { From a9bd135d801af1ff1c7346233c4e46df80fae1f8 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 22:18:22 +0300 Subject: [PATCH 27/30] test(cli): cover runInstall mint check and skip path Exercise runInstall credential validation and the skip-mint-check install path to raise patch coverage above the 80% gate. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index d5ee8caee9..747bed65e3 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1705,6 +1705,53 @@ func TestRunAnalyze_WithFakeClient(t *testing.T) { assert.Contains(t, buf.String(), "Layer:") } +func TestRunInstall_RequiresAgentCredsWhenMintEnabled(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "testuser" + discovered := []forge.Repository{ + {Name: forge.ConfigRepoName, FullName: "testorg/" + forge.ConfigRepoName}, + } + client.Repos = discovered + + err := runInstall( + context.Background(), client, ui.New(&bytes.Buffer{}), "testorg", + []string{}, config.DefaultAgentRoles(), nil, + nil, "", + false, "", "", + "gcf", "test-project", "us-central1", "", true, + "https://mint.example.com/v1/token", + false, + discovered, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "OIDC mint requires") +} + +func TestRunInstall_WithSkipMintCheck(t *testing.T) { + cfg := setupTestConfig(map[string]bool{"myrepo": false}) + client := setupTestClient("testorg", cfg, []string{"myrepo"}) + client.AuthenticatedUser = "testuser" + + var agentCreds []layers.AgentCredentials + for _, role := range config.DefaultAgentRoles() { + agentCreds = append(agentCreds, layers.AgentCredentials{ + AgentEntry: config.AgentEntry{Role: role}, + }) + } + + err := runInstall( + context.Background(), client, ui.New(&bytes.Buffer{}), "testorg", + nil, config.DefaultAgentRoles(), agentCreds, + nil, "", + false, "", "", + "gcf", "test-project", "us-central1", "", true, + "https://mint.example.com/v1/token", + true, + client.Repos, + ) + require.NoError(t, err) +} + func TestFilterSlugsByAppSet(t *testing.T) { tests := []struct { name string From 2b93fff0ca82135aeb8cfcfa0eb359c53376bbdb Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 22:35:36 +0300 Subject: [PATCH 28/30] test: raise patch coverage for install, vendor, and download paths Add runInstall and runPerRepoInstall validation tests, prepareVendorFiles and FetchSourceTree coverage, VendorBinary error paths, and vendorcontent scaffold tests to close the codecov/patch gap. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/binary/download_test.go | 52 +++++++++ internal/cli/admin_test.go | 137 ++++++++++++++++++++++++ internal/cli/vendor_test.go | 21 ++++ internal/layers/vendor_test.go | 22 ++++ internal/scaffold/vendorcontent_test.go | 90 ++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 internal/scaffold/vendorcontent_test.go diff --git a/internal/binary/download_test.go b/internal/binary/download_test.go index 90e8dce2f7..7b4701ed3c 100644 --- a/internal/binary/download_test.go +++ b/internal/binary/download_test.go @@ -680,5 +680,57 @@ func TestExtractSourceTreeAggregateSizeLimit(t *testing.T) { assert.Contains(t, err.Error(), "aggregate extracted size exceeds maximum") } +func TestFetchSourceTree_ExtractsArchive(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + content := []byte("module root") + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "fullsend-1.0.0/go.mod", + Typeflag: tar.TypeReg, + Size: int64(len(content)), + Mode: 0o644, + })) + _, err := tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0.0.tar.gz" { + w.Write(buf.Bytes()) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + origBase := SourceArchiveBaseURL + SourceArchiveBaseURL = srv.URL + t.Cleanup(func() { SourceArchiveBaseURL = origBase }) + + dest := t.TempDir() + require.NoError(t, FetchSourceTree("1.0.0", dest)) + + data, err := os.ReadFile(filepath.Join(dest, "go.mod")) + require.NoError(t, err) + assert.Equal(t, content, data) +} + +func TestFetchSourceTree_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + origBase := SourceArchiveBaseURL + SourceArchiveBaseURL = srv.URL + t.Cleanup(func() { SourceArchiveBaseURL = origBase }) + + err := FetchSourceTree("9.9.9", t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "returned 404") +} + // Ensure io is used in download tests. var _ = io.Discard diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 747bed65e3..5653288081 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1752,6 +1752,143 @@ func TestRunInstall_WithSkipMintCheck(t *testing.T) { require.NoError(t, err) } +func TestRunInstall_DiscoversRepos(t *testing.T) { + cfg := setupTestConfig(map[string]bool{"myrepo": false}) + client := setupTestClient("testorg", cfg, []string{"myrepo"}) + client.AuthenticatedUser = "testuser" + + var agentCreds []layers.AgentCredentials + for _, role := range config.DefaultAgentRoles() { + agentCreds = append(agentCreds, layers.AgentCredentials{ + AgentEntry: config.AgentEntry{Role: role}, + }) + } + + var buf bytes.Buffer + err := runInstall( + context.Background(), client, ui.New(&buf), "testorg", + nil, config.DefaultAgentRoles(), agentCreds, + nil, "", + false, "", "", + "gcf", "test-project", "us-central1", "", true, + "https://mint.example.com/v1/token", + true, + nil, + ) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Discovering repositories") +} + +func TestRunInstall_InvalidEnabledRepo(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "testuser" + discovered := []forge.Repository{ + {Name: "myrepo", FullName: "testorg/myrepo"}, + } + + err := runInstall( + context.Background(), client, ui.New(&bytes.Buffer{}), "testorg", + []string{"missing-repo"}, config.DefaultAgentRoles(), nil, + nil, "", + false, "", "", + "gcf", "test-project", "us-central1", "", true, + "https://mint.example.com/v1/token", + true, + discovered, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing-repo") +} + +func TestRunInstall_WithVendorAndSkipMint(t *testing.T) { + cfg := setupTestConfig(map[string]bool{"myrepo": false}) + client := setupTestClient("testorg", cfg, []string{"myrepo"}) + client.AuthenticatedUser = "testuser" + + var agentCreds []layers.AgentCredentials + for _, role := range config.DefaultAgentRoles() { + agentCreds = append(agentCreds, layers.AgentCredentials{ + AgentEntry: config.AgentEntry{Role: role}, + }) + } + + var buf bytes.Buffer + err := runInstall( + context.Background(), client, ui.New(&buf), "testorg", + nil, config.DefaultAgentRoles(), agentCreds, + nil, "", + true, "", "", + "gcf", "test-project", "us-central1", "", true, + "https://mint.example.com/v1/token", + true, + client.Repos, + ) + require.NoError(t, err) + assert.Contains(t, buf.String(), "vendored assets") +} + +func TestRunPerRepoInstall_ValidationErrors(t *testing.T) { + base := perRepoInstallConfig{ + RepoFullName: "acme/widget", + Agents: strings.Join(config.PerRepoDefaultRoles(), ","), + InferenceProject: "my-project", + MintProject: "my-project", + MintURL: "https://mint.example.com/v1/token", + SkipMintCheck: true, + } + tests := []struct { + name string + cfg perRepoInstallConfig + want string + }{ + { + name: "url not owner/repo", + cfg: func() perRepoInstallConfig { + c := base + c.RepoFullName = "https://github.com/acme/widget" + return c + }(), + want: "expected owner/repo format", + }, + { + name: "invalid owner", + cfg: func() perRepoInstallConfig { + c := base + c.RepoFullName = "-bad/widget" + return c + }(), + want: "invalid owner name", + }, + { + name: "missing inference project", + cfg: func() perRepoInstallConfig { + c := base + c.InferenceProject = "" + return c + }(), + want: "--inference-project is required", + }, + { + name: "missing mint project without skip", + cfg: func() perRepoInstallConfig { + c := base + c.SkipMintCheck = false + c.MintURL = "" + c.MintProject = "" + return c + }(), + want: "--mint-project", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := runPerRepoInstall(context.Background(), tt.cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + func TestFilterSlugsByAppSet(t *testing.T) { tests := []struct { name string diff --git a/internal/cli/vendor_test.go b/internal/cli/vendor_test.go index 06854ed5ad..fd52120f93 100644 --- a/internal/cli/vendor_test.go +++ b/internal/cli/vendor_test.go @@ -187,3 +187,24 @@ func TestApplyDeprecatedVendorBinaryFlag(t *testing.T) { applyDeprecatedVendorBinaryFlag(cmd, &vendor) assert.True(t, vendor) } + +func TestPrepareVendorFiles_ExplicitBinary(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs Linux ELF binary") + } + exe, err := os.Executable() + require.NoError(t, err) + + bundle, cleanup, err := prepareVendorFiles(ui.New(&strings.Builder{}), "org", "my-repo", exe, "") + require.NoError(t, err) + t.Cleanup(cleanup) + assert.Greater(t, bundle.assetCount, 0) + assert.NotEmpty(t, bundle.files) +} + +func TestPrepareVendorFiles_InvalidExplicitBinary(t *testing.T) { + _, cleanup, err := prepareVendorFiles(ui.New(&strings.Builder{}), "org", "my-repo", "/nonexistent/fullsend", "") + require.Error(t, err) + cleanup() + assert.Contains(t, err.Error(), "validating --fullsend-binary") +} diff --git a/internal/layers/vendor_test.go b/internal/layers/vendor_test.go index 98b3737a04..95d671c3ab 100644 --- a/internal/layers/vendor_test.go +++ b/internal/layers/vendor_test.go @@ -2,6 +2,7 @@ package layers import ( "context" + "errors" "os" "path/filepath" "strings" @@ -113,6 +114,27 @@ func TestVendorBinary_RejectsDirectory(t *testing.T) { assert.Contains(t, err.Error(), "is a directory") } +func TestVendorBinary_RejectsMissingFile(t *testing.T) { + err := VendorBinary(context.Background(), &forge.FakeClient{}, "org", forge.ConfigRepoName, VendoredBinaryPath, "/nonexistent/fullsend", "msg") + require.Error(t, err) + assert.Contains(t, err.Error(), "stat binary") +} + +func TestVendorBinary_UploadError(t *testing.T) { + dir := t.TempDir() + binPath := filepath.Join(dir, "fullsend") + require.NoError(t, os.WriteFile(binPath, []byte("bin"), 0o755)) + + client := &forge.FakeClient{ + Errors: map[string]error{ + "CreateOrUpdateFile": errors.New("upload denied"), + }, + } + err := VendorBinary(context.Background(), client, "org", forge.ConfigRepoName, VendoredBinaryPath, binPath, "msg") + require.Error(t, err) + assert.Contains(t, err.Error(), "uploading vendored binary") +} + func TestDeleteVendoredPaths(t *testing.T) { client := &forge.FakeClient{ FileContents: map[string][]byte{ diff --git a/internal/scaffold/vendorcontent_test.go b/internal/scaffold/vendorcontent_test.go new file mode 100644 index 0000000000..e945476e41 --- /dev/null +++ b/internal/scaffold/vendorcontent_test.go @@ -0,0 +1,90 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectVendoredAssets_FromCheckout(t *testing.T) { + root, err := moduleRootFromScaffold() + if err != nil { + t.Skip("not in fullsend checkout") + } + + files, err := CollectVendoredAssets(root, "") + require.NoError(t, err) + require.NotEmpty(t, files) + + var hasReusable, hasDefaults bool + for _, f := range files { + if strings.HasPrefix(f.Path, ".github/workflows/reusable-") { + hasReusable = true + } + if strings.HasPrefix(f.Path, ".defaults/") { + hasDefaults = true + } + } + assert.True(t, hasReusable, "expected reusable workflow files") + assert.True(t, hasDefaults, "expected .defaults/ files") +} + +func TestCollectVendoredAssets_PerRepoPrefix(t *testing.T) { + root, err := moduleRootFromScaffold() + if err != nil { + t.Skip("not in fullsend checkout") + } + + files, err := CollectVendoredAssets(root, ".fullsend/") + require.NoError(t, err) + require.NotEmpty(t, files) + for _, f := range files { + if strings.HasPrefix(f.Path, ".github/workflows/") { + assert.True(t, strings.HasPrefix(f.Path, ".fullsend/.github/workflows/"), "workflows should use per-repo prefix: %s", f.Path) + } + } +} + +func TestCollectVendoredAssets_InvalidRoot(t *testing.T) { + dir := t.TempDir() + _, err := CollectVendoredAssets(dir, "") + require.Error(t, err) +} + +func TestVendoredInfraFileMode(t *testing.T) { + assert.Equal(t, "100755", vendoredInfraFileMode(".github/scripts/prepare-agent-workspace.sh")) + assert.Equal(t, "100644", vendoredInfraFileMode("action.yml")) +} + +func TestIsVendoredReusableWorkflow(t *testing.T) { + assert.True(t, isVendoredReusableWorkflow(".github/workflows/reusable-triage.yml")) + assert.False(t, isVendoredReusableWorkflow(".github/workflows/triage.yml")) + assert.False(t, isVendoredReusableWorkflow("action.yml")) +} + +func TestIsVendoredDefaultsInfra(t *testing.T) { + assert.True(t, isVendoredDefaultsInfra("action.yml")) + assert.True(t, isVendoredDefaultsInfra(".github/actions/foo/action.yml")) + assert.True(t, isVendoredDefaultsInfra(".github/scripts/run.sh")) + assert.False(t, isVendoredDefaultsInfra(".github/workflows/reusable-triage.yml")) +} + +func TestWalkVendoredUpstreamFromRoot_SkipsSymlink(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("ok"), 0o644)) + link := filepath.Join(root, "action.yml") + require.NoError(t, os.Symlink(target, link)) + + var seen []string + err := walkVendoredUpstreamFromRoot(root, func(path string, _ []byte) error { + seen = append(seen, path) + return nil + }) + require.NoError(t, err) + assert.Empty(t, seen, "symlinks should be skipped") +} From 3fb219c1238d2d00d1a026d07be70a24cffd8bb9 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 22:45:59 +0300 Subject: [PATCH 29/30] Signed-off-by: Barak Korren test: gofmt admin_test after coverage additions Co-authored-by: Cursor --- internal/cli/admin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 5653288081..14022fdc57 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1830,7 +1830,7 @@ func TestRunInstall_WithVendorAndSkipMint(t *testing.T) { func TestRunPerRepoInstall_ValidationErrors(t *testing.T) { base := perRepoInstallConfig{ RepoFullName: "acme/widget", - Agents: strings.Join(config.PerRepoDefaultRoles(), ","), + Agents: strings.Join(config.PerRepoDefaultRoles(), ","), InferenceProject: "my-project", MintProject: "my-project", MintURL: "https://mint.example.com/v1/token", From 22d710dd7597a9b8cb141235518a33861d6a6802 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 16 Jun 2026 23:37:44 +0300 Subject: [PATCH 30/30] docs(adr): document trust boundary for vendored defaults gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record that hashFiles gating upstream sparse checkout is an optimization, not a security control — config-repo write access is equivalent to workflow authoring. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .../0047-vendored-installs-with-vendor-flag.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/ADRs/0047-vendored-installs-with-vendor-flag.md b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md index ad78ad28b3..235c740278 100644 --- a/docs/ADRs/0047-vendored-installs-with-vendor-flag.md +++ b/docs/ADRs/0047-vendored-installs-with-vendor-flag.md @@ -93,6 +93,20 @@ onto the workspace root at job start (inline prepare step). Thin caller `uses:` paths are rendered at install/sync time (local `./...` when `--vendor`, upstream `@v0` when layered). +### Trust boundary for runtime defaults + +Reusable workflows gate upstream sparse checkout on `hashFiles('.defaults/action.yml', +'.fullsend/.defaults/action.yml') == ''` — when vendored markers are absent, the +job fetches defaults from `fullsend-ai/fullsend` at the configured ref. + +That gate is an optimization, not a security control. Whoever can write to the +config repo (per-org `.fullsend`, or a target repo's `.fullsend/` tree in +per-repo mode) already controls which workflows and composite actions run in +enrolled repos. A writer with that access could omit or replace vendored marker +files to change which defaults are fetched — equivalent to authoring or editing +workflow YAML directly. Branch protection and CODEOWNERS on `.fullsend` (and +target-repo guardrails) remain the enforcement layer. + ### What this PR removes These existed on earlier iterations of the distribution-mode branch and are