Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,19 @@ Install: process 1→7 (forward)
Uninstall: process 7→1 (reverse)
```
Comment thread
ifireball marked this conversation as resolved.

Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` 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 rather than through `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. Binary vendoring (when `--vendor-fullsend-binary` is set) and stale binary cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`.

### Binary acquisition (`internal/binary`)

Linux binary resolution for `fullsend run` and vendoring lives in `internal/binary`:

| Function | Policy |
|----------|--------|
| `ResolveForRun` | Release download (released CLI only) → cross-compile → latest release |
| `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.

---

Expand Down
9 changes: 8 additions & 1 deletion docs/guides/getting-started/github-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +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` | Build and upload the fullsend binary to the config repo for local dev testing (e.g., macOS with a Podman Linux VM) |
| `--vendor-fullsend-binary` | No | `false` | Resolve and upload a linux/amd64 fullsend binary for CI (see [Vendoring the CLI binary](#vendoring-the-cli-binary)) |
| `--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

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.

`fullsend admin analyze <org>` reports when a stale vendored binary is present (no install-intent flags on analyze).

## Per-repo setup

Per-repo mode bootstraps a single repository with a `.fullsend/` directory, shim workflow, and repo-level secrets:
Expand Down
22 changes: 21 additions & 1 deletion docs/guides/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ 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` | Cross-compile and vendor the fullsend binary for development iteration |
| `--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) |

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.

Expand All @@ -266,6 +267,25 @@ 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

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.

When the flag is set, the binary is resolved in this order:

1. **`--fullsend-binary <path>`** — 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)

When the flag is **off**, any existing vendored binary is removed so CI uses released 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.
- Works from any directory inside the module checkout (module root discovery via `GOMOD`).

### Merge enrollment PRs

If you chose to enroll repositories during install, the installer dispatches a workflow that creates an enrollment PR in each enrolled repo. These PRs add a shim workflow (`.github/workflows/fullsend.yaml`) that wires events to the agent pipeline.
Expand Down
31 changes: 31 additions & 0 deletions e2e/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"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/layers"
)

// e2eEnv holds the shared state for an e2e test run.
Expand Down Expand Up @@ -651,3 +652,33 @@ func runUnenrollmentTest(t *testing.T, env *e2eEnv) {
require.True(t, forge.IsNotFound(err), "shim should be removed from %s after unenrollment", testRepo)
t.Log("Verified shim is gone")
}

// TestVendorFromSubdirectory verifies that --vendor-fullsend-binary cross-compiles
// when the CLI is run from a subdirectory inside the module (GOMOD discovery).
func TestVendorFromSubdirectory(t *testing.T) {
env := setupE2ETest(t)
ctx := context.Background()

subdir := filepath.Join(moduleRoot(t), "internal", "cli")
installArgs := []string{
"admin", "install", env.org,
"--skip-app-setup",
"--skip-mint-check",
"--mint-url", env.cfg.mintURL,
"--app-set", e2eAppSet,
"--enroll-none",
"--vendor-fullsend-binary",
}
runCLIFromDir(t, env.binary, env.token, subdir, installArgs...)

_, err := env.client.GetFileContent(ctx, env.org, forge.ConfigRepoName, layers.VendoredBinaryPath)
require.NoError(t, err, "vendored binary should exist at %s", layers.VendoredBinaryPath)

registerRepoCleanup(t, env.client, env.org, forge.ConfigRepoName)

runCLI(t, env.binary, env.token,
"admin", "uninstall", env.org,
"--yolo",
"--app-set", e2eAppSet,
)
}
27 changes: 18 additions & 9 deletions e2e/admin/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,19 @@ func buildCLIBinary(t *testing.T) string {
}

// runCLI executes the fullsend CLI with the given args, passing GITHUB_TOKEN.
// The working directory is set to the module root so that --vendor-fullsend-binary
// can find ./cmd/fullsend/ (same as a user running from the repo root).
// By default the working directory is the module root. Use runCLIFromDir to
// run from a subdirectory (GOMOD discovery makes this work for vendoring).
func runCLI(t *testing.T, binary, token string, args ...string) string {
t.Helper()
t.Logf("[cli] fullsend %s", strings.Join(args, " "))
return runCLIFromDir(t, binary, token, moduleRoot(t), args...)
}

modRoot, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
if err != nil {
t.Fatalf("finding module root for runCLI: %v", err)
}
// runCLIFromDir runs the CLI with cwd set to dir.
func runCLIFromDir(t *testing.T, binary, token, dir string, args ...string) string {
t.Helper()
t.Logf("[cli] fullsend %s (cwd=%s)", strings.Join(args, " "), dir)

cmd := exec.Command(binary, args...)
cmd.Dir = strings.TrimSpace(string(modRoot))
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GITHUB_TOKEN="+token, "CI=true")
out, runErr := cmd.CombinedOutput()
output := string(out)
Expand All @@ -283,6 +283,15 @@ func runCLI(t *testing.T, binary, token string, args ...string) string {
return output
}

func moduleRoot(t *testing.T) string {
t.Helper()
modRoot, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
if err != nil {
t.Fatalf("finding module root: %v", err)
}
return strings.TrimSpace(string(modRoot))
}

// retryOnNotFound retries an operation up to maxAttempts times with linear
// backoff when it returns a not-found error (GitHub eventual consistency).
func retryOnNotFound(ctx context.Context, maxAttempts int, fn func() error) error {
Expand Down
115 changes: 115 additions & 0 deletions internal/binary/acquire.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package binary

import (
"fmt"
"os"
"path/filepath"
)

// Source identifies how a Linux fullsend binary was obtained.
type Source int

const (
SourceExplicitPath Source = iota
SourceCheckoutBuild
SourceReleaseDownload
)

// AcquireResult holds the path to an acquired binary and metadata for callers.
type AcquireResult struct {
TmpDir string // caller must RemoveAll when non-empty
Path string
Source Source
}

// ResolveExplicit validates that path is a Linux ELF for arch.
func ResolveExplicit(path, arch string) error {
return ValidateLinuxBinary(path, arch)
}

// ResolveForRun obtains a Linux binary using the run policy:
// release download (if released) → cross-compile → latest release.
func ResolveForRun(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")

// 1. Released version → download matching release asset.
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)
return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceReleaseDownload}, nil
} else {
fmt.Fprintf(os.Stderr, "WARNING: release download failed: %v\n", dlErr)
}
}

// 2. Try cross-compilation (requires Go toolchain + module checkout).
fmt.Fprintf(os.Stderr, "Cross-compiling fullsend for linux/%s...\n", arch)
if ccErr := CrossCompile(CrossCompileOpts{
Version: version,
Arch: arch,
DestPath: binaryPath,
VersionStamp: "-crosscompiled",
}); ccErr == nil {
fmt.Fprintf(os.Stderr, "Cross-compiled fullsend for linux/%s\n", arch)
return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceCheckoutBuild}, nil
} else {
fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr)
}
Comment thread
ifireball marked this conversation as resolved.

// 3. Last resort → download latest release.
fmt.Fprintf(os.Stderr, "Downloading latest fullsend release for linux/%s...\n", arch)
latestErr := DownloadLatestRelease(arch, binaryPath)
if latestErr == nil {
fmt.Fprintf(os.Stderr, "Downloaded latest fullsend for linux/%s\n", arch)
return AcquireResult{TmpDir: tmpDir, Path: binaryPath, Source: SourceReleaseDownload}, nil
}
fmt.Fprintf(os.Stderr, "WARNING: latest release download failed: %v\n", latestErr)

os.RemoveAll(tmpDir)
return AcquireResult{}, fmt.Errorf("all strategies failed for linux/%s: provide --fullsend-binary or install Go toolchain", arch)
}

// 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) {
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
} else {
fmt.Fprintf(os.Stderr, "WARNING: cross-compilation failed: %v\n", ccErr)
}

// 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)
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)
}
}

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)
}
64 changes: 64 additions & 0 deletions internal/binary/crosscompile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package binary

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// CrossCompileOpts configures a cross-compilation build.
type CrossCompileOpts struct {
Version string // CLI version to embed (before stamp suffix)
Arch string
DestPath string
VersionStamp string // e.g. "-vendored", "-crosscompiled", or ""
}

// ModuleRoot returns the fullsend module root directory, or an error if not
// inside a Go module checkout.
func ModuleRoot() (string, error) {
goPath, lookErr := exec.LookPath("go")
if lookErr != nil {
return "", fmt.Errorf("Go toolchain not found: %w", lookErr)
}
modRootCmd := exec.Command(goPath, "env", "GOMOD")
modOutput, err := modRootCmd.Output()
if err != nil {
return "", fmt.Errorf("finding module root: %w", err)
}
modPath := strings.TrimSpace(string(modOutput))
if modPath == "" || modPath == os.DevNull {
return "", fmt.Errorf("not in a Go module")
}
return filepath.Dir(modPath), nil
}

// 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 {
goPath, lookErr := exec.LookPath("go")
if lookErr != nil {
return fmt.Errorf("Go toolchain not found — install Go or use a released version of fullsend: %w", lookErr)
}

modRoot, err := ModuleRoot()
if err != nil {
return fmt.Errorf("not in a Go module — run from the fullsend source tree or use a released version: %w", err)
}

versionLD := opts.Version + opts.VersionStamp
buildCmd := exec.Command(goPath, "build",
"-ldflags", fmt.Sprintf("-X github.com/fullsend-ai/fullsend/internal/cli.version=%s", versionLD),
"-o", opts.DestPath,
"./cmd/fullsend/",
)
buildCmd.Dir = modRoot
buildCmd.Env = append(os.Environ(), "GOTOOLCHAIN=auto", "GOOS=linux", "GOARCH="+opts.Arch, "CGO_ENABLED=0")
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("cross-compiling for linux/%s: %w", opts.Arch, err)
}
return nil
}
Loading
Loading