diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b1e1..1010b52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,9 @@ go install ./cmd/builder ./builder ios build --profile store --submit # Short for: ios release (no groups) ./builder ios build --profile development --distribute # Build, then print an over-the-air install link + QR code ./builder ios distribute [--ipa x.ipa] [--once] [--json] # Same for an existing IPA; --cleanup removes leftovers +./builder builds [--provider p] [--limit N] [--status running|failed|succeeded] [--json] # Build history +./builder builds show|logs|download|cancel # id: build ID, provider run ID or run URL; logs --failed --follow +./builder ios cancel --provider p --run-id X # deprecated alias of builds cancel X --provider p ./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json) ./builder asc groups create [--external] # also: groups delete, groups add-build ./builder asc testers add ... --group # also: testers remove, users invite @@ -216,6 +219,7 @@ internal/ ipa/ # Info.plist and embedded.mobileprovision reading from .ipa archives otainstall/ # ios distribute: over-the-air install links (manifest, QR, GitHub draft release + gist backend) build/ # Build coordination (snapshot + trigger + poll + download) + builds/ # builds: history over GitHub/Codemagic/Bitrise (Source: list, find, show, logs, download, cancel) signing/ # CSR generation, .p12 assembly, and Auto (portal-free provisioning on top of asc) snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref workflow/ # Workflow template (embedded) @@ -428,6 +432,21 @@ internal/ - **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light); `TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`. +- **Build History** (`internal/builds`): the providers are the source of truth, nothing is stored. A + `Source` per provider normalizes runs into `Build` (status queued/running/succeeded/failed/cancelled, + kind `ipa`/`simulator` by workflow). GitHub lists `ios-build.yml` + `ios-share.yml` runs and takes the + ID off the end of `display_title` (`iOS Build ` or `.../ios-build/`); Codemagic/Bitrise list + the configured build/share workflows (`ci.History`) and read `BUILD_ID`/`BUILDER_PROFILE` from the + variables they report back, else the `.ipa` artifact name. `inputs` sends `BUILDER_PROFILE` only + for a profile (the runner ignores it; `BUILDER_` is reserved). GitHub runs carry no inputs, so no profile. +- **Build Refs**: `` is a build ID (8 hex, searched in recent runs; an all-digit miss falls back to a + run ID), a run ID, or a URL whose host also picks the provider (`ProviderFromURL`). `builds cancel` + with a bare Codemagic/Bitrise run ID skips the lookup and calls `build.CancelRemote` directly, which + keeps `ios cancel --run-id` (now a deprecated alias) exactly as before. +- **Build Logs**: a `LogStream` prints what is new per `Read`; `--follow` loops `builds.Follow`. GitHub + serves a job log (`/actions/jobs/{id}/logs`, 302 to storage) only once the job ends, Codemagic per + step (`buildActions[].logUrl`, token only to the API host), Bitrise as chunks by position, else the + archived raw URL. `builds download` reuses `Coordinator.DownloadArtifact` / `build.DownloadRemote`. - **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets` (internal/signing/sets.go) hold the non-interactive core of `signing setup` and on-demand provisioning; cmd/builder keeps the prompts, the plan and the diff --git a/README.md b/README.md index cc24fe8..1f3c7ad 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,42 @@ build cannot pick a [profile](#build-profiles) per run; it applies the profile named by `defaultProfile`, if there is one. The simulator build takes no profile at all. +## Build history + +The provider stays the source of truth: `builder builds` reads the runs Builder +started there and shows them in one shape, whether they ran on GitHub Actions, +Codemagic or Bitrise. Nothing is stored locally. + +```bash +builder builds # Newest 20 builds on the default provider +builder builds --status failed --limit 50 --json +builder builds --provider codemagic # Another provider's builds +builder builds show 1a2b3c4d # Jobs and steps, failed step and its errors, artifacts and expiry +builder builds logs 1a2b3c4d --failed # Only the failed jobs (GitHub) or steps (Codemagic) +builder builds logs 1a2b3c4d --follow # Keep printing until the build ends +builder builds download 1a2b3c4d # The IPA into ./dist/ (-o to change), e.g. after Ctrl-C on ios build +builder builds cancel 1a2b3c4d +``` + +`` is the 8-character build ID `ios build` prints, a provider run ID, or a +run URL; a URL also picks its provider, so `--provider` is only needed for bare +run IDs on a non-default provider. The listing covers IPA builds (`ios build`, +kind `ipa`) and simulator sessions (`ios share`, kind `simulator`): + +- **GitHub**: runs of `ios-build.yml` and `ios-share.yml`; the build ID comes + from the run name (`iOS Build `, or the tag for tag-triggered runs). Job + logs exist once a job has ended, so `--follow` prints step names while a job + runs and its log when it ends. Artifacts expire after 7 days. GitHub does not + report workflow inputs, so the profile column stays empty. +- **Codemagic / Bitrise**: builds of the configured `build_workflow` and + `share_workflow`; the build ID and profile come from the variables Builder sent + (`BUILD_ID`, `BUILDER_PROFILE`), else from the IPA's name. Codemagic serves logs + per step; Bitrise serves one build log, so `--failed` prints all of a failed + build's log. + +`builder ios cancel --provider --run-id ` still works and now means +`builder builds cancel --provider `. + ## Additional macOS Providers GitHub Actions remains the default, so existing commands continue to work. Add @@ -215,6 +251,13 @@ builder ios build --unsigned # Build without code signing (if signing is config builder ios build --provider codemagic # Build on another provider (also: bitrise) builder ios build --profile production # Build with a profile from builder.json +# Build history (GitHub, Codemagic and Bitrise) +builder builds # Recent builds: ID, kind, profile, status, started, duration, run +builder builds show # Jobs, steps, failure, artifacts (: build ID, run ID or run URL) +builder builds logs --follow # Logs until the build ends (--failed: failed jobs/steps only) +builder builds download # The IPA into ./dist/ +builder builds cancel # Cancel a running build + # Simulator (free, needs a MOBAI_API_KEY secret) builder ios share # Try the build on a simulator in the MobAI app builder ios share --duration 1h # Keep it available longer while unused @@ -273,7 +316,7 @@ builder asc users # Team members and whether they can test internall builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee ``` -Every `release`/`upload`/`submit`/`distribute`/`asc` command takes `--json` for +Every `release`/`upload`/`submit`/`distribute`/`asc` command, and `builds`/`builds show`, takes `--json` for machine-readable output and never prompts, so agents and CI jobs can drive them. ## Configuration diff --git a/cmd/builder/builds.go b/cmd/builder/builds.go new file mode 100644 index 0000000..9ae579f --- /dev/null +++ b/cmd/builder/builds.go @@ -0,0 +1,353 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/MobAI-App/ios-builder/internal/build" + "github.com/MobAI-App/ios-builder/internal/builds" + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/spf13/cobra" +) + +// newBuildsSource opens the history of one provider. A package var so tests +// can point it at fake servers. +var newBuildsSource = func(cfg *config.Config, name string) (builds.Source, error) { + if name == "github" { + gh, err := getGitHubClient() + if err != nil { + return nil, err + } + return &builds.GitHub{Client: gh, Config: cfg, Out: os.Stderr}, nil + } + p, ciCfg, err := build.RemoteProvider(cfg, name) + if err != nil { + return nil, err + } + rp, ok := p.(builds.RemoteProvider) + if !ok { + return nil, fmt.Errorf("%s has no build history support", name) + } + return &builds.Remote{P: rp, CI: ciCfg, Project: cfg.Project}, nil +} + +// buildsLogInterval is how often `builds logs --follow` polls. +var buildsLogInterval = 10 * time.Second + +var buildsCmd = &cobra.Command{ + Use: "builds", + Short: "List Builder builds and inspect, download or cancel one", + Long: `Lists the builds Builder started on the selected provider (GitHub Actions by +default, or Codemagic/Bitrise), newest first. The provider stays the source of +truth: nothing is stored locally. + + in the subcommands is the 8-character Builder build ID, a provider run ID, +or a run URL (whose host also picks the provider).`, + Args: cobra.NoArgs, + RunE: runBuildsList, +} + +var buildsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a build: status, jobs and steps, failure, artifacts", + Args: cobra.ExactArgs(1), + RunE: runBuildsShow, +} + +var buildsLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Print a build's logs", + Long: `Prints the logs of a build: GitHub job logs, Codemagic step logs, or the Bitrise +build log. GitHub and Codemagic publish a job's or step's log once it ends, so +--follow prints those as they end (and GitHub step names as they start), until +the build finishes.`, + Args: cobra.ExactArgs(1), + RunE: runBuildsLogs, +} + +var buildsDownloadCmd = &cobra.Command{ + Use: "download ", + Short: "Download a build's IPA", + Long: "Downloads the IPA of a finished build, for example after `ios build` was interrupted. GitHub keeps artifacts for 7 days.", + Args: cobra.ExactArgs(1), + RunE: runBuildsDownload, +} + +var buildsCancelCmd = &cobra.Command{ + Use: "cancel ", + Short: "Cancel a running build", + Args: cobra.ExactArgs(1), + RunE: runBuildsCancel, +} + +func init() { + rootCmd.AddCommand(buildsCmd) + buildsCmd.PersistentFlags().String("provider", "", "Provider to read (default builder.json provider, else github; a run URL picks its own)") + buildsCmd.Flags().Int("limit", 20, "Number of builds to list") + buildsCmd.Flags().String("status", "", "Only builds that are running, failed or succeeded") + buildsCmd.Flags().Bool("json", false, "Print JSON") + buildsShowCmd.Flags().Bool("json", false, "Print JSON") + buildsLogsCmd.Flags().Bool("failed", false, "Only the logs of failed jobs or steps") + buildsLogsCmd.Flags().BoolP("follow", "f", false, "Keep printing until the build finishes") + buildsDownloadCmd.Flags().StringP("output", "o", "dist", "Output directory for the IPA") + buildsCmd.AddCommand(buildsShowCmd, buildsLogsCmd, buildsDownloadCmd, buildsCancelCmd) +} + +// buildsSource picks the provider: --provider, else the one a run URL names, +// else builder.json's. +func buildsSource(cmd *cobra.Command, ref string) (builds.Source, error) { + cfg, err := loadConfig() + if err != nil { + return nil, err + } + flag, _ := cmd.Flags().GetString("provider") + if flag == "" { + flag = builds.ProviderFromURL(ref) + } + name, err := cfg.ProviderName(flag) + if err != nil { + return nil, err + } + return newBuildsSource(cfg, name) +} + +// findBuild resolves on the selected provider. +func findBuild(cmd *cobra.Command, ref string) (builds.Source, builds.Build, error) { + src, err := buildsSource(cmd, ref) + if err != nil { + return nil, builds.Build{}, err + } + b, err := src.Find(cmd.Context(), ref) + return src, b, err +} + +func runBuildsList(cmd *cobra.Command, _ []string) error { + limit, _ := cmd.Flags().GetInt("limit") + status, _ := cmd.Flags().GetString("status") + asJSON, _ := cmd.Flags().GetBool("json") + if limit <= 0 { + return fmt.Errorf("--limit must be positive") + } + if err := builds.ValidStatus(status); err != nil { + return err + } + src, err := buildsSource(cmd, "") + if err != nil { + return err + } + list, err := src.List(cmd.Context(), builds.ListOptions{Limit: limit, Status: status}) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if asJSON { + if list == nil { + list = []builds.Build{} + } + return writeJSONTo(out, list) + } + if len(list) == 0 { + fmt.Fprintf(out, "No builds on %s.\n", src.Provider()) + return nil + } + now := time.Now() + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "BUILD ID\tKIND\tPROFILE\tSTATUS\tSTARTED\tDURATION\tRUN") + for i := range list { + b := &list[i] + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", orDash(b.ID), b.Kind, orDash(b.Profile), statusText(b), + started(b), duration(b, now), runText(b)) + } + return tw.Flush() +} + +func runBuildsShow(cmd *cobra.Command, args []string) error { + src, b, err := findBuild(cmd, args[0]) + if err != nil { + return err + } + d, err := src.Show(cmd.Context(), &b) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if asJSON, _ := cmd.Flags().GetBool("json"); asJSON { + return writeJSONTo(out, &d) + } + printDetail(out, &d, time.Now()) + return nil +} + +func printDetail(w io.Writer, d *builds.Detail, now time.Time) { + title := "Build " + orDash(d.ID) + extra := []string{d.Kind} + if d.Profile != "" { + extra = append(extra, "profile "+d.Profile) + } + fmt.Fprintf(w, "%s (%s)\n", title, strings.Join(extra, ", ")) + fmt.Fprintf(w, " Provider: %s, run %s\n", d.Provider, d.RunID) + if d.URL != "" { + fmt.Fprintf(w, " URL: %s\n", d.URL) + } + fmt.Fprintf(w, " Status: %s\n", statusText(&d.Build)) + fmt.Fprintf(w, " Started: %s\n", started(&d.Build)) + fmt.Fprintf(w, " Duration: %s\n", duration(&d.Build, now)) + for _, j := range d.Jobs { + fmt.Fprintf(w, "\nJob %s: %s\n", j.Name, j.Status) + for _, s := range j.Steps { + fmt.Fprintf(w, " %2d. %-40s %s\n", s.Number, s.Name, s.Status) + } + } + if f := d.Failure; f != nil { + fmt.Fprintln(w) + switch { + case f.Step != "": + fmt.Fprintf(w, "Failed step: %s (job %s)\n", f.Step, f.Job) + case f.Job != "": + fmt.Fprintf(w, "Failed job: %s\n", f.Job) + } + for _, m := range f.Messages { + fmt.Fprintf(w, " %s\n", strings.ReplaceAll(m, "\n", "\n ")) + } + } + fmt.Fprintln(w) + if len(d.Artifacts) == 0 { + fmt.Fprintln(w, "Artifacts: none") + return + } + fmt.Fprintln(w, "Artifacts:") + for _, a := range d.Artifacts { + note := "" + switch { + case a.Expired: + note = " expired" + case a.ExpiresAt != nil: + note = " expires " + a.ExpiresAt.Local().Format("2006-01-02 15:04") + } + fmt.Fprintf(w, " %s %.2f MB%s\n", a.Name, float64(a.Size)/(1024*1024), note) + } +} + +func runBuildsLogs(cmd *cobra.Command, args []string) error { + src, b, err := findBuild(cmd, args[0]) + if err != nil { + return err + } + failed, _ := cmd.Flags().GetBool("failed") + follow, _ := cmd.Flags().GetBool("follow") + out := cmd.OutOrStdout() + stream := src.Logs(&b, failed) + if follow { + return builds.Follow(cmd.Context(), stream, out, buildsLogInterval) + } + done, err := stream.Read(cmd.Context(), out) + if err != nil { + return err + } + if !done { + fmt.Fprintf(cmd.ErrOrStderr(), "Build %s is still %s; pass --follow to keep printing until it ends.\n", orDash(b.ID), b.Status) + } + return nil +} + +func runBuildsDownload(cmd *cobra.Command, args []string) error { + src, b, err := findBuild(cmd, args[0]) + if err != nil { + return err + } + dir, _ := cmd.Flags().GetString("output") + path, size, err := src.Download(cmd.Context(), &b, dir) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "IPA: %s (%.2f MB)\n", path, float64(size)/(1024*1024)) + return nil +} + +func runBuildsCancel(cmd *cobra.Command, args []string) error { + return cancelBuild(cmd, args[0]) +} + +func cancelBuild(cmd *cobra.Command, ref string) error { + src, err := buildsSource(cmd, ref) + if err != nil { + return err + } + var b builds.Build + if src.Provider() != "github" && !builds.IsBuildID(ref) && !strings.Contains(ref, "://") { + // A Codemagic or Bitrise run ID goes straight to the cancel API, which + // confirms the run has stopped, as `ios cancel --run-id` always did. + b = builds.Build{RunID: ref, Provider: src.Provider(), Status: builds.StatusRunning} + } else if b, err = src.Find(cmd.Context(), ref); err != nil { + return err + } + out := cmd.OutOrStdout() + if b.Done() { + fmt.Fprintf(out, "Build %s already ended: %s.\n", orDash(b.ID), statusText(&b)) + return nil + } + if err := src.Cancel(cmd.Context(), &b); err != nil { + return fmt.Errorf("cancellation could not be confirmed; check %s: %w", orDash(b.URL), err) + } + if src.Provider() == "github" { + fmt.Fprintf(out, "Cancellation requested for build %s (run %s).\n", orDash(b.ID), b.RunID) + return nil + } + fmt.Fprintf(out, "Build %s (run %s) has stopped.\n", orDash(b.ID), b.RunID) + return nil +} + +func writeJSONTo(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} + +// statusText is the normalized status, with the provider's word when it says +// more ("failed (timed_out)"). +func statusText(b *builds.Build) string { + if b.State == "" || strings.EqualFold(b.State, b.Status) || + (b.Status == builds.StatusSucceeded && b.State == "success") || + (b.Status == builds.StatusFailed && b.State == "failure") { + return b.Status + } + return b.Status + " (" + b.State + ")" +} + +func started(b *builds.Build) string { + t := b.CreatedAt + if b.StartedAt != nil { + t = *b.StartedAt + } + if t.IsZero() { + return "-" + } + return t.Local().Format("2006-01-02 15:04") +} + +func duration(b *builds.Build, now time.Time) string { + d := b.Duration(now) + if d == 0 { + return "-" + } + return d.Round(time.Second).String() +} + +func runText(b *builds.Build) string { + if b.URL != "" { + return b.URL + } + return b.RunID +} diff --git a/cmd/builder/builds_test.go b/cmd/builder/builds_test.go new file mode 100644 index 0000000..8dabddb --- /dev/null +++ b/cmd/builder/builds_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/builds" + "github.com/MobAI-App/ios-builder/internal/config" +) + +// stubSource is a provider history in memory; the provider fakes over HTTP +// live in internal/builds. +type stubSource struct { + name string + list []builds.Build + calls []string + cancelled []string +} + +func (s *stubSource) Provider() string { return s.name } + +func (s *stubSource) List(_ context.Context, opts builds.ListOptions) ([]builds.Build, error) { + s.calls = append(s.calls, fmt.Sprintf("list %d %s", opts.Limit, opts.Status)) + return s.list, nil +} + +func (s *stubSource) Find(_ context.Context, ref string) (builds.Build, error) { + s.calls = append(s.calls, "find "+ref) + for i := range s.list { + b := s.list[i] + if b.ID == ref || b.RunID == ref || b.URL == ref { + return b, nil + } + } + return builds.Build{}, fmt.Errorf("no build %s", ref) +} + +func (s *stubSource) Show(_ context.Context, b *builds.Build) (builds.Detail, error) { + return builds.Detail{Build: *b, Jobs: []builds.Job{{Name: "build", Status: "failure", Steps: []builds.Step{{Number: 1, Name: "Build IPA", Status: "failure"}}}}, + Failure: &builds.Failure{Job: "build", Step: "Build IPA", Messages: []string{"No profile found"}}}, nil +} + +type stubLogs struct{ failed bool } + +func (l stubLogs) Read(_ context.Context, w io.Writer) (bool, error) { + fmt.Fprintf(w, "log failed=%v\n", l.failed) + return true, nil +} + +func (s *stubSource) Logs(_ *builds.Build, failed bool) builds.LogStream { return stubLogs{failed} } + +func (s *stubSource) Download(_ context.Context, b *builds.Build, dir string) (string, int64, error) { + return filepath.Join(dir, "App-"+b.ID+".ipa"), 2 << 20, nil +} + +func (s *stubSource) Cancel(_ context.Context, b *builds.Build) error { + s.cancelled = append(s.cancelled, b.RunID) + return nil +} + +func stubBuilds(t *testing.T) map[string]*stubSource { + t.Helper() + chdir(t) + cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}, + Codemagic: config.CIConfig{AppID: "cm", Branch: "main"}, Bitrise: config.CIConfig{AppID: "br", Branch: "main"}} + if err := config.NewManager().Save(cfg); err != nil { + t.Fatal(err) + } + start := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC) + end := start.Add(7 * time.Minute) + sources := map[string]*stubSource{} + for _, name := range []string{"github", "codemagic", "bitrise"} { + sources[name] = &stubSource{name: name, list: []builds.Build{ + {ID: "1234abcd", Kind: builds.KindIPA, Status: builds.StatusRunning, State: "in_progress", Provider: name, RunID: "30", CreatedAt: start}, + {ID: "deadbeef", Kind: builds.KindIPA, Profile: "store", Status: builds.StatusFailed, State: "failure", Provider: name, RunID: "20", + URL: "https://app.bitrise.io/build/20", CreatedAt: start, StartedAt: &start, FinishedAt: &end}, + }} + } + old := newBuildsSource + newBuildsSource = func(_ *config.Config, name string) (builds.Source, error) { return sources[name], nil } + t.Cleanup(func() { newBuildsSource = old }) + return sources +} + +func TestBuildsList(t *testing.T) { + src := stubBuilds(t) + stdout, stderr, err := run(t, "builds", "--status", "failed", "--limit", "5") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + if src["github"].calls[0] != "list 5 failed" { + t.Fatalf("calls: %v", src["github"].calls) + } + for _, want := range []string{"BUILD ID", "deadbeef", "store", "7m0s", "https://app.bitrise.io/build/20", "running (in_progress)"} { + if !strings.Contains(stdout, want) { + t.Errorf("list lacks %q:\n%s", want, stdout) + } + } + + stdout, _, err = run(t, "builds", "--json", "--provider", "codemagic") + if err != nil { + t.Fatal(err) + } + var got []map[string]any + if err := json.Unmarshal([]byte(stdout), &got); err != nil || len(got) != 2 { + t.Fatalf("json: %v\n%s", err, stdout) + } + if got[1]["provider"] != "codemagic" || got[1]["duration_seconds"] != float64(420) || got[1]["profile"] != "store" { + t.Fatalf("json fields: %v", got[1]) + } + + if _, _, err := run(t, "builds", "--status", "done"); err == nil { + t.Error("an unknown --status was accepted") + } +} + +func TestBuildsShowLogsDownload(t *testing.T) { + src := stubBuilds(t) + stdout, _, err := run(t, "builds", "show", "deadbeef") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Build deadbeef (ipa, profile store)", "Failed step: Build IPA (job build)", "No profile found", "Artifacts: none"} { + if !strings.Contains(stdout, want) { + t.Errorf("show lacks %q:\n%s", want, stdout) + } + } + // A run URL picks its provider. + stdout, _, err = run(t, "builds", "show", "https://app.bitrise.io/build/20", "--json") + if err != nil { + t.Fatal(err) + } + var detail map[string]any + if err := json.Unmarshal([]byte(stdout), &detail); err != nil || detail["id"] != "deadbeef" || detail["duration_seconds"] != float64(420) || + detail["failure"] == nil || detail["artifacts"] == nil || detail["provider"] != "bitrise" { + t.Fatalf("show --json: %v\n%s", err, stdout) + } + if len(src["bitrise"].calls) != 1 || len(src["github"].calls) != 1 { + t.Fatalf("URL routing: github %v bitrise %v", src["github"].calls, src["bitrise"].calls) + } + + stdout, _, err = run(t, "builds", "logs", "20", "--failed") + if err != nil || stdout != "log failed=true\n" { + t.Fatalf("logs: %q %v", stdout, err) + } + + stdout, _, err = run(t, "builds", "download", "deadbeef", "-o", "out") + if err != nil || !strings.Contains(stdout, filepath.Join("out", "App-deadbeef.ipa")) || !strings.Contains(stdout, "2.00 MB") { + t.Fatalf("download: %q %v", stdout, err) + } +} + +func TestBuildsCancelAndIOSCancelAlias(t *testing.T) { + src := stubBuilds(t) + stdout, _, err := run(t, "builds", "cancel", "1234abcd") + if err != nil || !strings.Contains(stdout, "Cancellation requested") || len(src["github"].cancelled) != 1 { + t.Fatalf("cancel: %q %v %v", stdout, err, src["github"].cancelled) + } + stdout, _, err = run(t, "builds", "cancel", "deadbeef") + if err != nil || !strings.Contains(stdout, "already ended") || len(src["github"].cancelled) != 1 { + t.Fatalf("cancel of a finished build: %q %v", stdout, err) + } + + // The old command still works, and a Codemagic run ID goes straight to + // the cancel API without a lookup. + stdout, _, err = run(t, "ios", "cancel", "--provider", "codemagic", "--run-id", "cm-run") + if err != nil || !strings.Contains(stdout, "has stopped") { + t.Fatalf("ios cancel: %q %v", stdout, err) + } + if got := src["codemagic"]; len(got.cancelled) != 1 || got.cancelled[0] != "cm-run" || len(got.calls) != 0 { + t.Fatalf("ios cancel: cancelled %v calls %v", got.cancelled, got.calls) + } + if _, _, err := run(t, "ios", "cancel", "--provider", "codemagic"); err == nil { + t.Error("ios cancel without --run-id") + } +} diff --git a/cmd/builder/providers.go b/cmd/builder/providers.go index 113e282..9608af1 100644 --- a/cmd/builder/providers.go +++ b/cmd/builder/providers.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" - "github.com/MobAI-App/ios-builder/internal/build" "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/workflow" @@ -118,27 +117,18 @@ func runProviderInit(cmd *cobra.Command) error { } func init() { - cancelCmd := &cobra.Command{Use: "cancel", Short: "Cancel a submitted Codemagic or Bitrise run", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - cfg, err := loadConfig() - if err != nil { - return err - } - name, _ := cmd.Flags().GetString("provider") - id, _ := cmd.Flags().GetString("run-id") - if id == "" { - return fmt.Errorf("--run-id is required") - } - p, _, err := build.RemoteProvider(cfg, name) - if err != nil { - return err - } - if err := build.CancelRemote(cmd.Context(), p, id); err != nil { - return fmt.Errorf("cancellation could not be confirmed; check the provider dashboard: %w", err) - } - fmt.Println("Run has stopped.") - return nil - }} - cancelCmd.Flags().String("provider", "", "Provider holding the run (codemagic or bitrise)") + // Kept for scripts written before `builds cancel`, which also takes a + // Builder build ID or run URL and covers GitHub. + cancelCmd := &cobra.Command{Use: "cancel", Short: "Cancel a submitted run (use builds cancel)", Args: cobra.NoArgs, + Deprecated: "use `builder builds cancel [--provider name]`", + RunE: func(cmd *cobra.Command, _ []string) error { + id, _ := cmd.Flags().GetString("run-id") + if id == "" { + return fmt.Errorf("--run-id is required") + } + return cancelBuild(cmd, id) + }} + cancelCmd.Flags().String("provider", "", "Provider holding the run (github, codemagic or bitrise)") cancelCmd.Flags().String("run-id", "", "Run ID from the provider workflow URL") iosCmd.AddCommand(cancelCmd) } diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 0dcd66e..a2ea796 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -762,7 +762,7 @@ func runIOSShare(cmd *cobra.Command, args []string) error { fmt.Println("Simulator session submitted. The build and bridge must start before it appears in MobAI under CI Devices.") fmt.Println("The workflow is limited to 90 minutes including setup/build; idle duration is not a guaranteed session length.") fmt.Printf("Workflow: %s\n", result.WorkflowURL) - fmt.Printf("Cancel: builder ios cancel --provider %s --run-id %s\n", name, result.ProviderRunID) + fmt.Printf("Cancel: builder builds cancel %s --provider %s\n", result.ProviderRunID, name) fmt.Printf("After the run finishes, remove its snapshot: git push %s --delete refs/ios-builder/jobs/%s\n", remote, result.BuildID) return nil } diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index 780243c..fa8e539 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -209,8 +209,8 @@ before it is ready. Release it in MobAI when finished, or cancel using the run ID printed by the CLI: ```sh -builder ios cancel --provider codemagic --run-id CODEMAGIC_RUN_ID -builder ios cancel --provider bitrise --run-id BITRISE_RUN_ID +builder builds cancel CODEMAGIC_RUN_ID --provider codemagic +builder builds cancel BITRISE_RUN_ID --provider bitrise ``` Run the printed snapshot cleanup command after the job stops. `--duration` is diff --git a/docs/providers.md b/docs/providers.md index 9e8d7f7..445387d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -172,7 +172,7 @@ First [create a MobAI key and add it to each provider](provider-secrets.md#3-cre ```sh builder ios share --provider codemagic builder ios share --provider bitrise --duration 20m -builder ios cancel --provider bitrise --run-id RUN_SLUG +builder builds cancel RUN_SLUG --provider bitrise ``` Set `MOBAI_API_KEY` as a secret on the chosen provider. Any MobAI account will @@ -186,7 +186,7 @@ The remote job outlives the CLI and consumes minutes until released, idle, cancelled, or timed out. Workflows on these free plans are capped at 90 minutes including setup and compilation. `--duration` controls idle time only (maximum 60 minutes for these providers), so it does not guarantee that much usable time. -The CLI prints an explicit cancellation command. `ios cancel` confirms the run +The CLI prints an explicit cancellation command. `builds cancel` (formerly `ios cancel`, still accepted) confirms the run has stopped; it does not affect another provider's runs. Submitted simulator sessions retain their source ref while queued/running. diff --git a/internal/build/download.go b/internal/build/download.go new file mode 100644 index 0000000..af794a9 --- /dev/null +++ b/internal/build/download.go @@ -0,0 +1,60 @@ +package build + +import ( + "context" + "fmt" + + "github.com/MobAI-App/ios-builder/internal/ci" +) + +// DownloadArtifact saves the IPA inside a GitHub artifact as +// /-.ipa, the same file `ios build` writes, so a +// build whose command was interrupted can still be fetched. name is the +// build ID, or the run ID when the build ID is unknown. +func (c *Coordinator) DownloadArtifact(ctx context.Context, artifactID int64, name, outputDir string) (string, int64, error) { + if c.github == nil { + return "", 0, fmt.Errorf("GitHub client is required") + } + if outputDir == "" { + outputDir = "dist" + } + path, size, err := c.downloadIPAArtifactByID(ctx, outputDir, artifactID, name) + if err != nil { + return "", 0, err + } + c.progress.Finish() + return path, size, nil +} + +// DownloadRemote saves the IPA of a finished Codemagic or Bitrise run under +// the same name `ios build` uses, after the same checks. +func DownloadRemote(ctx context.Context, p ci.Provider, runID, buildID, outputDir, project string) (string, int64, error) { + if outputDir == "" { + outputDir = "dist" + } + run := ci.Run{ID: runID} + status, err := p.Status(ctx, run) + if err != nil { + return "", 0, err + } + if !status.Done { + return "", 0, fmt.Errorf("the build is still running (%s); its IPA is not there yet", status.State) + } + if !status.Success { + return "", 0, fmt.Errorf("the build ended without success (%s), so it has no IPA", status.State) + } + if lister, ok := p.(ci.ArtifactLister); ok { + if status.Artifacts, err = lister.Artifacts(ctx, run); err != nil { + return "", 0, err + } + } + artifact, err := findIPA(status.Artifacts, buildID) + if err != nil { + return "", 0, err + } + name := buildID + if name == "" { + name = runID + } + return saveRemoteIPA(ctx, p, run, artifact, outputDir, project, name) +} diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 6aeeb82..9ddf82d 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -144,7 +144,7 @@ func TestRemoteInputsMapping(t *testing.T) { s, _, _ = c.settings("preview", "", false) got = c.inputs("abcdef12", "ref", "sha", s) - if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" { + if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" || got["BUILDER_PROFILE"] != "preview" { t.Fatalf("profile mapping: %v", got) } var env map[string]string diff --git a/internal/build/remote.go b/internal/build/remote.go index 4f48493..2d32ce7 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -88,6 +88,11 @@ func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) if s.Distribution != "" { v["DISTRIBUTION"] = s.Distribution } + // Read back by `builder builds`, which finds the profile in the variables + // the provider reports; the runner ignores it. + if s.Profile != "" { + v["BUILDER_PROFILE"] = s.Profile + } return v } diff --git a/internal/builds/builds.go b/internal/builds/builds.go new file mode 100644 index 0000000..82ef36b --- /dev/null +++ b/internal/builds/builds.go @@ -0,0 +1,251 @@ +// Package builds is the history of Builder builds across providers. GitHub +// Actions, Codemagic and Bitrise stay the source of truth; this package reads +// their runs and normalizes them into one shape keyed by the Builder build ID. +package builds + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "sort" + "strings" + "time" +) + +// Kinds of build. +const ( + KindIPA = "ipa" // ios build + KindSimulator = "simulator" // ios share +) + +// Normalized statuses. +const ( + StatusQueued = "queued" + StatusRunning = "running" + StatusSucceeded = "succeeded" + StatusFailed = "failed" + StatusCancelled = "cancelled" +) + +// Build is one Builder build as any provider reports it. +type Build struct { + ID string `json:"id,omitempty"` // Builder build ID (8 hex), empty when not recoverable + Kind string `json:"kind"` + Profile string `json:"profile,omitempty"` + Status string `json:"status"` + State string `json:"state"` // the provider's own status/conclusion + Provider string `json:"provider"` + RunID string `json:"run_id"` + URL string `json:"url,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// Done reports whether the build has stopped. +func (b *Build) Done() bool { + return b.Status == StatusSucceeded || b.Status == StatusFailed || b.Status == StatusCancelled +} + +// Duration is how long the build has run: until it finished, or until now +// while it runs. Zero before it starts. +func (b *Build) Duration(now time.Time) time.Duration { + start := b.CreatedAt + if b.StartedAt != nil { + start = *b.StartedAt + } + if start.IsZero() { + return 0 + } + end := now + if b.FinishedAt != nil { + end = *b.FinishedAt + } else if b.Done() { + return 0 + } + if end.Before(start) { + return 0 + } + return end.Sub(start) +} + +// MarshalJSON adds the duration in whole seconds. Marshal a *Build (or a +// slice element) so it applies. +func (b *Build) MarshalJSON() ([]byte, error) { + type plain Build + return json.Marshal(struct { + plain + DurationSeconds int64 `json:"duration_seconds"` + }{plain(*b), int64(b.Duration(time.Now()).Seconds())}) +} + +// Job is a job of a run (GitHub) or the run itself (Codemagic, Bitrise). +type Job struct { + Name string `json:"name"` + Status string `json:"status"` + Steps []Step `json:"steps,omitempty"` +} + +// Step is a step of a job. +type Step struct { + Number int `json:"number,omitempty"` + Name string `json:"name"` + Status string `json:"status"` +} + +// Failure is why a build failed: the failed job and step and what the runner +// reported, as far as the provider tells. +type Failure struct { + Job string `json:"job,omitempty"` + Step string `json:"step,omitempty"` + Messages []string `json:"messages,omitempty"` +} + +// Artifact is a file a build uploaded. +type Artifact struct { + Name string `json:"name"` + Size int64 `json:"size"` + Expired bool `json:"expired,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// Detail is a build with its jobs, failure and artifacts. +type Detail struct { + Build + Jobs []Job `json:"jobs,omitempty"` + Failure *Failure `json:"failure,omitempty"` + Artifacts []Artifact `json:"artifacts"` +} + +// MarshalJSON keeps the embedded Build's fields and duration at the top +// level. Marshal a *Detail so it applies. +func (d *Detail) MarshalJSON() ([]byte, error) { + base, err := d.Build.MarshalJSON() + if err != nil { + return nil, err + } + var fields map[string]any + if err := json.Unmarshal(base, &fields); err != nil { + return nil, err + } + if len(d.Jobs) > 0 { + fields["jobs"] = d.Jobs + } + if d.Failure != nil { + fields["failure"] = d.Failure + } + artifacts := d.Artifacts + if artifacts == nil { + artifacts = []Artifact{} + } + fields["artifacts"] = artifacts + return json.Marshal(fields) +} + +// ListOptions narrow a listing. +type ListOptions struct { + Limit int // newest first; 0 means 20 + Status string // "", running (includes queued), failed (includes cancelled), succeeded +} + +// LogStream prints a build's log in pieces. Read writes what is new since the +// last call and reports whether the build has finished and everything was +// printed. +type LogStream interface { + Read(ctx context.Context, w io.Writer) (done bool, err error) +} + +// Source is one provider's view of Builder builds. +type Source interface { + Provider() string + List(ctx context.Context, opts ListOptions) ([]Build, error) + // Find resolves a Builder build ID, a provider run ID or a run URL. + Find(ctx context.Context, ref string) (Build, error) + Show(ctx context.Context, b *Build) (Detail, error) + Logs(b *Build, failedOnly bool) LogStream + // Download saves the build's IPA into dir and returns its path and size. + Download(ctx context.Context, b *Build, dir string) (string, int64, error) + Cancel(ctx context.Context, b *Build) error +} + +var buildIDRe = regexp.MustCompile(`^[0-9a-f]{8}$`) + +// IsBuildID reports whether ref has the shape of a Builder build ID. +func IsBuildID(ref string) bool { return buildIDRe.MatchString(ref) } + +// ValidStatus checks a --status value. +func ValidStatus(s string) error { + switch s { + case "", StatusRunning, StatusFailed, StatusSucceeded: + return nil + } + return fmt.Errorf("unknown status %q: use running, failed or succeeded", s) +} + +// matches applies a --status filter. +func matches(status, filter string) bool { + switch filter { + case "": + return true + case StatusRunning: + return status == StatusRunning || status == StatusQueued + case StatusFailed: + return status == StatusFailed || status == StatusCancelled + } + return status == filter +} + +// sortBuilds orders builds newest first. +func sortBuilds(bs []Build) { + sort.SliceStable(bs, func(i, j int) bool { return bs[i].CreatedAt.After(bs[j].CreatedAt) }) +} + +func limitOf(opts ListOptions) int { + if opts.Limit <= 0 { + return 20 + } + return opts.Limit +} + +func timePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +// ProviderFromURL names the provider a run URL belongs to, or "" when ref is +// not one. +func ProviderFromURL(ref string) string { + switch { + case strings.Contains(ref, "github.com/") && strings.Contains(ref, "/actions/runs/"): + return "github" + case strings.Contains(ref, "codemagic.io/"): + return "codemagic" + case strings.Contains(ref, "bitrise.io/"): + return "bitrise" + } + return "" +} + +// Follow reads a log stream until the build ends, waiting interval between +// reads. +func Follow(ctx context.Context, s LogStream, w io.Writer, interval time.Duration) error { + for { + done, err := s.Read(ctx, w) + if err != nil || done { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + } +} + +func notFound(provider, ref string) error { + return fmt.Errorf("no %s build matches %q; pass a Builder build ID from `builder builds`, a run ID or a run URL", provider, ref) +} diff --git a/internal/builds/github.go b/internal/builds/github.go new file mode 100644 index 0000000..df1322c --- /dev/null +++ b/internal/builds/github.go @@ -0,0 +1,353 @@ +package builds + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/MobAI-App/ios-builder/internal/build" + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" +) + +// The two workflow files Builder writes, and what each produces. +var githubWorkflows = []struct{ file, kind string }{ + {build.WorkflowFile, KindIPA}, + {"ios-share.yml", KindSimulator}, +} + +// GitHub reads Builder runs of ios-build.yml and ios-share.yml. +type GitHub struct { + Client *github.Client + Config *config.Config + // Out receives download progress; nil discards it. + Out io.Writer +} + +func (*GitHub) Provider() string { return "github" } + +func (g *GitHub) owner() string { return g.Config.GitHub.Owner } +func (g *GitHub) repo() string { return g.Config.GitHub.Repo } + +// runTitleIDRe takes the build ID off the end of a run-name: "iOS Build +// abcd1234" (dispatch) or "iOS Build ios-build/abcd1234" (tag push). +var runTitleIDRe = regexp.MustCompile(`(?:^|[\s/])([0-9a-f]{8})$`) + +func (g *GitHub) build(run *github.WorkflowRun, kind string) Build { + title := run.DisplayTitle + if title == "" { + title = run.Name + } + b := Build{Kind: kind, Provider: "github", RunID: strconv.FormatInt(run.ID, 10), URL: run.HTMLURL, + CreatedAt: run.CreatedAt, StartedAt: timePtr(run.RunStartedAt)} + if m := runTitleIDRe.FindStringSubmatch(strings.TrimSpace(title)); m != nil { + b.ID = m[1] + } + switch run.Status { + case "completed": + b.State = run.Conclusion + b.FinishedAt = timePtr(run.UpdatedAt) + switch run.Conclusion { + case "success": + b.Status = StatusSucceeded + case "cancelled", "skipped": + b.Status = StatusCancelled + default: + b.Status = StatusFailed + } + case "queued", "waiting", "requested", "pending": + b.State, b.Status = run.Status, StatusQueued + default: + b.State, b.Status = run.Status, StatusRunning + } + return b +} + +// maxPages bounds a listing: 1000 runs per workflow file. +const maxPages = 10 + +func (g *GitHub) List(ctx context.Context, opts ListOptions) ([]Build, error) { + limit := limitOf(opts) + var all []Build + for _, wf := range githubWorkflows { + found := 0 + for page := 1; page <= maxPages && found < limit; page++ { + runs, err := g.Client.ListWorkflowRunsPage(ctx, g.owner(), g.repo(), wf.file, min(100, max(limit, 20)), page) + if err != nil { + var apiErr *github.APIError + // A repository without the share workflow has no simulator builds. + if wf.kind == KindSimulator && errors.As(err, &apiErr) && apiErr.Status == "404" { + break + } + return nil, err + } + for i := range runs { + b := g.build(&runs[i], wf.kind) + if matches(b.Status, opts.Status) { + all = append(all, b) + found++ + } + } + if len(runs) < min(100, max(limit, 20)) { + break + } + } + } + sortBuilds(all) + if len(all) > limit { + all = all[:limit] + } + return all, nil +} + +// runURLRe matches https://github.com///actions/runs/[/...]. +var runURLRe = regexp.MustCompile(`^/([^/]+)/([^/]+)/actions/runs/(\d+)`) + +func (g *GitHub) Find(ctx context.Context, ref string) (Build, error) { + ref = strings.TrimSpace(ref) + if strings.Contains(ref, "://") { + u, err := url.Parse(ref) + if err != nil { + return Build{}, fmt.Errorf("not a run URL: %s", ref) + } + m := runURLRe.FindStringSubmatch(u.Path) + if m == nil { + return Build{}, fmt.Errorf("not a GitHub Actions run URL: %s", ref) + } + if !strings.EqualFold(m[1], g.owner()) || !strings.EqualFold(m[2], g.repo()) { + return Build{}, fmt.Errorf("run %s belongs to %s/%s, not %s/%s from builder.json", m[3], m[1], m[2], g.owner(), g.repo()) + } + return g.byRunID(ctx, m[3]) + } + if IsBuildID(ref) { + b, err := g.byBuildID(ctx, ref) + if err == nil { + return b, nil + } + if _, numErr := strconv.ParseInt(ref, 10, 64); numErr != nil { + return Build{}, err + } + } + if _, err := strconv.ParseInt(ref, 10, 64); err != nil { + return Build{}, notFound("GitHub", ref) + } + return g.byRunID(ctx, ref) +} + +// byBuildID searches the newest runs of both workflows for the build ID. +func (g *GitHub) byBuildID(ctx context.Context, id string) (Build, error) { + for _, wf := range githubWorkflows { + for page := 1; page <= 3; page++ { + runs, err := g.Client.ListWorkflowRunsPage(ctx, g.owner(), g.repo(), wf.file, 100, page) + if err != nil { + var apiErr *github.APIError + if wf.kind == KindSimulator && errors.As(err, &apiErr) && apiErr.Status == "404" { + break + } + return Build{}, err + } + for i := range runs { + if b := g.build(&runs[i], wf.kind); b.ID == id { + return b, nil + } + } + if len(runs) < 100 { + break + } + } + } + return Build{}, notFound("GitHub", id) +} + +// byRunID reads one run. The run object does not name its workflow file, so +// the kind comes from the run name. +func (g *GitHub) byRunID(ctx context.Context, id string) (Build, error) { + n, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return Build{}, notFound("GitHub", id) + } + run, err := g.Client.GetWorkflowRun(ctx, g.owner(), g.repo(), n) + if err != nil { + return Build{}, err + } + kind := KindIPA + if strings.HasPrefix(run.Name, "iOS Simulator") || strings.HasPrefix(run.DisplayTitle, "iOS Simulator") { + kind = KindSimulator + } + return g.build(run, kind), nil +} + +func (g *GitHub) runID(b *Build) (int64, error) { + n, err := strconv.ParseInt(b.RunID, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid GitHub run ID %q", b.RunID) + } + return n, nil +} + +func stepStatus(status, conclusion string) string { + if status == "completed" && conclusion != "" { + return conclusion + } + return status +} + +func (g *GitHub) Show(ctx context.Context, b *Build) (Detail, error) { + d := Detail{Build: *b} + id, err := g.runID(b) + if err != nil { + return d, err + } + jobs, err := g.Client.ListRunJobs(ctx, g.owner(), g.repo(), id) + if err != nil { + return d, err + } + for i := range jobs { + j := &jobs[i] + job := Job{Name: j.Name, Status: stepStatus(j.Status, j.Conclusion)} + for _, s := range j.Steps { + job.Steps = append(job.Steps, Step{Number: s.Number, Name: s.Name, Status: stepStatus(s.Status, s.Conclusion)}) + } + d.Jobs = append(d.Jobs, job) + } + if b.Status == StatusFailed { + // Best-effort, as for `ios build`: the conclusion is shown either way. + if f, _ := g.Client.RunFailure(ctx, g.owner(), g.repo(), id); f != nil { + d.Failure = &Failure{Job: f.Job, Step: f.Step, Messages: f.Messages} + } + } + artifacts, err := g.Client.ListRunArtifacts(ctx, g.owner(), g.repo(), id) + if err != nil { + return d, err + } + for _, a := range artifacts { + d.Artifacts = append(d.Artifacts, Artifact{Name: a.Name, Size: a.SizeInBytes, Expired: a.Expired, ExpiresAt: timePtr(a.ExpiresAt)}) + } + return d, nil +} + +func (g *GitHub) Download(ctx context.Context, b *Build, dir string) (string, int64, error) { + if b.Kind == KindSimulator { + return "", 0, fmt.Errorf("build %s is a simulator session (ios share); it has no IPA", b.label()) + } + id, err := g.runID(b) + if err != nil { + return "", 0, err + } + artifacts, err := g.Client.ListRunArtifacts(ctx, g.owner(), g.repo(), id) + if err != nil { + return "", 0, err + } + var ipa *github.Artifact + for i := range artifacts { + if artifacts[i].Name == build.IPAArtifactName { + ipa = &artifacts[i] + } + } + switch { + case ipa == nil && !b.Done(): + return "", 0, fmt.Errorf("build %s is still %s and has not uploaded its IPA yet", b.label(), b.Status) + case ipa == nil: + return "", 0, fmt.Errorf("build %s (%s) uploaded no IPA", b.label(), b.State) + case ipa.Expired: + return "", 0, fmt.Errorf("the IPA of build %s expired on %s; GitHub keeps artifacts for 7 days", b.label(), ipa.ExpiresAt.Format("2006-01-02")) + } + out := g.Out + if out == nil { + out = io.Discard + } + return build.NewCoordinatorWithOutput(g.Config, g.Client, out).DownloadArtifact(ctx, ipa.ID, b.fileName(), dir) +} + +func (g *GitHub) Cancel(ctx context.Context, b *Build) error { + id, err := g.runID(b) + if err != nil { + return err + } + return g.Client.CancelWorkflowRun(ctx, g.owner(), g.repo(), id) +} + +func (g *GitHub) Logs(b *Build, failedOnly bool) LogStream { + return &githubLogs{g: g, b: *b, failedOnly: failedOnly, printed: map[int64]bool{}, stepSeen: map[string]bool{}} +} + +// githubLogs prints each job's log once the job completes (GitHub serves job +// logs only then) and, while a job runs, a line per step as it starts. +type githubLogs struct { + g *GitHub + b Build + failedOnly bool + printed map[int64]bool + stepSeen map[string]bool +} + +func (l *githubLogs) Read(ctx context.Context, w io.Writer) (bool, error) { + id, err := l.g.runID(&l.b) + if err != nil { + return false, err + } + // The run's state is read before its jobs, so a run seen completed here + // has every job's log available below. + run, err := l.g.Client.GetWorkflowRun(ctx, l.g.owner(), l.g.repo(), id) + if err != nil { + return false, err + } + jobs, err := l.g.Client.ListRunJobs(ctx, l.g.owner(), l.g.repo(), id) + if err != nil { + return false, err + } + for i := range jobs { + j := &jobs[i] + if j.Status != "completed" { + if l.failedOnly { + continue + } + for _, s := range j.Steps { + key := fmt.Sprintf("%d/%d", j.ID, s.Number) + if s.Status == "queued" || s.Status == "pending" || l.stepSeen[key] { + continue + } + l.stepSeen[key] = true + fmt.Fprintf(w, "--- %s: step %d %s\n", j.Name, s.Number, s.Name) + } + continue + } + if l.printed[j.ID] { + continue + } + l.printed[j.ID] = true + if l.failedOnly && j.Conclusion != "failure" { + continue + } + data, err := l.g.Client.JobLogs(ctx, l.g.owner(), l.g.repo(), j.ID) + if err != nil { + return false, fmt.Errorf("job %s: %w", j.Name, err) + } + fmt.Fprintf(w, "==> %s (%s)\n", j.Name, stepStatus(j.Status, j.Conclusion)) + _, _ = w.Write(data) + if len(data) > 0 && data[len(data)-1] != '\n' { + fmt.Fprintln(w) + } + } + return run.Status == "completed", nil +} + +func (b *Build) label() string { + if b.ID != "" { + return b.ID + } + return b.RunID +} + +// fileName is the name part of the saved IPA, as `ios build` uses it. +func (b *Build) fileName() string { + if b.ID != "" { + return b.ID + } + return b.Provider + "-" + b.RunID +} diff --git a/internal/builds/github_test.go b/internal/builds/github_test.go new file mode 100644 index 0000000..3dbf235 --- /dev/null +++ b/internal/builds/github_test.go @@ -0,0 +1,376 @@ +package builds + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" +) + +// ipaBytes is a minimal IPA: one app bundle with an Info.plist. +func ipaBytes(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + z := zip.NewWriter(&buf) + w, err := z.Create("Payload/App.app/Info.plist") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("")) + if err := z.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// githubFake is a repository with three ios-build.yml runs (one succeeded by +// dispatch, one failed from a tag push, one running) and one ios-share.yml run. +type githubFake struct { + t *testing.T + srv *httptest.Server + mu sync.Mutex + // runningDone flips run 30 to completed (and its job's log available). + runningDone bool + cancelled []string + expired bool + calls []string +} + +func newGitHubFake(t *testing.T) *githubFake { + f := &githubFake{t: t} + artifactZip := func() []byte { + var buf bytes.Buffer + z := zip.NewWriter(&buf) + w, _ := z.Create("abcd1234.ipa") + _, _ = w.Write(ipaBytes(t)) + _ = z.Close() + return buf.Bytes() + }() + run := func(id int, title, status, conclusion, created string) string { + return fmt.Sprintf(`{"id":%d,"name":%q,"display_title":%q,"status":%q,"conclusion":%q,"html_url":"https://github.com/o/r/actions/runs/%d","created_at":%q,"run_started_at":%q,"updated_at":"2026-10-01T10:%s:00Z"}`, + id, title, title, status, conclusion, id, created, created, "30") + } + mux := http.NewServeMux() + handle := func(pattern string, h http.HandlerFunc) { + mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + f.mu.Unlock() + if r.Header.Get("Authorization") != "Bearer tok" && !strings.HasPrefix(r.URL.Path, "/blob/") { + t.Errorf("%s without the token", r.URL.Path) + } + h(w, r) + }) + } + running := func() string { + f.mu.Lock() + defer f.mu.Unlock() + if f.runningDone { + return run(30, "iOS Build 1234abcd", "completed", "success", "2026-10-01T10:20:00Z") + } + return run(30, "iOS Build 1234abcd", "in_progress", "", "2026-10-01T10:20:00Z") + } + handle("GET /repos/o/r/actions/workflows/ios-build.yml/runs", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "1" { + fmt.Fprint(w, `{"total_count":3,"workflow_runs":[]}`) + return + } + fmt.Fprintf(w, `{"total_count":3,"workflow_runs":[%s,%s,%s]}`, running(), + run(20, "iOS Build ios-build/deadbeef", "completed", "failure", "2026-10-01T10:10:00Z"), + run(10, "iOS Build abcd1234", "completed", "success", "2026-10-01T10:00:00Z")) + }) + handle("GET /repos/o/r/actions/workflows/ios-share.yml/runs", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"total_count":1,"workflow_runs":[%s]}`, run(40, "iOS Simulator cafef00d", "completed", "cancelled", "2026-10-01T10:15:00Z")) + }) + handle("GET /repos/o/r/actions/runs/{id}", func(w http.ResponseWriter, r *http.Request) { + switch r.PathValue("id") { + case "10": + fmt.Fprint(w, run(10, "iOS Build abcd1234", "completed", "success", "2026-10-01T10:00:00Z")) + case "20": + fmt.Fprint(w, run(20, "iOS Build ios-build/deadbeef", "completed", "failure", "2026-10-01T10:10:00Z")) + case "30": + fmt.Fprint(w, running()) + case "40": + fmt.Fprint(w, run(40, "iOS Simulator cafef00d", "completed", "cancelled", "2026-10-01T10:15:00Z")) + default: + w.WriteHeader(404) + fmt.Fprint(w, `{"message":"Not Found"}`) + } + }) + handle("GET /repos/o/r/actions/runs/{id}/jobs", func(w http.ResponseWriter, r *http.Request) { + switch r.PathValue("id") { + case "20": + fmt.Fprint(w, `{"total_count":2,"jobs":[ + {"id":201,"name":"prepare","status":"completed","conclusion":"success","steps":[{"name":"Checkout","status":"completed","conclusion":"success","number":1}]}, + {"id":202,"name":"build","status":"completed","conclusion":"failure","steps":[ + {"name":"Checkout","status":"completed","conclusion":"success","number":1}, + {"name":"Build IPA","status":"completed","conclusion":"failure","number":2}]}]}`) + case "30": + f.mu.Lock() + done := f.runningDone + f.mu.Unlock() + if done { + fmt.Fprint(w, `{"total_count":1,"jobs":[{"id":301,"name":"build","status":"completed","conclusion":"success","steps":[ + {"name":"Checkout","status":"completed","conclusion":"success","number":1}, + {"name":"Build IPA","status":"completed","conclusion":"success","number":2}]}]}`) + return + } + fmt.Fprint(w, `{"total_count":1,"jobs":[{"id":301,"name":"build","status":"in_progress","steps":[ + {"name":"Checkout","status":"completed","conclusion":"success","number":1}, + {"name":"Build IPA","status":"in_progress","number":2}, + {"name":"Upload IPA","status":"queued","number":3}]}]}`) + default: + fmt.Fprint(w, `{"total_count":1,"jobs":[{"id":101,"name":"build","status":"completed","conclusion":"success","steps":[]}]}`) + } + }) + handle("GET /repos/o/r/check-runs/202/annotations", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"annotation_level":"failure","message":"No profile matching 'Builder store com.example.app' found"}]`) + }) + handle("GET /repos/o/r/actions/runs/{id}/artifacts", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("id") != "10" { + fmt.Fprint(w, `{"total_count":0,"artifacts":[]}`) + return + } + f.mu.Lock() + expired := f.expired + f.mu.Unlock() + fmt.Fprintf(w, `{"total_count":1,"artifacts":[{"id":555,"name":"ipa","size_in_bytes":%d,"expired":%t,"expires_at":"2026-10-08T10:00:00Z"}]}`, len(artifactZip), expired) + }) + handle("GET /repos/o/r/actions/artifacts/555/zip", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(artifactZip) + }) + handle("GET /repos/o/r/actions/jobs/{id}/logs", func(w http.ResponseWriter, r *http.Request) { + // GitHub redirects to short-lived storage. + http.Redirect(w, r, "/blob/log-"+r.PathValue("id"), http.StatusFound) + }) + handle("GET /blob/{name}", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "log of %s\n", r.PathValue("name")) + }) + handle("POST /repos/o/r/actions/runs/{id}/cancel", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.cancelled = append(f.cancelled, r.PathValue("id")) + f.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *githubFake) source() *GitHub { + cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "o", Repo: "r"}} + return &GitHub{Client: github.NewClientWithBaseURL("tok", f.srv.URL), Config: cfg} +} + +func ids(bs []Build) string { + var out []string + for i := range bs { + out = append(out, bs[i].ID+":"+bs[i].Kind+":"+bs[i].Status) + } + return strings.Join(out, " ") +} + +func TestGitHubList(t *testing.T) { + g := newGitHubFake(t).source() + ctx := context.Background() + all, err := g.List(ctx, ListOptions{}) + if err != nil { + t.Fatal(err) + } + want := "1234abcd:ipa:running cafef00d:simulator:cancelled deadbeef:ipa:failed abcd1234:ipa:succeeded" + if got := ids(all); got != want { + t.Fatalf("list:\n got %s\nwant %s", got, want) + } + if all[3].RunID != "10" || all[3].URL != "https://github.com/o/r/actions/runs/10" || all[3].Duration(time.Now()) != 30*time.Minute { + t.Fatalf("run fields: %+v", all[3]) + } + for filter, want := range map[string]string{ + StatusRunning: "1234abcd:ipa:running", + StatusFailed: "cafef00d:simulator:cancelled deadbeef:ipa:failed", + StatusSucceeded: "abcd1234:ipa:succeeded", + } { + got, err := g.List(ctx, ListOptions{Status: filter}) + if err != nil || ids(got) != want { + t.Errorf("--status %s: got %s (%v), want %s", filter, ids(got), err, want) + } + } + limited, err := g.List(ctx, ListOptions{Limit: 2}) + if err != nil || ids(limited) != "1234abcd:ipa:running cafef00d:simulator:cancelled" { + t.Fatalf("--limit 2: %s %v", ids(limited), err) + } +} + +func TestGitHubFind(t *testing.T) { + g := newGitHubFake(t).source() + ctx := context.Background() + for ref, want := range map[string]string{ + "deadbeef": "20", // build ID from a tag-pushed run + "cafef00d": "40", // simulator run + "10": "10", // run ID + "https://github.com/o/r/actions/runs/20/job/202": "20", + } { + b, err := g.Find(ctx, ref) + if err != nil || b.RunID != want { + t.Errorf("Find(%q) = run %q, %v; want run %s", ref, b.RunID, err, want) + } + } + if b, _ := g.Find(ctx, "40"); b.Kind != KindSimulator || b.ID != "cafef00d" { + t.Errorf("run 40 by ID: %+v", b) + } + if _, err := g.Find(ctx, "0badf00d"); err == nil || !strings.Contains(err.Error(), "no GitHub build matches") { + t.Errorf("unknown build ID: %v", err) + } + if _, err := g.Find(ctx, "https://github.com/other/repo/actions/runs/20"); err == nil || !strings.Contains(err.Error(), "other/repo") { + t.Errorf("foreign repo URL: %v", err) + } +} + +func TestGitHubShowFailedRun(t *testing.T) { + g := newGitHubFake(t).source() + ctx := context.Background() + b, err := g.Find(ctx, "deadbeef") + if err != nil { + t.Fatal(err) + } + d, err := g.Show(ctx, &b) + if err != nil { + t.Fatal(err) + } + if len(d.Jobs) != 2 || d.Jobs[1].Steps[1].Status != "failure" { + t.Fatalf("jobs: %+v", d.Jobs) + } + if d.Failure == nil || d.Failure.Step != "Build IPA" || d.Failure.Job != "build" || len(d.Failure.Messages) != 1 { + t.Fatalf("failure: %+v", d.Failure) + } + + b, _ = g.Find(ctx, "abcd1234") + d, err = g.Show(ctx, &b) + if err != nil || d.Failure != nil || len(d.Artifacts) != 1 || d.Artifacts[0].Name != "ipa" || d.Artifacts[0].ExpiresAt == nil { + t.Fatalf("succeeded run: %+v %v", d, err) + } +} + +func TestGitHubLogs(t *testing.T) { + f := newGitHubFake(t) + g := f.source() + ctx := context.Background() + + b, _ := g.Find(ctx, "deadbeef") + var out bytes.Buffer + done, err := g.Logs(&b, false).Read(ctx, &out) + if err != nil || !done || !strings.Contains(out.String(), "==> prepare") || !strings.Contains(out.String(), "log of log-202") { + t.Fatalf("all logs: done=%v %v\n%s", done, err, out.String()) + } + out.Reset() + if _, err := g.Logs(&b, true).Read(ctx, &out); err != nil || strings.Contains(out.String(), "prepare") || !strings.Contains(out.String(), "log of log-202") { + t.Fatalf("--failed must print only the failed job: %v\n%s", err, out.String()) + } + + // --follow on a running build: step lines first, the job log once it ends. + b, _ = g.Find(ctx, "1234abcd") + out.Reset() + reads := 0 + stream := g.Logs(&b, false) + wrapped := streamFunc(func(ctx context.Context, w *bytes.Buffer) (bool, error) { + reads++ + if reads == 2 { + f.mu.Lock() + f.runningDone = true + f.mu.Unlock() + } + return stream.Read(ctx, w) + }) + for { + done, err := wrapped(ctx, &out) + if err != nil { + t.Fatal(err) + } + if done { + break + } + } + got := out.String() + if strings.Count(got, "step 2 Build IPA") != 1 || strings.Contains(got, "Upload IPA") || !strings.HasSuffix(got, "log of log-301\n") { + t.Fatalf("follow output:\n%s", got) + } +} + +type streamFunc func(context.Context, *bytes.Buffer) (bool, error) + +func TestFollowStopsWhenDone(t *testing.T) { + f := newGitHubFake(t) + g := f.source() + b, _ := g.Find(context.Background(), "1234abcd") + go func() { + time.Sleep(30 * time.Millisecond) + f.mu.Lock() + f.runningDone = true + f.mu.Unlock() + }() + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := Follow(ctx, g.Logs(&b, false), &out, 10*time.Millisecond); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "log of log-301") { + t.Fatalf("follow output:\n%s", out.String()) + } +} + +func TestGitHubDownload(t *testing.T) { + f := newGitHubFake(t) + g := f.source() + ctx := context.Background() + dir := t.TempDir() + + b, _ := g.Find(ctx, "abcd1234") + path, size, err := g.Download(ctx, &b, dir) + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(dir, "App-abcd1234.ipa") || size != int64(len(ipaBytes(t))) { + t.Fatalf("saved %s (%d bytes)", path, size) + } + if data, _ := os.ReadFile(path); !bytes.Equal(data, ipaBytes(t)) { + t.Fatal("the saved IPA is not the artifact's") + } + + f.mu.Lock() + f.expired = true + f.mu.Unlock() + if _, _, err := g.Download(ctx, &b, dir); err == nil || !strings.Contains(err.Error(), "expired on 2026-10-08") { + t.Errorf("expired artifact: %v", err) + } + b, _ = g.Find(ctx, "1234abcd") + if _, _, err := g.Download(ctx, &b, dir); err == nil || !strings.Contains(err.Error(), "still running") { + t.Errorf("running build: %v", err) + } + b, _ = g.Find(ctx, "cafef00d") + if _, _, err := g.Download(ctx, &b, dir); err == nil || !strings.Contains(err.Error(), "simulator") { + t.Errorf("simulator build: %v", err) + } +} + +func TestGitHubCancel(t *testing.T) { + f := newGitHubFake(t) + g := f.source() + b, _ := g.Find(context.Background(), "1234abcd") + if err := g.Cancel(context.Background(), &b); err != nil { + t.Fatal(err) + } + if len(f.cancelled) != 1 || f.cancelled[0] != "30" { + t.Fatalf("cancelled: %v", f.cancelled) + } +} diff --git a/internal/builds/remote.go b/internal/builds/remote.go new file mode 100644 index 0000000..1f2c70b --- /dev/null +++ b/internal/builds/remote.go @@ -0,0 +1,311 @@ +package builds + +import ( + "context" + "fmt" + "io" + "net/url" + "path" + "regexp" + "strings" + + "github.com/MobAI-App/ios-builder/internal/build" + "github.com/MobAI-App/ios-builder/internal/ci" + "github.com/MobAI-App/ios-builder/internal/config" +) + +// RemoteProvider is what a Codemagic or Bitrise source needs from its +// provider: the run API Builder builds with, plus the history reads. +type RemoteProvider interface { + ci.Provider + ci.History +} + +// Remote reads Builder builds on Codemagic or Bitrise: builds of the +// configured build and share workflows, identified by the BUILD_ID variable +// Builder sent with them. +type Remote struct { + P RemoteProvider + CI config.CIConfig + Project string +} + +func (r *Remote) Provider() string { return r.P.Name() } + +func (r *Remote) workflows() []struct{ name, kind string } { + return []struct{ name, kind string }{{r.CI.BuildWorkflow, KindIPA}, {r.CI.ShareWorkflow, KindSimulator}} +} + +func (r *Remote) kindOf(workflow string) string { + if workflow != "" && workflow == r.CI.ShareWorkflow { + return KindSimulator + } + return KindIPA +} + +// ipaNameRe is the artifact name the runners give an IPA: .ipa. +var ipaNameRe = regexp.MustCompile(`^([0-9a-f]{8})\.ipa(?:\.zip)?$`) + +func (r *Remote) build(rec *ci.Record) Build { + b := Build{Kind: r.kindOf(rec.Workflow), Provider: r.P.Name(), RunID: rec.ID, URL: rec.URL, State: rec.State, + Profile: rec.Variables["BUILDER_PROFILE"], CreatedAt: rec.CreatedAt, + StartedAt: timePtr(rec.StartedAt), FinishedAt: timePtr(rec.FinishedAt)} + if id := rec.Variables["BUILD_ID"]; IsBuildID(id) { + b.ID = id + } else { + // Without the variables in the response, the IPA's name still carries it. + for _, a := range rec.Artifacts { + if m := ipaNameRe.FindStringSubmatch(path.Base(a.Name)); m != nil { + b.ID = m[1] + break + } + } + } + switch { + case rec.Success: + b.Status = StatusSucceeded + case rec.Cancelled: + b.Status = StatusCancelled + case rec.Done: + b.Status = StatusFailed + case rec.StartedAt.IsZero() && (rec.State == "queued" || rec.State == "initializing" || rec.State == "on-hold"): + b.Status = StatusQueued + default: + b.Status = StatusRunning + } + if b.CreatedAt.IsZero() && b.StartedAt != nil { + b.CreatedAt = *b.StartedAt + } + return b +} + +func (r *Remote) records(ctx context.Context, limit int) ([]ci.Record, error) { + var all []ci.Record + for _, wf := range r.workflows() { + if wf.name == "" { + continue + } + recs, err := r.P.Builds(ctx, wf.name, limit) + if err != nil { + return nil, fmt.Errorf("%s builds of %s: %w", r.P.Name(), wf.name, err) + } + all = append(all, recs...) + } + return all, nil +} + +func (r *Remote) List(ctx context.Context, opts ListOptions) ([]Build, error) { + limit := limitOf(opts) + fetch := limit + if opts.Status != "" { + // Filtering happens here, so read further back to fill the page. + fetch = max(limit*3, 50) + } + recs, err := r.records(ctx, fetch) + if err != nil { + return nil, err + } + var out []Build + for i := range recs { + if b := r.build(&recs[i]); matches(b.Status, opts.Status) { + out = append(out, b) + } + } + sortBuilds(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// runIDFromURL takes the run ID off a Codemagic (…/build/) or Bitrise +// (…/build/) URL. +func runIDFromURL(ref string) (string, bool) { + u, err := url.Parse(ref) + if err != nil || u.Host == "" { + return "", false + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := len(parts) - 2; i >= 0; i-- { + if parts[i] == "build" || parts[i] == "builds" { + return parts[i+1], parts[i+1] != "" + } + } + return "", false +} + +func (r *Remote) Find(ctx context.Context, ref string) (Build, error) { + ref = strings.TrimSpace(ref) + if strings.Contains(ref, "://") { + id, ok := runIDFromURL(ref) + if !ok { + return Build{}, fmt.Errorf("not a %s build URL: %s", r.P.Name(), ref) + } + ref = id + } else if IsBuildID(ref) { + recs, err := r.records(ctx, 50) + if err != nil { + return Build{}, err + } + var found []Build + for i := range recs { + if b := r.build(&recs[i]); b.ID == ref { + found = append(found, b) + } + } + if len(found) > 0 { + sortBuilds(found) + return found[0], nil + } + return Build{}, notFound(r.P.Name(), ref) + } + if ref == "" { + return Build{}, notFound(r.P.Name(), ref) + } + rec, err := r.P.Build(ctx, ref) + if err != nil { + return Build{}, err + } + return r.build(&rec), nil +} + +func (r *Remote) Show(ctx context.Context, b *Build) (Detail, error) { + d := Detail{Build: *b} + rec, err := r.P.Build(ctx, b.RunID) + if err != nil { + return d, err + } + d.Build = r.build(&rec) + if d.ID == "" { + d.ID = b.ID + } + if len(rec.Steps) > 0 { + job := Job{Name: rec.Workflow, Status: rec.State} + for i, s := range rec.Steps { + job.Steps = append(job.Steps, Step{Number: i + 1, Name: s.Name, Status: s.State}) + if s.Failed && d.Failure == nil { + d.Failure = &Failure{Job: rec.Workflow, Step: s.Name} + } + } + d.Jobs = append(d.Jobs, job) + } + if d.Status == StatusFailed && d.Failure == nil { + d.Failure = &Failure{Job: rec.Workflow} + } + if d.Failure != nil && rec.Reason != "" { + d.Failure.Messages = append(d.Failure.Messages, rec.Reason) + } + artifacts := rec.Artifacts + if lister, ok := r.P.(ci.ArtifactLister); ok && rec.Done { + if artifacts, err = lister.Artifacts(ctx, ci.Run{ID: rec.ID, URL: rec.URL}); err != nil { + return d, err + } + } + for _, a := range artifacts { + d.Artifacts = append(d.Artifacts, Artifact{Name: a.Name, Size: a.Size}) + } + return d, nil +} + +func (r *Remote) Download(ctx context.Context, b *Build, dir string) (string, int64, error) { + if b.Kind == KindSimulator { + return "", 0, fmt.Errorf("build %s is a simulator session (ios share); it has no IPA", b.label()) + } + name := b.ID + if name == "" { + name = b.fileName() + } + return build.DownloadRemote(ctx, r.P, b.RunID, name, dir, r.Project) +} + +func (r *Remote) Cancel(ctx context.Context, b *Build) error { + return build.CancelRemote(ctx, r.P, b.RunID) +} + +func (r *Remote) Logs(b *Build, failedOnly bool) LogStream { + switch p := r.P.(type) { + case *ci.Codemagic: + return &codemagicLogs{p: p, b: *b, failedOnly: failedOnly, printed: map[int]bool{}} + case *ci.Bitrise: + return &bitriseLogs{p: p, b: *b, failedOnly: failedOnly, last: -1} + } + return unsupportedLogs{r.P.Name()} +} + +type unsupportedLogs struct{ name string } + +func (u unsupportedLogs) Read(context.Context, io.Writer) (bool, error) { + return false, fmt.Errorf("%s does not expose build logs to Builder", u.name) +} + +// codemagicLogs prints each step's log once the step has ended; Codemagic +// serves logs per step. +type codemagicLogs struct { + p *ci.Codemagic + b Build + failedOnly bool + printed map[int]bool +} + +func (l *codemagicLogs) Read(ctx context.Context, w io.Writer) (bool, error) { + rec, err := l.p.Build(ctx, l.b.RunID) + if err != nil { + return false, err + } + for i := range rec.Steps { + s := &rec.Steps[i] + if l.printed[i] || (!s.Done && !rec.Done) { + continue + } + l.printed[i] = true + if (l.failedOnly && !s.Failed) || s.LogURL == "" { + continue + } + fmt.Fprintf(w, "==> %s (%s)\n", s.Name, s.State) + if err := l.p.StepLog(ctx, s, w); err != nil { + return false, fmt.Errorf("step %s: %w", s.Name, err) + } + fmt.Fprintln(w) + } + return rec.Done, nil +} + +// bitriseLogs prints new log chunks as Bitrise appends them. Once the log is +// archived and nothing was printed, the whole text comes from its raw URL. +// Bitrise has no per-step logs, so --failed prints the whole log of a failed +// build. +type bitriseLogs struct { + p *ci.Bitrise + b Build + failedOnly bool + last int +} + +func (l *bitriseLogs) Read(ctx context.Context, w io.Writer) (bool, error) { + rec, err := l.p.Build(ctx, l.b.RunID) + if err != nil { + return false, err + } + if l.failedOnly && (!rec.Done || rec.Success) { + return rec.Done, nil + } + log, err := l.p.Log(ctx, l.b.RunID) + if err != nil { + return false, err + } + for _, c := range log.Chunks { + if c.Position <= l.last { + continue + } + l.last = c.Position + _, _ = io.WriteString(w, c.Text) + } + if rec.Done && log.Archived && l.last < 0 && log.RawURL != "" { + l.last = 0 + if err := l.p.RawLog(ctx, log.RawURL, w); err != nil { + return false, err + } + } + // A finished build keeps writing chunks until its log is archived. + return rec.Done && (log.Archived || len(log.Chunks) > 0), nil +} diff --git a/internal/builds/remote_test.go b/internal/builds/remote_test.go new file mode 100644 index 0000000..9e4426e --- /dev/null +++ b/internal/builds/remote_test.go @@ -0,0 +1,350 @@ +package builds + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/MobAI-App/ios-builder/internal/ci" + "github.com/MobAI-App/ios-builder/internal/config" +) + +var ciConfig = config.CIConfig{AppID: "app", Branch: "main", BuildWorkflow: "ios-build", ShareWorkflow: "ios-share"} + +// codemagicFake serves the builds API, the v3 status API, step logs and +// artifact files from one TLS server, since artifact URLs must be HTTPS. +type codemagicFake struct { + srv *httptest.Server + mu sync.Mutex + cancelled bool +} + +func newCodemagicFake(t *testing.T) *codemagicFake { + f := &codemagicFake{} + ipa := ipaBytes(t) + mux := http.NewServeMux() + var base string + build := func(id, workflow, status, buildID, created string, steps string) string { + return fmt.Sprintf(`{"_id":%q,"workflowId":%q,"status":%q,"createdAt":%q,"startedAt":%q, + "environment":{"variables":{"BUILD_ID":%q,"BUILDER_PROFILE":"store"}},"buildActions":[%s]}`, + id, workflow, status, created, created, buildID, steps) + } + failedSteps := fmt.Sprintf(`{"name":"Set up","status":"success","logUrl":"%[1]s/builds/cm2/step/1"},{"name":"Build","status":"failed","logUrl":"%[1]s/builds/cm2/step/2"}`, "BASE") + builds := func() map[string]string { + f.mu.Lock() + defer f.mu.Unlock() + running := "building" + if f.cancelled { + running = "canceled" + } + return map[string]string{ + "cm1": build("cm1", "ios-build", "finished", "abcd1234", "2026-10-01T10:00:00Z", ""), + "cm2": build("cm2", "ios-build", "failed", "deadbeef", "2026-10-01T10:10:00Z", strings.ReplaceAll(failedSteps, "BASE", base)), + "cm3": build("cm3", "ios-build", running, "1234abcd", "2026-10-01T10:20:00Z", ""), + "cm4": build("cm4", "ios-share", "finished", "cafef00d", "2026-10-01T10:15:00Z", ""), + } + } + auth := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-auth-token") != "cm-token" { + t.Errorf("%s without the token", r.URL.Path) + } + h(w, r) + } + } + mux.HandleFunc("GET /builds", auth(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("appId") != "app" { + t.Errorf("list without the app: %s", r.URL) + } + wf := r.URL.Query().Get("workflowId") + var items []string + for _, id := range []string{"cm1", "cm2", "cm3", "cm4"} { + if b := builds()[id]; strings.Contains(b, `"workflowId":"`+wf+`"`) { + items = append(items, b) + } + } + fmt.Fprintf(w, `{"builds":[%s]}`, strings.Join(items, ",")) + })) + mux.HandleFunc("GET /builds/{id}", auth(func(w http.ResponseWriter, r *http.Request) { + b, ok := builds()[r.PathValue("id")] + if !ok { + w.WriteHeader(404) + fmt.Fprint(w, `{"message":"build not found"}`) + return + } + fmt.Fprintf(w, `{"build":%s}`, b) + })) + mux.HandleFunc("GET /builds/cm2/step/{n}", auth(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "step %s output", r.PathValue("n")) + })) + mux.HandleFunc("GET /api/v3/builds/{id}", auth(func(w http.ResponseWriter, r *http.Request) { + status := "finished" + if r.PathValue("id") == "cm3" { + f.mu.Lock() + status = map[bool]string{true: "canceled", false: "building"}[f.cancelled] + f.mu.Unlock() + } + fmt.Fprintf(w, `{"data":{"status":%q,"artifacts":[{"name":"abcd1234.ipa","size_in_bytes":%d,"short_lived_download_url":"%s/files/abcd1234.ipa"}]}}`, status, len(ipa), base) + })) + mux.HandleFunc("GET /files/abcd1234.ipa", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-auth-token") != "" { + t.Error("the token went to the artifact URL") + } + _, _ = w.Write(ipa) + }) + mux.HandleFunc("POST /builds/cm3/cancel", auth(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.cancelled = true + f.mu.Unlock() + w.WriteHeader(208) + })) + f.srv = httptest.NewTLSServer(mux) + base = f.srv.URL + t.Cleanup(f.srv.Close) + return f +} + +func (f *codemagicFake) source() *Remote { + p := ci.NewCodemagicWith(ciConfig, "cm-token", ci.Options{APIURL: f.srv.URL, StatusURL: f.srv.URL + "/api/v3", Transport: f.srv.Client().Transport}) + return &Remote{P: p, CI: ciConfig, Project: "App"} +} + +func TestCodemagicHistory(t *testing.T) { + f := newCodemagicFake(t) + r := f.source() + ctx := context.Background() + + all, err := r.List(ctx, ListOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := ids(all), "1234abcd:ipa:running cafef00d:simulator:succeeded deadbeef:ipa:failed abcd1234:ipa:succeeded"; got != want { + t.Fatalf("list:\n got %s\nwant %s", got, want) + } + if all[0].Profile != "store" || all[0].RunID != "cm3" || all[0].URL != "https://codemagic.io/app/app/build/cm3" { + t.Fatalf("fields: %+v", all[0]) + } + if got, _ := r.List(ctx, ListOptions{Status: StatusFailed}); ids(got) != "deadbeef:ipa:failed" { + t.Fatalf("--status failed: %s", ids(got)) + } + + for ref, want := range map[string]string{"deadbeef": "cm2", "cm1": "cm1", "https://codemagic.io/app/app/build/cm4": "cm4"} { + if b, err := r.Find(ctx, ref); err != nil || b.RunID != want { + t.Errorf("Find(%q) = %q %v, want %s", ref, b.RunID, err, want) + } + } + if _, err := r.Find(ctx, "0badf00d"); err == nil { + t.Error("unknown build ID found") + } + + b, _ := r.Find(ctx, "deadbeef") + d, err := r.Show(ctx, &b) + if err != nil || d.Failure == nil || d.Failure.Step != "Build" || len(d.Jobs) != 1 || len(d.Jobs[0].Steps) != 2 { + t.Fatalf("show: %+v %v", d, err) + } + var out bytes.Buffer + if done, err := r.Logs(&b, true).Read(ctx, &out); err != nil || !done || out.String() != "==> Build (failed)\nstep 2 output\n" { + t.Fatalf("--failed logs: %v %v %q", done, err, out.String()) + } + out.Reset() + if _, err := r.Logs(&b, false).Read(ctx, &out); err != nil || !strings.Contains(out.String(), "step 1 output") { + t.Fatalf("logs: %v %q", err, out.String()) + } + + dir := t.TempDir() + b, _ = r.Find(ctx, "abcd1234") + path, _, err := r.Download(ctx, &b, dir) + if err != nil || path != filepath.Join(dir, "App-abcd1234.ipa") { + t.Fatalf("download: %s %v", path, err) + } + b, _ = r.Find(ctx, "cafef00d") + if _, _, err := r.Download(ctx, &b, dir); err == nil || !strings.Contains(err.Error(), "simulator") { + t.Fatalf("simulator download: %v", err) + } + + b, _ = r.Find(ctx, "1234abcd") + if err := r.Cancel(ctx, &b); err != nil || !f.cancelled { + t.Fatalf("cancel: %v %v", err, f.cancelled) + } +} + +// bitriseFake serves the Bitrise builds, artifacts and log APIs. +type bitriseFake struct { + srv *httptest.Server + mu sync.Mutex + aborted bool + reads int +} + +func newBitriseFake(t *testing.T) *bitriseFake { + f := &bitriseFake{} + ipa := ipaBytes(t) + var base string + build := func(slug, workflow string, status int, text, buildID, at string) string { + finished := `null` + if status != 0 { + finished = fmt.Sprintf("%q", strings.Replace(at, ":00:00", ":05:00", 1)) + } + return fmt.Sprintf(`{"slug":%q,"status":%d,"status_text":%q,"triggered_workflow":%q,"triggered_at":%q,"started_on_worker_at":%q,"finished_at":%s, + "original_build_params":{"environments":[{"mapped_to":"BUILD_ID","value":%q},{"mapped_to":"BUILDER_PROFILE","value":"preview"}]}}`, + slug, status, text, workflow, at, at, finished, buildID) + } + builds := func() map[string]string { + f.mu.Lock() + defer f.mu.Unlock() + running := build("br3", "ios-build", 0, "in-progress", "1234abcd", "2026-10-01T12:00:00Z") + if f.aborted { + running = build("br3", "ios-build", 3, "aborted", "1234abcd", "2026-10-01T12:00:00Z") + } + return map[string]string{ + "br1": build("br1", "ios-build", 1, "success", "abcd1234", "2026-10-01T10:00:00Z"), + "br2": build("br2", "ios-build", 2, "error", "deadbeef", "2026-10-01T11:00:00Z"), + "br4": build("br4", "ios-share", 1, "success", "cafef00d", "2026-10-01T11:30:00Z"), + "br3": running, + } + } + mux := http.NewServeMux() + auth := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "br-token" { + t.Errorf("%s without the token", r.URL.Path) + } + h(w, r) + } + } + mux.HandleFunc("GET /apps/app/builds", auth(func(w http.ResponseWriter, r *http.Request) { + wf := r.URL.Query().Get("workflow") + var items []string + for _, id := range []string{"br3", "br4", "br2", "br1"} { + if b := builds()[id]; strings.Contains(b, `"triggered_workflow":"`+wf+`"`) { + items = append(items, b) + } + } + fmt.Fprintf(w, `{"data":[%s],"paging":{}}`, strings.Join(items, ",")) + })) + mux.HandleFunc("GET /apps/app/builds/{slug}", auth(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"data":%s}`, builds()[r.PathValue("slug")]) + })) + mux.HandleFunc("GET /apps/app/builds/{slug}/artifacts", auth(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"data":[{"slug":"a1","title":"abcd1234.ipa.zip","file_size_bytes":%d}],"paging":{}}`, len(ipa)) + })) + mux.HandleFunc("GET /apps/app/builds/{slug}/artifacts/a1", auth(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"data":{"expiring_download_url":"%s/files/a1"}}`, base) + })) + mux.HandleFunc("GET /files/a1", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(ipa) }) + mux.HandleFunc("GET /apps/app/builds/{slug}/log", auth(func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("slug") == "br2" { + fmt.Fprintf(w, `{"is_archived":true,"expiring_raw_log_url":"%s/files/raw-log","log_chunks":[]}`, base) + return + } + f.mu.Lock() + f.reads++ + n := f.reads + f.mu.Unlock() + chunks := `{"chunk":"first\n","position":0}` + if n > 1 { + chunks += `,{"chunk":"second\n","position":1}` + } + fmt.Fprintf(w, `{"is_archived":false,"log_chunks":[%s]}`, chunks) + })) + mux.HandleFunc("GET /files/raw-log", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "whole archived log\n") + }) + mux.HandleFunc("POST /apps/app/builds/br3/abort", auth(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.aborted = true + f.mu.Unlock() + fmt.Fprint(w, `{"status":"ok"}`) + })) + f.srv = httptest.NewTLSServer(mux) + base = f.srv.URL + t.Cleanup(f.srv.Close) + return f +} + +func (f *bitriseFake) source() *Remote { + p := ci.NewBitriseWith(ciConfig, "br-token", ci.Options{APIURL: f.srv.URL, Transport: f.srv.Client().Transport}) + return &Remote{P: p, CI: ciConfig, Project: "App"} +} + +func TestBitriseHistory(t *testing.T) { + f := newBitriseFake(t) + r := f.source() + ctx := context.Background() + + all, err := r.List(ctx, ListOptions{}) + if err != nil { + t.Fatal(err) + } + if got, want := ids(all), "1234abcd:ipa:running cafef00d:simulator:succeeded deadbeef:ipa:failed abcd1234:ipa:succeeded"; got != want { + t.Fatalf("list:\n got %s\nwant %s", got, want) + } + if all[3].Profile != "preview" || all[3].URL != "https://app.bitrise.io/build/br1" || all[3].Duration(all[3].CreatedAt).Minutes() != 5 { + t.Fatalf("fields: %+v", all[3]) + } + if got, _ := r.List(ctx, ListOptions{Status: StatusRunning}); ids(got) != "1234abcd:ipa:running" { + t.Fatalf("--status running: %s", ids(got)) + } + for ref, want := range map[string]string{"abcd1234": "br1", "br2": "br2", "https://app.bitrise.io/build/br3": "br3"} { + if b, err := r.Find(ctx, ref); err != nil || b.RunID != want { + t.Errorf("Find(%q) = %q %v, want %s", ref, b.RunID, err, want) + } + } + + b, _ := r.Find(ctx, "deadbeef") + d, err := r.Show(ctx, &b) + if err != nil || d.Failure == nil || len(d.Artifacts) != 1 || d.Artifacts[0].Name != "abcd1234.ipa" { + t.Fatalf("show: %+v %v", d, err) + } + var out bytes.Buffer + if done, err := r.Logs(&b, true).Read(ctx, &out); err != nil || !done || out.String() != "whole archived log\n" { + t.Fatalf("archived log: %v %v %q", done, err, out.String()) + } + + // A running build: chunks are printed once each, across reads. + b, _ = r.Find(ctx, "1234abcd") + out.Reset() + stream := r.Logs(&b, false) + for i := 0; i < 2; i++ { + if done, err := stream.Read(ctx, &out); err != nil || done { + t.Fatalf("read %d: %v %v", i, done, err) + } + } + if out.String() != "first\nsecond\n" { + t.Fatalf("chunks: %q", out.String()) + } + + dir := t.TempDir() + b, _ = r.Find(ctx, "abcd1234") + if path, _, err := r.Download(ctx, &b, dir); err != nil || path != filepath.Join(dir, "App-abcd1234.ipa") { + t.Fatalf("download: %s %v", path, err) + } + b, _ = r.Find(ctx, "1234abcd") + if _, _, err := r.Download(ctx, &b, dir); err == nil || !strings.Contains(err.Error(), "still running") { + t.Fatalf("running download: %v", err) + } + if err := r.Cancel(ctx, &b); err != nil || !f.aborted { + t.Fatalf("cancel: %v %v", err, f.aborted) + } +} + +func TestRunIDFromURL(t *testing.T) { + for in, want := range map[string]string{ + "https://codemagic.io/app/app/build/65f0": "65f0", + "https://app.bitrise.io/build/1f2e-3d4c": "1f2e-3d4c", + "https://app.bitrise.io/build/1f2e-3d4c?tab=x": "1f2e-3d4c", + } { + if got, ok := runIDFromURL(in); !ok || got != want { + t.Errorf("runIDFromURL(%q) = %q %v", in, got, ok) + } + } + if ProviderFromURL("https://github.com/o/r/actions/runs/1") != "github" || ProviderFromURL("abcd1234") != "" { + t.Error("ProviderFromURL") + } +} diff --git a/internal/ci/history.go b/internal/ci/history.go new file mode 100644 index 0000000..3090d60 --- /dev/null +++ b/internal/ci/history.go @@ -0,0 +1,366 @@ +package ci + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/MobAI-App/ios-builder/internal/config" +) + +// Record is one provider build as the history commands see it: its state, +// timestamps, and the variables Builder sent with it, which carry the build ID. +type Record struct { + ID, URL, Workflow string + State string // the provider's own word + Done, Success bool + Cancelled bool + Reason string // why it stopped, when the provider says + Variables map[string]string + CreatedAt time.Time + StartedAt time.Time + FinishedAt time.Time + Artifacts []Artifact + Steps []Step +} + +// Step is one build step. Only Codemagic reports steps through its API. +type Step struct { + Name, State string + Done, Failed bool + StartedAt, EndedAt time.Time + LogURL string +} + +// History reads past and running builds of a provider. +type History interface { + Builds(ctx context.Context, workflow string, limit int) ([]Record, error) + Build(ctx context.Context, id string) (Record, error) +} + +// Options points a provider at other hosts and a transport (tests, proxies). +// Empty fields keep the production values. +type Options struct { + APIURL string // Codemagic dispatch/legacy API, Bitrise API + StatusURL string // Codemagic v3 API + Transport http.RoundTripper +} + +// NewCodemagicWith is NewCodemagic with Options applied. +func NewCodemagicWith(cfg config.CIConfig, token string, o Options) *Codemagic { + c := NewCodemagic(cfg, token) + if o.APIURL != "" { + c.dispatchURL = strings.TrimSuffix(o.APIURL, "/") + } + if o.StatusURL != "" { + c.statusURL = strings.TrimSuffix(o.StatusURL, "/") + } + if o.Transport != nil { + c.api.http.Transport = o.Transport + } + return c +} + +// NewBitriseWith is NewBitrise with Options applied. +func NewBitriseWith(cfg config.CIConfig, token string, o Options) *Bitrise { + b := NewBitrise(cfg, token) + if o.APIURL != "" { + b.baseURL = strings.TrimSuffix(o.APIURL, "/") + } + if o.Transport != nil { + b.api.http.Transport = o.Transport + } + return b +} + +func sortNewestFirst(records []Record) { + sort.SliceStable(records, func(i, j int) bool { return records[i].CreatedAt.After(records[j].CreatedAt) }) +} + +// codemagicBuild is a build in Codemagic's builds API. +type codemagicBuild struct { + ID string `json:"_id"` + WorkflowID string `json:"workflowId"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Message string `json:"message"` + Environment struct { + Variables map[string]any `json:"variables"` + } `json:"environment"` + Artefacts []struct { + Name string `json:"name"` + Size int64 `json:"size"` + } `json:"artefacts"` + BuildActions []struct { + Name string `json:"name"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + LogURL string `json:"logUrl"` + } `json:"buildActions"` +} + +func (c *Codemagic) record(b *codemagicBuild) Record { + r := Record{ID: b.ID, Workflow: b.WorkflowID, State: b.Status, CreatedAt: b.CreatedAt, StartedAt: b.StartedAt, + FinishedAt: b.FinishedAt, Reason: b.Message, Variables: map[string]string{}, + URL: "https://codemagic.io/app/" + url.PathEscape(c.config.AppID) + "/build/" + url.PathEscape(b.ID)} + r.Done, r.Success = codemagicDone(b.Status) + r.Cancelled = b.Status == "canceled" + for k, v := range b.Environment.Variables { + if s, ok := v.(string); ok { + r.Variables[k] = s + } + } + for _, a := range b.Artefacts { + r.Artifacts = append(r.Artifacts, Artifact{Name: a.Name, Size: a.Size}) + } + for _, a := range b.BuildActions { + done, _ := codemagicDone(a.Status) + if a.Status == "success" { + done = true + } + r.Steps = append(r.Steps, Step{Name: a.Name, State: a.Status, Done: done, Failed: a.Status == "failed", + StartedAt: a.StartedAt, EndedAt: a.FinishedAt, LogURL: a.LogURL}) + } + return r +} + +// codemagicDone maps a Codemagic build (or step) status onto done/success. +func codemagicDone(status string) (done, success bool) { + switch status { + case "finished": + return true, true + case "failed", "canceled", "timeout", "skipped": + return true, false + } + return false, false +} + +// Builds lists the app's builds of one workflow, newest first. +func (c *Codemagic) Builds(ctx context.Context, workflow string, limit int) ([]Record, error) { + q := url.Values{} + q.Set("appId", c.config.AppID) + q.Set("workflowId", workflow) + var result struct { + Builds []codemagicBuild `json:"builds"` + } + if err := c.api.request(ctx, "GET", c.dispatchURL+"/builds?"+q.Encode(), nil, &result); err != nil { + return nil, err + } + var records []Record + for i := range result.Builds { + b := &result.Builds[i] + // The workflowId filter is the API's; checking it keeps other + // workflows out should the filter be ignored. + if b.WorkflowID != "" && b.WorkflowID != workflow { + continue + } + records = append(records, c.record(b)) + } + sortNewestFirst(records) + if limit > 0 && len(records) > limit { + records = records[:limit] + } + return records, nil +} + +// Build reads one build by its Codemagic ID. +func (c *Codemagic) Build(ctx context.Context, id string) (Record, error) { + var result struct { + Build codemagicBuild `json:"build"` + } + if err := c.api.request(ctx, "GET", c.dispatchURL+"/builds/"+url.PathEscape(id), nil, &result); err != nil { + return Record{}, err + } + if result.Build.ID == "" { + return Record{}, fmt.Errorf("Codemagic build %s not found", id) + } + return c.record(&result.Build), nil +} + +// StepLog reads one step's log. The token goes only to the API host: a log +// URL elsewhere is fetched without it, as a pre-signed link. +func (c *Codemagic) StepLog(ctx context.Context, s *Step, w io.Writer) error { + if s.LogURL == "" { + return fmt.Errorf("Codemagic reported no log for step %q", s.Name) + } + u, err := url.Parse(s.LogURL) + api, _ := url.Parse(c.dispatchURL) + if err != nil || api == nil || u.Host != api.Host || u.Scheme != api.Scheme { + _, err := downloadURL(ctx, s.LogURL, w, c.api.http.Transport) + return err + } + return c.api.text(ctx, s.LogURL, w) +} + +// text streams a GET response body that is not JSON. +func (a apiClient) text(ctx context.Context, endpoint string, w io.Writer) error { + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("invalid API request") + } + req.Header.Set(a.header, a.token) + client := *a.http + client.Timeout = 0 // a long log may take longer than one API call + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return &APIError{} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, message: providerMessage(resp)} + } + _, err = io.Copy(w, resp.Body) + return err +} + +// bitriseBuild is a build in Bitrise's builds API. +type bitriseBuild struct { + Slug string `json:"slug"` + Status *int `json:"status"` + StatusText string `json:"status_text"` + Workflow string `json:"triggered_workflow"` + TriggeredAt time.Time `json:"triggered_at"` + StartedAt *time.Time `json:"started_on_worker_at"` + FinishedAt *time.Time `json:"finished_at"` + AbortReason string `json:"abort_reason"` + Params struct { + Environments []struct { + Key string `json:"mapped_to"` + Value string `json:"value"` + } `json:"environments"` + } `json:"original_build_params"` +} + +func bitriseRecord(b *bitriseBuild) (Record, error) { + if b.Status == nil || *b.Status < 0 || *b.Status > 4 { + return Record{}, &APIError{message: "Bitrise returned an unknown build status"} + } + code := *b.Status + r := Record{ID: b.Slug, Workflow: b.Workflow, State: b.StatusText, Done: code != 0, Success: code == 1, + Cancelled: code == 3 || code == 4, Reason: b.AbortReason, CreatedAt: b.TriggeredAt, Variables: map[string]string{}, + URL: "https://app.bitrise.io/build/" + url.PathEscape(b.Slug)} + if r.State == "" { + r.State = "status " + strconv.Itoa(code) + } + if b.StartedAt != nil { + r.StartedAt = *b.StartedAt + } + if b.FinishedAt != nil { + r.FinishedAt = *b.FinishedAt + } + for _, e := range b.Params.Environments { + r.Variables[e.Key] = e.Value + } + return r, nil +} + +// Builds lists the app's builds of one workflow, newest first. +func (b *Bitrise) Builds(ctx context.Context, workflow string, limit int) ([]Record, error) { + if limit <= 0 { + limit = 50 + } + var records []Record + next := "" + seen := map[string]bool{} + for len(records) < limit { + q := url.Values{} + q.Set("workflow", workflow) + q.Set("limit", strconv.Itoa(min(limit-len(records), 50))) + if next != "" { + q.Set("next", next) + } + var page struct { + Data []bitriseBuild `json:"data"` + Paging struct { + Next string `json:"next"` + } `json:"paging"` + } + if err := b.api.request(ctx, "GET", b.buildsURL()+"?"+q.Encode(), nil, &page); err != nil { + return nil, err + } + for i := range page.Data { + item := &page.Data[i] + if item.Workflow != "" && item.Workflow != workflow { + continue + } + r, err := bitriseRecord(item) + if err != nil { + return nil, err + } + records = append(records, r) + } + next = page.Paging.Next + if next == "" || len(page.Data) == 0 { + break + } + if seen[next] { + return nil, fmt.Errorf("Bitrise build pagination repeated a cursor") + } + seen[next] = true + } + sortNewestFirst(records) + if len(records) > limit { + records = records[:limit] + } + return records, nil +} + +// Build reads one build by its Bitrise slug. +func (b *Bitrise) Build(ctx context.Context, id string) (Record, error) { + var result struct { + Data bitriseBuild `json:"data"` + } + if err := b.api.request(ctx, "GET", b.runURL(Run{ID: id}), nil, &result); err != nil { + return Record{}, err + } + if result.Data.Slug == "" { + result.Data.Slug = id + } + return bitriseRecord(&result.Data) +} + +// BitriseLog is one read of a build's log: the chunks so far, and once the +// log is archived, the URL of the whole text. +type BitriseLog struct { + Chunks []LogChunk + Archived bool + RawURL string +} + +// LogChunk is a piece of a Bitrise log; Position orders them. +type LogChunk struct { + Text string `json:"chunk"` + Position int `json:"position"` +} + +// Log reads the build log as it stands. +func (b *Bitrise) Log(ctx context.Context, id string) (BitriseLog, error) { + var result struct { + Chunks []LogChunk `json:"log_chunks"` + Archived bool `json:"is_archived"` + RawURL string `json:"expiring_raw_log_url"` + } + if err := b.api.request(ctx, "GET", b.runURL(Run{ID: id})+"/log", nil, &result); err != nil { + return BitriseLog{}, err + } + sort.SliceStable(result.Chunks, func(i, j int) bool { return result.Chunks[i].Position < result.Chunks[j].Position }) + return BitriseLog{Chunks: result.Chunks, Archived: result.Archived, RawURL: result.RawURL}, nil +} + +// RawLog copies an archived log from its expiring URL, without credentials. +func (b *Bitrise) RawLog(ctx context.Context, rawURL string, w io.Writer) error { + _, err := downloadURL(ctx, rawURL, w, b.api.http.Transport) + return err +} diff --git a/internal/github/runs.go b/internal/github/runs.go new file mode 100644 index 0000000..099630a --- /dev/null +++ b/internal/github/runs.go @@ -0,0 +1,62 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" +) + +// ListWorkflowRunsPage lists one page (1-based, newest first) of a workflow +// file's runs, perPage at most 100. +func (c *Client) ListWorkflowRunsPage(ctx context.Context, owner, repo, workflowFile string, perPage, page int) ([]WorkflowRun, error) { + q := url.Values{} + q.Set("per_page", strconv.Itoa(perPage)) + q.Set("page", strconv.Itoa(page)) + path := fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/runs?%s", owner, repo, workflowFile, q.Encode()) + + var resp WorkflowRunsResponse + if err := c.do(ctx, path, &resp); err != nil { + return nil, fmt.Errorf("failed to list workflow runs: %w", err) + } + return resp.WorkflowRuns, nil +} + +// JobLogs reads a job's plain-text log. GitHub answers with a redirect to a +// short-lived storage URL, which is fetched without the token. The log exists +// once the job has completed; before that GitHub answers 404. +func (c *Client) JobLogs(ctx context.Context, owner, repo string, jobID int64) ([]byte, error) { + path := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", owner, repo, jobID) + + resp, err := c.request(ctx, "GET", path, nil) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusFound { + resp.Body.Close() + location := resp.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("job log redirect missing Location header") + } + req, err := http.NewRequestWithContext(ctx, "GET", location, nil) + if err != nil { + return nil, err + } + resp, err = c.httpClient.Do(req) + if err != nil { + return nil, err + } + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to read job log: status %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read job log: %w", err) + } + return data, nil +} diff --git a/internal/github/types.go b/internal/github/types.go index b112334..577b55a 100644 --- a/internal/github/types.go +++ b/internal/github/types.go @@ -28,6 +28,9 @@ type WorkflowRun struct { HTMLURL string `json:"html_url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // DisplayTitle is the run-name ("iOS Build "). + DisplayTitle string `json:"display_title"` + RunStartedAt time.Time `json:"run_started_at"` } // WorkflowRunsResponse is the response from listing workflow runs