Skip to content
Closed
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
101 changes: 99 additions & 2 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func newInstallCmd() *cobra.Command {
var agents string
var dryRun bool
var skipAppSetup bool
var vendorBinary bool
var gcpProject string
var gcpRegion string
var gcpServiceAccount string
Expand Down Expand Up @@ -161,6 +162,10 @@ func newInstallCmd() *cobra.Command {
inferenceProviderName = loadExistingInferenceProvider(ctx, client, org)
}

if vendorBinary && dryRun {
return fmt.Errorf("--vendor-fullsend-binary cannot be used with --dry-run")
}

if dryRun {
return runDryRun(ctx, client, printer, org, repos, roles, inferenceProvider, inferenceProviderName)
}
Expand All @@ -175,14 +180,15 @@ func newInstallCmd() *cobra.Command {
agentCreds = creds
}

return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName)
return runInstall(ctx, client, printer, org, repos, roles, agentCreds, inferenceProvider, inferenceProviderName, vendorBinary)
},
}

cmd.Flags().StringSliceVar(&repos, "repo", nil, "repositories to enable (repeatable)")
cmd.Flags().StringVar(&agents, "agents", "fullsend,triage,coder,review", "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, "cross-compile and upload fullsend binary to the config repo for development iteration")
cmd.Flags().StringVar(&gcpProject, "gcp-project", "", "GCP project ID for Vertex AI inference")
cmd.Flags().StringVar(&gcpRegion, "gcp-region", "", "GCP region for Vertex AI (e.g. us-east5, required with --gcp-project)")
cmd.Flags().StringVar(&gcpServiceAccount, "gcp-service-account", "", "existing GCP service account name (optional, used with --gcp-project)")
Expand Down Expand Up @@ -354,7 +360,7 @@ func runAppSetup(ctx context.Context, client forge.Client, printer *ui.Printer,
}

// runInstall performs the full installation.
func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, org string, enabledRepos, roles []string, agentCreds []layers.AgentCredentials, inferenceProvider inference.Provider, inferenceProviderName string) 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, vendorBinary bool) error {
printer.Header("Discovering repositories")

allRepos, err := client.ListOrgRepos(ctx, org)
Expand Down Expand Up @@ -411,6 +417,16 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o
}
printer.Blank()

if vendorBinary {
if err := vendorFullsendBinary(ctx, client, printer, org); err != nil {
return fmt.Errorf("vendoring binary: %w", err)
}
} else {
if err := clearVendoredBinary(ctx, client, printer, org); err != nil {
return fmt.Errorf("clearing vendored binary: %w", err)
}
}

// Dispatch token setup — the .fullsend repo now exists so the user
// can select it when creating the fine-grained PAT.
dispatchToken, err := promptDispatchToken(ctx, client, printer, org)
Expand Down Expand Up @@ -743,6 +759,87 @@ func loadKnownSlugs(ctx context.Context, client forge.Client, org string) map[st
return cfg.AgentSlugs()
}

// vendorFullsendBinary cross-compiles the fullsend binary for linux/amd64
// and uploads it to bin/fullsend in the config repo. It also sets the
// FULLSEND_USE_VENDORED_BINARY repo variable to "true" so the action
// will use the vendored binary. This enables rapid iteration during
// development without cutting a release.
func vendorFullsendBinary(ctx context.Context, client forge.Client, printer *ui.Printer, org string) error {
printer.StepStart("Cross-compiling fullsend for linux/amd64")

tmpBinary, err := os.CreateTemp("", "fullsend-linux-amd64-*")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpBinary.Close()
defer os.Remove(tmpBinary.Name())

// Cross-compile with a minimal environment to avoid leaking tokens
// or other sensitive variables into the build subprocess.
buildCmd := exec.Command("go", "build",
"-ldflags", fmt.Sprintf("-X github.com/fullsend-ai/fullsend/internal/cli.version=%s-vendored", version),
"-o", tmpBinary.Name(),
"github.com/fullsend-ai/fullsend/cmd/fullsend",
)
buildCmd.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=" + os.Getenv("PATH"),
"GOPATH=" + os.Getenv("GOPATH"),
"GOROOT=" + os.Getenv("GOROOT"),
"GOMODCACHE=" + os.Getenv("GOMODCACHE"),
"GOOS=linux",
"GOARCH=amd64",
"CGO_ENABLED=0",
}
buildCmd.Stderr = os.Stderr
if err := buildCmd.Run(); err != nil {
printer.StepFail("Cross-compilation failed")
return fmt.Errorf("cross-compiling (ensure 'go' is on PATH and you are in the fullsend module): %w", err)
}
printer.StepDone("Cross-compiled fullsend for linux/amd64")

