From 951a5641e5c6d8cd33346c446884428f9751867f Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Fri, 12 Jun 2026 03:58:18 +0530 Subject: [PATCH] Fix install path, make release safe, and implement real launch-check Bug fixes: - install.sh: strip the leading "v" from the archive name to match GoReleaser's {{ .Version }}; the curl|sh install previously 404'd. - ci github: the generated workflow now runs `shipkit install`, so the readiness gate can pass and the release job is no longer always skipped. - agent: route through config.FileName instead of a hardcoded ".shipkit.yaml". Safety + features: - release: add --dry-run to print the provider commands without executing them; command mapping refactored into a testable table. - doctor: add --json structured output. - launch-check: real readiness evaluation (tools installed, config present and readable, store identifiers set and not the com.company.* placeholder), with text and --json output and a non-zero exit when not ready. Quality: - CI now enforces gofmt and `go vet`. - Tests for release dry-run, config round-trip, launch-check, and workflow generation. All pass under -race. --- .github/workflows/ci.yml | 9 +++ README.md | 41 +++++++---- install.sh | 5 +- internal/agent/agent.go | 15 ++-- internal/cli/cli.go | 72 ++++++++++++------- internal/cli/cli_test.go | 30 ++++++++ internal/config/config.go | 41 +++++++++++ internal/config/config_test.go | 26 +++++++ internal/doctor/doctor.go | 48 +++++++++++++ internal/launch/launch.go | 115 +++++++++++++++++++++++++++++++ internal/launch/launch_test.go | 92 +++++++++++++++++++++++++ internal/workflow/github.go | 4 ++ internal/workflow/github_test.go | 39 +++++++++++ 13 files changed, 494 insertions(+), 43 deletions(-) create mode 100644 internal/launch/launch.go create mode 100644 internal/launch/launch_test.go create mode 100644 internal/workflow/github_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6075086..e98990c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,14 @@ jobs: with: go-version-file: go.mod cache: true + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... - run: go test ./... - run: go build ./cmd/shipkit diff --git a/README.md b/README.md index c5dae8b..58c4d20 100644 --- a/README.md +++ b/README.md @@ -157,12 +157,13 @@ shipkit ci github | `shipkit agent --json` | AI-agent-friendly project context | | `shipkit install` | Install `gpc`, `rc`, and `asc` under the hood | | `shipkit init "My App"` | Create `.shipkit.yaml` | -| `shipkit doctor` | Check local tool readiness | +| `shipkit doctor [--json]` | Check local tool readiness | | `shipkit ci github` | Generate a GitHub Actions workflow | | `shipkit release android` | Run Android release flow through `gpc` | | `shipkit release ios` | Run iOS release flow through `asc` | | `shipkit release all` | Run Android then iOS release flows | -| `shipkit launch-check` | Check launch readiness | +| `shipkit release ... --dry-run` | Print the provider commands without running them | +| `shipkit launch-check [--json]` | Check launch readiness | | `shipkit version` | Print build metadata | --- @@ -288,6 +289,7 @@ Creates: The generated workflow: - installs Shipkit +- installs the provider CLIs (`gpc`, `rc`, `asc`) via `shipkit install` - checks local release tooling - runs `shipkit release` for `android`, `ios`, or `all` @@ -321,31 +323,45 @@ shipkit release ios # asc testflight upload These are deliberately thin wrappers. Advanced users can always drop down to `gpc`, `rc`, or `asc` directly. +Preview before you ship — `--dry-run` prints the exact provider commands without executing them: + +```bash +shipkit release all --dry-run +# [dry-run] gpc release --track internal +# [dry-run] asc testflight upload +``` + --- ## Launch Readiness ```bash shipkit launch-check +shipkit launch-check --json ``` -The product direction is to answer one question: +It answers one question: ```text Can this app ship today? ``` -Planned checks: +Checks today (verifiable locally, text or JSON, non-zero exit when not ready): + +- provider CLIs (`gpc`, `rc`, `asc`) are installed +- `.shipkit.yaml` exists and is readable +- app name is set +- iOS bundle ID is set and not the generated `com.company.*` placeholder +- Android package is set and not the generated `com.company.*` placeholder + +Planned checks (require store/network access): - Android package name matches Play Console setup - iOS bundle ID matches App Store Connect setup - RevenueCat product IDs exist for both stores - CI secrets are present -- release notes exist -- store metadata exists -- screenshots are present +- release notes, store metadata, and screenshots exist - internal track or TestFlight target is configured -- output is available as text and JSON --- @@ -445,7 +461,10 @@ internal/install install.go Homebrew-backed install orchestration internal/doctor - doctor.go local tool readiness checks + doctor.go local tool readiness checks (text and JSON) + +internal/launch + launch.go launch-readiness evaluation (text and JSON) internal/config config.go .shipkit.yaml rendering @@ -463,11 +482,9 @@ Small codebase. Clear boundaries. No duplicate provider API clients. ## Roadmap -- `shipkit doctor --json` -- `shipkit launch-check --json` - provider auth validation, not only executable checks - GitHub secret checklist generation -- release commands driven by `.shipkit.yaml` +- release commands driven by `.shipkit.yaml` (tracks, TestFlight target) - RevenueCat product consistency checks across iOS and Android - store metadata and screenshot readiness checks - CI summary comments for release readiness diff --git a/install.sh b/install.sh index ea5730b..d369757 100755 --- a/install.sh +++ b/install.sh @@ -19,7 +19,10 @@ if [ "$version" = "latest" ]; then version="$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)" fi -archive="${bin_name}_${version}_${os}_${arch}.tar.gz" +# GoReleaser strips the leading "v" from the archive name (.Version), but the +# release tag and download path keep it (.Tag). Mirror that split here. +archive_version="${version#v}" +archive="${bin_name}_${archive_version}_${os}_${arch}.tar.gz" url="https://github.com/$repo/releases/download/$version/$archive" tmp="$(mktemp -d)" diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 77e20ab..573c92d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -7,6 +7,7 @@ import ( "io" "os" + "github.com/AndroidPoet/shipkit/internal/config" "github.com/AndroidPoet/shipkit/internal/doctor" "github.com/AndroidPoet/shipkit/internal/runner" ) @@ -52,7 +53,7 @@ func BuildContext(ctx context.Context, r runner.Runner) Context { } configPresent := false - if _, err := os.Stat(".shipkit.yaml"); err == nil { + if _, err := os.Stat(config.FileName); err == nil { configPresent = true } @@ -73,7 +74,7 @@ func BuildContext(ctx context.Context, r runner.Runner) Context { Goal: "Make mobile release setup deterministic for humans and AI agents.", Tools: tools, Config: ConfigStatus{ - File: ".shipkit.yaml", + File: config.FileName, Present: configPresent, }, NextActions: nextActions, @@ -81,17 +82,17 @@ func BuildContext(ctx context.Context, r runner.Runner) Context { } func Print(ctx context.Context, r runner.Runner, stdout io.Writer, jsonOutput bool) error { - context := BuildContext(ctx, r) + agentCtx := BuildContext(ctx, r) if jsonOutput { encoder := json.NewEncoder(stdout) encoder.SetIndent("", " ") - return encoder.Encode(context) + return encoder.Encode(agentCtx) } fmt.Fprintln(stdout, "Shipkit Agent Context") fmt.Fprintln(stdout) fmt.Fprintln(stdout, "Tools:") - for _, tool := range context.Tools { + for _, tool := range agentCtx.Tools { if tool.Installed { fmt.Fprintf(stdout, "- %s (%s): installed at %s\n", tool.Name, tool.Command, tool.Path) } else { @@ -99,10 +100,10 @@ func Print(ctx context.Context, r runner.Runner, stdout io.Writer, jsonOutput bo } } fmt.Fprintln(stdout) - fmt.Fprintf(stdout, "Config: %s present=%t\n", context.Config.File, context.Config.Present) + fmt.Fprintf(stdout, "Config: %s present=%t\n", agentCtx.Config.File, agentCtx.Config.Present) fmt.Fprintln(stdout) fmt.Fprintln(stdout, "Next actions:") - for _, action := range context.NextActions { + for _, action := range agentCtx.NextActions { fmt.Fprintf(stdout, "- %s\n", action) } return nil diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 367377e..35e983a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "os" "strings" "github.com/AndroidPoet/shipkit/internal/agent" @@ -12,6 +11,7 @@ import ( "github.com/AndroidPoet/shipkit/internal/doctor" "github.com/AndroidPoet/shipkit/internal/guide" "github.com/AndroidPoet/shipkit/internal/install" + "github.com/AndroidPoet/shipkit/internal/launch" "github.com/AndroidPoet/shipkit/internal/runner" "github.com/AndroidPoet/shipkit/internal/workflow" ) @@ -55,6 +55,9 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade fmt.Fprintf(stdout, "Created %s\n", path) return nil case "doctor": + if hasFlag(args[1:], "--json") { + return doctor.PrintJSON(ctx, r, stdout) + } return doctor.Print(ctx, r, stdout) case "ci": if len(args) < 2 || args[1] != "github" { @@ -69,38 +72,60 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade case "release": return release(ctx, r, args[1:], stdout, stderr) case "launch-check": - if err := doctor.Print(ctx, r, stdout); err != nil { - return err - } - _, err := os.Stat(config.FileName) - if err != nil { - return fmt.Errorf("%s missing; run `shipkit init`", config.FileName) - } - fmt.Fprintln(stdout, "Launch config found.") - return nil + return launch.Print(ctx, r, stdout, hasFlag(args[1:], "--json")) default: return fmt.Errorf("unknown command %q", args[0]) } } -func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr io.Writer) error { - if len(args) != 1 { - return fmt.Errorf("usage: shipkit release android|ios|all") - } +const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]" - switch args[0] { +// releaseCommands maps a release target to the ordered provider commands it runs. +// Keeping it as data (rather than inline calls) lets `--dry-run` preview the exact +// commands and lets tests assert the mapping without executing anything. +func releaseCommands(target string) ([][]string, error) { + switch target { case "android": - return r.Run(ctx, stdout, stderr, "gpc", "release", "--track", "internal") + return [][]string{{"gpc", "release", "--track", "internal"}}, nil case "ios": - return r.Run(ctx, stdout, stderr, "asc", "testflight", "upload") + return [][]string{{"asc", "testflight", "upload"}}, nil case "all": - if err := release(ctx, r, []string{"android"}, stdout, stderr); err != nil { + android, _ := releaseCommands("android") + ios, _ := releaseCommands("ios") + return append(android, ios...), nil + default: + return nil, fmt.Errorf(releaseUsage) + } +} + +func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr io.Writer) error { + dryRun := hasFlag(args, "--dry-run") + + targets := make([]string, 0, len(args)) + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + targets = append(targets, arg) + } + } + if len(targets) != 1 { + return fmt.Errorf(releaseUsage) + } + + commands, err := releaseCommands(targets[0]) + if err != nil { + return err + } + + for _, command := range commands { + if dryRun { + fmt.Fprintf(stdout, "[dry-run] %s\n", strings.Join(command, " ")) + continue + } + if err := r.Run(ctx, stdout, stderr, command[0], command[1:]...); err != nil { return err } - return release(ctx, r, []string{"ios"}, stdout, stderr) - default: - return fmt.Errorf("usage: shipkit release android|ios|all") } + return nil } func printHelp(stdout io.Writer) { @@ -116,12 +141,13 @@ Usage: shipkit agent [--json] AI-agent-friendly local context shipkit install Install gpc, rc, and asc under the hood shipkit init [app name] Create .shipkit.yaml - shipkit doctor Check required tools + shipkit doctor [--json] Check required tools shipkit ci github Generate a GitHub Actions release workflow shipkit release android Run the Android release flow through gpc shipkit release ios Run the iOS release flow through asc shipkit release all Run Android then iOS release flows - shipkit launch-check Check local launch readiness + shipkit release ... --dry-run Print the provider commands without running them + shipkit launch-check [--json] Check local launch readiness Start: shipkit init "My App" diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ed16e2c..3b069ab 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -54,3 +54,33 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) { t.Fatalf("runs = %#v", r.runs) } } + +func Test_Run_releaseDryRunExecutesNothing(t *testing.T) { + var stdout bytes.Buffer + r := &fakeRunner{} + + err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{}) + + if err != nil { + t.Fatal(err) + } + if len(r.runs) != 0 { + t.Fatalf("dry-run must not execute any command, got %#v", r.runs) + } + out := stdout.String() + for _, want := range []string{ + "[dry-run] gpc release --track internal", + "[dry-run] asc testflight upload", + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func Test_Run_releaseRejectsUnknownTarget(t *testing.T) { + err := runWith(context.Background(), &fakeRunner{}, []string{"release", "windows"}, strings.NewReader(""), io.Discard, io.Discard, BuildInfo{}) + if err == nil { + t.Fatal("expected error for unknown release target") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9638e39..7976e84 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" ) @@ -52,6 +53,46 @@ release: `, cfg.Name, cfg.IOSBundleID, cfg.AndroidPackage, revenueCat) } +// Loaded holds the identifiers Shipkit reads back from a .shipkit.yaml. +type Loaded struct { + Name string + IOSBundleID string + AndroidPackage string +} + +// Load reads the shipkit-generated config. It is a deliberately small, dependency-free +// reader for the flat structure Render writes — not a general YAML parser. The three +// keys it extracts are unique in the file, so a line scan is sufficient and avoids +// pulling a YAML dependency into an audit-friendly tool. +func Load(dir string) (Loaded, error) { + data, err := os.ReadFile(filepath.Join(dir, FileName)) + if err != nil { + return Loaded{}, err + } + + var loaded Loaded + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "name:"): + loaded.Name = parseValue(trimmed[len("name:"):]) + case strings.HasPrefix(trimmed, "ios_bundle_id:"): + loaded.IOSBundleID = parseValue(trimmed[len("ios_bundle_id:"):]) + case strings.HasPrefix(trimmed, "android_package:"): + loaded.AndroidPackage = parseValue(trimmed[len("android_package:"):]) + } + } + return loaded, nil +} + +func parseValue(raw string) string { + value := strings.TrimSpace(raw) + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted + } + return strings.Trim(value, `"`) +} + func Write(dir string, cfg AppConfig) (string, error) { path := filepath.Join(dir, FileName) if _, err := os.Stat(path); err == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f11b645..cd23197 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,32 @@ import ( "testing" ) +func Test_Load_roundTripsWrittenConfig(t *testing.T) { + dir := t.TempDir() + + if _, err := Write(dir, AppConfig{ + Name: "Launch Pad", + IOSBundleID: "com.acme.launchpad", + AndroidPackage: "com.acme.launchpad.android", + }); err != nil { + t.Fatalf("Write: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Name != "Launch Pad" { + t.Errorf("Name = %q", loaded.Name) + } + if loaded.IOSBundleID != "com.acme.launchpad" { + t.Errorf("IOSBundleID = %q", loaded.IOSBundleID) + } + if loaded.AndroidPackage != "com.acme.launchpad.android" { + t.Errorf("AndroidPackage = %q", loaded.AndroidPackage) + } +} + func Test_Render_includesToolCommands(t *testing.T) { cfg := Default("Launch Pad") diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 775592e..27a282a 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -2,6 +2,7 @@ package doctor import ( "context" + "encoding/json" "fmt" "io" @@ -15,6 +16,53 @@ type Result struct { Message string } +// Report is the structured, agent-friendly view of `shipkit doctor`. +type Report struct { + Ready bool `json:"ready"` + Missing int `json:"missing"` + Tools []ToolReport `json:"tools"` +} + +type ToolReport struct { + Name string `json:"name"` + Command string `json:"command"` + Installed bool `json:"installed"` + Path string `json:"path,omitempty"` +} + +func BuildReport(ctx context.Context, r runner.Runner) Report { + results := Check(ctx, r) + report := Report{Ready: true, Tools: make([]ToolReport, 0, len(results))} + for _, result := range results { + tool := ToolReport{ + Name: result.Tool.Name, + Command: result.Tool.Executable, + Installed: result.Ready, + } + if result.Ready { + tool.Path = result.Message + } else { + report.Missing++ + report.Ready = false + } + report.Tools = append(report.Tools, tool) + } + return report +} + +func PrintJSON(ctx context.Context, r runner.Runner, stdout io.Writer) error { + report := BuildReport(ctx, r) + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return err + } + if !report.Ready { + return fmt.Errorf("%d required tools missing", report.Missing) + } + return nil +} + func Check(ctx context.Context, r runner.Runner) []Result { results := make([]Result, 0, len(install.Tools)) for _, tool := range install.Tools { diff --git a/internal/launch/launch.go b/internal/launch/launch.go new file mode 100644 index 0000000..5f3b3e6 --- /dev/null +++ b/internal/launch/launch.go @@ -0,0 +1,115 @@ +package launch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/AndroidPoet/shipkit/internal/config" + "github.com/AndroidPoet/shipkit/internal/doctor" + "github.com/AndroidPoet/shipkit/internal/runner" +) + +// Check is a single launch-readiness signal. +type Check struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// Report answers one question: can this app ship today? +type Report struct { + Ready bool `json:"ready"` + Checks []Check `json:"checks"` +} + +// Evaluate runs the checks that can be verified locally today: required tools are +// installed, the config exists, and the store identifiers are filled in (not left as +// the generated `com.company.*` placeholder). Store/network checks remain on the +// roadmap and are intentionally not faked here. +func Evaluate(ctx context.Context, r runner.Runner) Report { + report := Report{Ready: true} + add := func(name string, ok bool, detail string) { + report.Checks = append(report.Checks, Check{Name: name, OK: ok, Detail: detail}) + if !ok { + report.Ready = false + } + } + + for _, result := range doctor.Check(ctx, r) { + detail := result.Message + if result.Ready { + detail = "installed at " + result.Message + } else { + detail = "missing; run `shipkit install`" + } + add("tool: "+result.Tool.Name, result.Ready, detail) + } + + if _, err := os.Stat(config.FileName); err != nil { + add("config", false, config.FileName+" missing; run `shipkit init`") + return report + } + add("config", true, config.FileName+" present") + + loaded, err := config.Load(".") + if err != nil { + add("config readable", false, err.Error()) + return report + } + + add("app name", loaded.Name != "", identifierDetail("app name", loaded.Name)) + add("ios bundle id", validIdentifier(loaded.IOSBundleID), identifierDetail("ios_bundle_id", loaded.IOSBundleID)) + add("android package", validIdentifier(loaded.AndroidPackage), identifierDetail("android_package", loaded.AndroidPackage)) + + return report +} + +func validIdentifier(value string) bool { + return value != "" && !strings.HasPrefix(value, "com.company.") +} + +func identifierDetail(field, value string) string { + switch { + case value == "": + return field + " is empty" + case strings.HasPrefix(value, "com.company."): + return field + " still uses the placeholder " + value + default: + return value + } +} + +func Print(ctx context.Context, r runner.Runner, stdout io.Writer, jsonOutput bool) error { + report := Evaluate(ctx, r) + + if jsonOutput { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return err + } + } else { + for _, check := range report.Checks { + mark := "✓" + if !check.OK { + mark = "✗" + } + fmt.Fprintf(stdout, "%s %s: %s\n", mark, check.Name, check.Detail) + } + fmt.Fprintln(stdout) + if report.Ready { + fmt.Fprintln(stdout, "Ready: this app can ship today.") + } else { + fmt.Fprintln(stdout, "Not ready: resolve the items marked ✗ above.") + } + } + + if !report.Ready { + return fmt.Errorf("launch readiness checks failed") + } + return nil +} diff --git a/internal/launch/launch_test.go b/internal/launch/launch_test.go new file mode 100644 index 0000000..828b0de --- /dev/null +++ b/internal/launch/launch_test.go @@ -0,0 +1,92 @@ +package launch + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/AndroidPoet/shipkit/internal/config" +) + +type fakeRunner struct { + paths map[string]string +} + +func (f fakeRunner) LookPath(name string) (string, error) { + if path, ok := f.paths[name]; ok { + return path, nil + } + return "", errors.New("missing") +} + +func (fakeRunner) Run(context.Context, io.Writer, io.Writer, string, ...string) error { + return nil +} + +func allToolsInstalled() fakeRunner { + return fakeRunner{paths: map[string]string{ + "gpc": "/bin/gpc", + "rc": "/bin/rc", + "asc": "/bin/asc", + }} +} + +func writeConfig(t *testing.T, cfg config.AppConfig) { + t.Helper() + t.Chdir(t.TempDir()) + if _, err := config.Write(".", cfg); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func Test_Evaluate_readyWhenToolsAndRealIdentifiersPresent(t *testing.T) { + writeConfig(t, config.AppConfig{ + Name: "Launch Pad", + IOSBundleID: "com.acme.launchpad", + AndroidPackage: "com.acme.launchpad", + }) + + report := Evaluate(context.Background(), allToolsInstalled()) + + if !report.Ready { + t.Fatalf("expected ready, got: %#v", report.Checks) + } +} + +func Test_Evaluate_notReadyOnPlaceholderIdentifiers(t *testing.T) { + // config.Default leaves the com.company.* placeholder, which is not shippable. + writeConfig(t, config.Default("Launch Pad")) + + report := Evaluate(context.Background(), allToolsInstalled()) + + if report.Ready { + t.Fatal("expected not ready while identifiers are placeholders") + } + if !hasFailingCheck(report, "ios bundle id") || !hasFailingCheck(report, "android package") { + t.Fatalf("expected placeholder identifier checks to fail: %#v", report.Checks) + } +} + +func Test_Evaluate_notReadyWhenToolsMissing(t *testing.T) { + writeConfig(t, config.AppConfig{ + Name: "Launch Pad", + IOSBundleID: "com.acme.launchpad", + AndroidPackage: "com.acme.launchpad", + }) + + report := Evaluate(context.Background(), fakeRunner{}) + + if report.Ready { + t.Fatal("expected not ready when provider tools are missing") + } +} + +func hasFailingCheck(report Report, name string) bool { + for _, check := range report.Checks { + if check.Name == name && !check.OK { + return true + } + } + return false +} diff --git a/internal/workflow/github.go b/internal/workflow/github.go index 346e870..a85e5b1 100644 --- a/internal/workflow/github.go +++ b/internal/workflow/github.go @@ -31,6 +31,8 @@ jobs: go-version: stable - name: Install Shipkit run: go install github.com/AndroidPoet/shipkit/cmd/shipkit@latest + - name: Install release tools + run: shipkit install - name: Check release cockpit run: shipkit doctor @@ -45,6 +47,8 @@ jobs: go-version: stable - name: Install Shipkit run: go install github.com/AndroidPoet/shipkit/cmd/shipkit@latest + - name: Install release tools + run: shipkit install - name: Release run: shipkit release "${{ inputs.platform }}" ` diff --git a/internal/workflow/github_test.go b/internal/workflow/github_test.go new file mode 100644 index 0000000..20d14c8 --- /dev/null +++ b/internal/workflow/github_test.go @@ -0,0 +1,39 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteGitHub(t *testing.T) { + dir := t.TempDir() + + path, err := WriteGitHub(dir) + if err != nil { + t.Fatalf("WriteGitHub: %v", err) + } + + want := filepath.Join(dir, ".github", "workflows", "mobile-release.yml") + if path != want { + t.Fatalf("path = %q, want %q", path, want) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read workflow: %v", err) + } + content := string(data) + + // The readiness gate must install the provider CLIs before doctor runs, + // otherwise doctor fails and the gated release job is always skipped. + if strings.Count(content, "shipkit install") != 2 { + t.Errorf("expected `shipkit install` in both jobs, got:\n%s", content) + } + for _, want := range []string{"shipkit doctor", "shipkit release"} { + if !strings.Contains(content, want) { + t.Errorf("workflow missing %q", want) + } + } +}