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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
41 changes: 29 additions & 12 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |

---
Expand DownExpand Up@@ -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`

Expand DownExpand Up@@ -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

---

Expand DownExpand Up@@ -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
Expand All@@ -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
Expand Down
5 changes: 4 additions & 1 deletion install.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)"

Expand Down
15 changes: 8 additions & 7 deletions internal/agent/agent.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
}

Expand All@@ -73,36 +74,36 @@ 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,
}
}

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 {
fmt.Fprintf(stdout, "- %s (%s): missing\n", tool.Name, tool.Command)
}
}
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
Expand Down
72 changes: 49 additions & 23 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,14 +4,14 @@ import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/AndroidPoet/shipkit/internal/agent"
"github.com/AndroidPoet/shipkit/internal/config"
"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"
)
Expand DownExpand Up@@ -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" {
Expand All@@ -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) {
Expand All@@ -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"
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")
}
}
41 changes: 41 additions & 0 deletions internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)

Expand DownExpand Up@@ -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 {
Expand Down
Loading
Loading