printer.StepStart("Uploading vendored binary to config repo")
if err := layers.VendorBinary(ctx, client, org, tmpBinary.Name()); err != nil {
printer.StepFail("Failed to upload vendored binary")
return err
}
info, _ := os.Stat(tmpBinary.Name())
if info != nil {
printer.StepDone(fmt.Sprintf("Uploaded vendored binary (%.1f MB)", float64(info.Size())/(1024*1024)))
} else {
printer.StepDone("Uploaded vendored binary")
}

printer.StepStart("Setting FULLSEND_USE_VENDORED_BINARY repo variable")
if err := client.CreateOrUpdateRepoVariable(ctx, org, forge.ConfigRepoName,
"FULLSEND_USE_VENDORED_BINARY", "true"); err != nil {
printer.StepFail("Failed to set repo variable")
return fmt.Errorf("setting FULLSEND_USE_VENDORED_BINARY variable: %w", err)
}
printer.StepDone("Set FULLSEND_USE_VENDORED_BINARY=true on " + forge.ConfigRepoName)

return nil
}

// clearVendoredBinary disables the vendored binary by setting
// FULLSEND_USE_VENDORED_BINARY to "false". This ensures that a previous
// --vendor-fullsend-binary install doesn't leave the opt-in flag active
// when the admin re-runs install without the flag.
func clearVendoredBinary(ctx context.Context, client forge.Client, printer *ui.Printer, org string) error {
exists, err := client.RepoVariableExists(ctx, org, forge.ConfigRepoName, "FULLSEND_USE_VENDORED_BINARY")
if err != nil || !exists {
return nil
}
printer.StepStart("Disabling vendored binary")
if err := client.CreateOrUpdateRepoVariable(ctx, org, forge.ConfigRepoName,
"FULLSEND_USE_VENDORED_BINARY", "false"); err != nil {
printer.StepFail("Failed to disable vendored binary")
return fmt.Errorf("clearing FULLSEND_USE_VENDORED_BINARY variable: %w", err)
}
printer.StepDone("Set FULLSEND_USE_VENDORED_BINARY=false (vendored binary disabled)")
return nil
}

// collectEnrolledRepoIDs returns the IDs of repos whose names appear in
// the enabledRepos list.
func collectEnrolledRepoIDs(allRepos []forge.Repository, enabledRepos []string) []int64 {
Expand Down
17 changes: 17 additions & 0 deletions internal/cli/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ 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)
}

func TestUninstallCmd_RequiresOrg(t *testing.T) {
Expand Down Expand Up @@ -112,3 +116,16 @@ func TestResolveToken_GitHubTokenFallback(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "github-token-456", token)
}

func TestInstallCmd_VendorBinaryDryRunConflict(t *testing.T) {
// Ensure --vendor-fullsend-binary and --dry-run cannot be used together.
// We set GH_TOKEN to pass token resolution, but the command should fail
// before making any API calls.
t.Setenv("GH_TOKEN", "test-token")

cmd := newRootCmd()
cmd.SetArgs([]string{"admin", "install", "--vendor-fullsend-binary", "--dry-run", "test-org"})
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be used with --dry-run")
}
20 changes: 20 additions & 0 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type FakeClient struct {
CreatedBranches []string // "owner/repo/branch"
CreatedProposals []ChangeProposal
DeletedRepos []string // "owner/repo"
DeletedFiles []FileRecord
CreatedSecrets []SecretRecord
Variables []VariableRecord
DeletedOrgSecrets []string // "org/name"
Expand Down Expand Up @@ -263,6 +264,25 @@ func (f *FakeClient) GetFileContent(_ context.Context, owner, repo, path string)
return data, nil
}

