// Dagger pipeline for multi-platform builds. // Usage: // // go run ci/dagger.go [--release v1.0.0] // // If --release is provided, uploads all built binaries in dist/ to the specified Gitea release. // Requires .env file with GITEA_TOKEN, GITEA_URL, GITEA_OWNER, GITEA_REPO. // Requires: go install dagger.io/dagger@latest && go get github.com/joho/godotenv && go mod tidy package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "mime/multipart" "net/http" "os" "os/exec" "path/filepath" "strings" "dagger.io/dagger" "github.com/joho/godotenv" ) func main() { releaseTag := flag.String("release", "", "(optional) Release tag to upload binaries to Gitea") flag.Parse() ctx := context.Background() client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout)) if err != nil { panic(err) } defer client.Close() platforms := []struct { OS string Arch string }{ {"darwin", "arm64"}, {"darwin", "amd64"}, {"windows", "amd64"}, {"linux", "amd64"}, } src := client.Host().Directory(".") var builtBinaries []string for _, p := range platforms { binName := fmt.Sprintf("valhallir-deconvolver-%s-%s", p.OS, p.Arch) if p.OS == "windows" { binName += ".exe" } ctr := client.Container().From("golang:1.24.5"). WithMountedDirectory("/src", src). WithWorkdir("/src"). WithEnvVariable("GOOS", p.OS). WithEnvVariable("GOARCH", p.Arch). WithExec([]string{"go", "build", "-o", binName, "."}) // Export the binary from the container to the host's dist/ directory outPath := fmt.Sprintf("dist/%s", binName) _, err := ctr.File(binName).Export(ctx, outPath) if err != nil { panic(fmt.Sprintf("export failed for %s/%s: %v", p.OS, p.Arch, err)) } fmt.Printf("Built and exported %s\n", outPath) builtBinaries = append(builtBinaries, outPath) } if *releaseTag != "" { fmt.Printf("\nUploading binaries to Gitea release: %s\n", *releaseTag) // Load .env err := godotenv.Load("ci/.env") if err != nil { panic("Error loading .env file: " + err.Error()) } giteaToken := os.Getenv("GITEA_TOKEN") giteaURL := os.Getenv("GITEA_URL") giteaOwner := os.Getenv("GITEA_OWNER") giteaRepo := os.Getenv("GITEA_REPO") if giteaToken == "" || giteaURL == "" || giteaOwner == "" || giteaRepo == "" { panic("GITEA_TOKEN, GITEA_URL, GITEA_OWNER, GITEA_REPO must be set in .env") } // 1. Get or create the release releaseID, err := getOrCreateRelease(giteaURL, giteaOwner, giteaRepo, *releaseTag, giteaToken) if err != nil { panic("Failed to get or create release: " + err.Error()) } // 2. Upload each binary as an asset for _, bin := range builtBinaries { fmt.Printf("Uploading %s...\n", bin) err := uploadAsset(giteaURL, giteaOwner, giteaRepo, releaseID, bin, giteaToken) if err != nil { panic(fmt.Sprintf("Failed to upload %s: %v", bin, err)) } fmt.Printf("Uploaded %s\n", bin) } fmt.Println("All binaries uploaded to Gitea release.") // 3. Tag the release locally and push the tag if err := tagAndPush(*releaseTag); err != nil { panic("Failed to tag and push: " + err.Error()) } fmt.Printf("Tagged and pushed %s\n", *releaseTag) } } func getOrCreateRelease(url, owner, repo, tag, token string) (int, error) { // Try to get the release by tag api := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/tags/%s", strings.TrimRight(url, "/"), owner, repo, tag) req, _ := http.NewRequest("GET", api, nil) req.Header.Set("Authorization", "token "+token) resp, err := http.DefaultClient.Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode == 200 { // Parse JSON to get ID type releaseResp struct{ ID int } var r releaseResp io.ReadAll(resp.Body) // ignore body for now, parse below dec := json.NewDecoder(resp.Body) dec.Decode(&r) return r.ID, nil } // If not found, create it if resp.StatusCode == 404 { api = fmt.Sprintf("%s/api/v1/repos/%s/%s/releases", strings.TrimRight(url, "/"), owner, repo) body := strings.NewReader(fmt.Sprintf(`{"tag_name":"%s","name":"%s"}`, tag, tag)) req, _ = http.NewRequest("POST", api, body) req.Header.Set("Authorization", "token "+token) req.Header.Set("Content-Type", "application/json") resp, err = http.DefaultClient.Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode == 201 { type releaseResp struct{ ID int } var r releaseResp dec := json.NewDecoder(resp.Body) dec.Decode(&r) return r.ID, nil } return 0, fmt.Errorf("failed to create release: %s", resp.Status) } return 0, fmt.Errorf("failed to get release: %s", resp.Status) } func uploadAsset(url, owner, repo string, releaseID int, filePath, token string) error { api := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%d/assets?name=%s", strings.TrimRight(url, "/"), owner, repo, releaseID, filepath.Base(filePath)) file, err := os.Open(filePath) if err != nil { return err } defer file.Close() var b bytes.Buffer w := multipart.NewWriter(&b) f, err := w.CreateFormFile("attachment", filepath.Base(filePath)) if err != nil { return err } _, err = io.Copy(f, file) if err != nil { return err } w.Close() req, _ := http.NewRequest("POST", api, &b) req.Header.Set("Authorization", "token "+token) req.Header.Set("Content-Type", w.FormDataContentType()) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 { return fmt.Errorf("upload failed: %s", resp.Status) } return nil } func tagAndPush(tag string) error { // Check if tag exists cmd := exec.Command("git", "tag", "--list", tag) out, err := cmd.Output() if err != nil { return err } if strings.TrimSpace(string(out)) == tag { // Tag already exists return nil } // Create tag cmd = exec.Command("git", "tag", tag) if err := cmd.Run(); err != nil { return err } // Push tag cmd = exec.Command("git", "push", "origin", tag) if err := cmd.Run(); err != nil { return err } return nil }