func (f *FakeClient) DeleteFile(_ context.Context, owner, repo, path, message string) error {
f.mu.Lock()
defer f.mu.Unlock()

if e := f.err("DeleteFile"); e != nil {
return e
}

f.DeletedFiles = append(f.DeletedFiles, FileRecord{
Owner: owner,
Repo: repo,
Path: path,
Message: message,
})
key := owner + "/" + repo + "/" + path
delete(f.FileContents, key)
return nil
}

func (f *FakeClient) CreateBranch(_ context.Context, owner, repo, branchName string) error {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
2 changes: 2 additions & 0 deletions internal/forge/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ func TestFakeClient_ErrorInjection(t *testing.T) {
{"CreateFile", func(fc *FakeClient) error { return fc.CreateFile(ctx, "o", "r", "p", "m", nil) }},
{"CreateOrUpdateFile", func(fc *FakeClient) error { return fc.CreateOrUpdateFile(ctx, "o", "r", "p", "m", nil) }},
{"GetFileContent", func(fc *FakeClient) error { _, err := fc.GetFileContent(ctx, "o", "r", "p"); return err }},
{"DeleteFile", func(fc *FakeClient) error { return fc.DeleteFile(ctx, "o", "r", "p", "m") }},
{"CreateBranch", func(fc *FakeClient) error { return fc.CreateBranch(ctx, "o", "r", "b") }},
{"CreateFileOnBranch", func(fc *FakeClient) error { return fc.CreateFileOnBranch(ctx, "o", "r", "b", "p", "m", nil) }},
{"CreateChangeProposal", func(fc *FakeClient) error {
Expand Down Expand Up @@ -397,6 +398,7 @@ func TestFakeClient_ThreadSafety(t *testing.T) {
_ = fc.CreateFile(ctx, "o", "r", "p", "m", []byte("data"))
_ = fc.CreateOrUpdateFile(ctx, "o", "r", "p", "m", []byte("data"))
_, _ = fc.GetFileContent(ctx, "o", "r", "file.txt")
_ = fc.DeleteFile(ctx, "o", "r", "p", "m")
_ = fc.CreateBranch(ctx, "o", "r", "b")
_ = fc.CreateFileOnBranch(ctx, "o", "r", "b", "p", "m", []byte("data"))
_, _ = fc.CreateChangeProposal(ctx, "o", "r", "t", "b", "h", "base")
Expand Down
5 changes: 5 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ type Client interface {

GetFileContent(ctx context.Context, owner, repo, path string) ([]byte, error)

// DeleteFile deletes a file from a repository. Idempotent: returns nil
// if the file does not exist. On GitHub, deleting requires the file's
// current SHA, which the implementation fetches automatically.
DeleteFile(ctx context.Context, owner, repo, path, message string) error

// Branch operations
CreateBranch(ctx context.Context, owner, repo, branchName string) error
CreateFileOnBranch(ctx context.Context, owner, repo, branch, path, message string, content []byte) error
Expand Down
40 changes: 40 additions & 0 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,46 @@ func (c *LiveClient) GetFileContent(ctx context.Context, owner, repo, path strin
return data, nil
}

// DeleteFile deletes a file from a repository. Idempotent: returns nil if
// the file does not exist.
func (c *LiveClient) DeleteFile(ctx context.Context, owner, repo, path, message string) error {
apiPath := fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, path)

// Get current SHA (required by the Contents API for deletes).
existingResp, err := c.do(ctx, http.MethodGet, apiPath, nil)
if err != nil {
return fmt.Errorf("check existing file for delete: %w", err)
}
if existingResp.StatusCode == http.StatusNotFound {
existingResp.Body.Close()
return nil // already gone
}
if err := checkStatus(existingResp, http.StatusOK); err != nil {
return fmt.Errorf("get file for delete: %w", err)
}

var existing struct {
SHA string `json:"sha"`
}
if err := decodeJSON(existingResp, &existing); err != nil {
return fmt.Errorf("decode existing file for delete: %w", err)
}

payload := map[string]any{
"message": message,
"sha": existing.SHA,
}
resp, err := c.do(ctx, http.MethodDelete, apiPath, payload)
if err != nil {
return fmt.Errorf("delete file %s: %w", path, err)
}
defer resp.Body.Close()
if err := checkStatus(resp, http.StatusOK); err != nil {
return fmt.Errorf("delete file %s: %w", path, err)
}
return nil
}

// CreateBranch creates a new branch from the repository's default branch.
func (c *LiveClient) CreateBranch(ctx context.Context, owner, repo, branchName string) error {
// Step 1: Get the default branch name.
Expand Down
42 changes: 42 additions & 0 deletions internal/forge/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -819,3 +819,45 @@ func TestListOrgRepos_Pagination(t *testing.T) {
assert.Len(t, repos, 101)
assert.Equal(t, 2, page) // Should have made exactly 2 requests
}

func TestDeleteFile(t *testing.T) {
step := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
step++
switch step {
case 1:
// GET to fetch SHA
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "/repos/owner/repo/contents/bin/fullsend", r.URL.Path)
json.NewEncoder(w).Encode(map[string]any{"sha": "abc123"})
case 2:
// DELETE with SHA
assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "/repos/owner/repo/contents/bin/fullsend", r.URL.Path)
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
assert.Equal(t, "abc123", body["sha"])
assert.Equal(t, "remove file", body["message"])
json.NewEncoder(w).Encode(map[string]any{"commit": map[string]any{"sha": "def456"}})
}
}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.DeleteFile(context.Background(), "owner", "repo", "bin/fullsend", "remove file")
require.NoError(t, err)
assert.Equal(t, 2, step)
}

func TestDeleteFile_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, `{"message": "Not Found"}`)
}))
defer srv.Close()

client := newTestClient(t, srv)
err := client.DeleteFile(context.Background(), "owner", "repo", "bin/fullsend", "remove file")
require.NoError(t, err) // idempotent: returns nil when file doesn't exist
}
24 changes: 24 additions & 0 deletions internal/layers/vendor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package layers

import (
"context"
"fmt"
"os"

"github.com/fullsend-ai/fullsend/internal/forge"
)

// VendorBinary uploads a pre-built fullsend binary to .fullsend/bin/fullsend.
// CI workflows detect this file and use it instead of downloading from
// GitHub releases, enabling development iteration without cutting a release.
func VendorBinary(ctx context.Context, client forge.Client, org, binaryPath string) error {
data, err := os.ReadFile(binaryPath)
if err != nil {
return fmt.Errorf("reading binary %s: %w", binaryPath, err)
}
if err := client.CreateOrUpdateFile(ctx, org, forge.ConfigRepoName,
"bin/fullsend", "chore: vendor fullsend binary for development", data); err != nil {
return fmt.Errorf("uploading vendored binary: %w", err)
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,26 @@ runs:
VERSION: ${{ inputs.version }}
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
FULLSEND_USE_VENDORED_BINARY: ${{ vars.FULLSEND_USE_VENDORED_BINARY }}
run: |
set -euo pipefail

# Use vendored binary if present and explicitly opted in.
# The FULLSEND_USE_VENDORED_BINARY repo variable must be set to "true"
# to prevent untrusted binaries from being executed.
if [[ "${FULLSEND_USE_VENDORED_BINARY}" == "true" ]] && [[ -f "${GITHUB_WORKSPACE}/bin/fullsend" ]]; then
echo "Using vendored fullsend binary from bin/fullsend"
mkdir -p "${RUNNER_TEMP}/fullsend"
cp "${GITHUB_WORKSPACE}/bin/fullsend" "${RUNNER_TEMP}/fullsend/fullsend"
chmod +x "${RUNNER_TEMP}/fullsend/fullsend"
if ! "${RUNNER_TEMP}/fullsend/fullsend" version; then
echo "::error::Vendored fullsend binary failed validation"
exit 1
fi
echo "${RUNNER_TEMP}/fullsend" >> "${GITHUB_PATH}"
exit 0
fi

VERSION="$(printf '%s' "${VERSION:-latest}" | tr -d '[:space:]')"
VERSION="${VERSION:-latest}"

Expand Down
Loading