Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
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
Draft
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
31 changes: 26 additions & 5 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,22 +10,43 @@ permissions:
contents: read

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
- name: Check formatting (gofumpt)
run: |
unformatted="$(gofmt -l .)"
go install mvdan.cc/gofumpt@latest
unformatted="$(gofumpt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "These files are not gofumpt-clean:"
echo "$unformatted"
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go vet ./...
- run: go test ./...
- name: Test with coverage
run: go test ./... -coverprofile=coverage.out -covermode=atomic
- name: Coverage summary
run: go tool cover -func=coverage.out
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- run: go build ./cmd/shipkit
50 changes: 50 additions & 0 deletions .github/workflows/docs.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
name: Deploy docs

on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- run: pnpm install --no-frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: website/out

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
/shipkit
*.test
.shipkit.yaml
coverage.out
coverage.html
31 changes: 31 additions & 0 deletions .golangci.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
# golangci-lint v2 configuration
# Run: golangci-lint run ./...
version: "2"

linters:
default: none
enable:
- errcheck # checks unchecked errors
- govet # vet-style suspicious constructs
- ineffassign # detects ineffectual assignments
- staticcheck # staticcheck (includes gosimple + stylecheck)
- unused # finds unused code
settings:
errcheck:
# Writing to stdout/stderr writers rarely fails and the error is
# not actionable in a CLI, so don't force checks on those calls.
exclude-functions:
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
exclusions:
generated: lax
presets:
- comments
- std-error-handling

formatters:
enable:
- gofumpt # stricter gofmt
exclusions:
generated: lax
32 changes: 31 additions & 1 deletion Makefile
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
.PHONY: build test install snapshot release-check
.PHONY: build test cover cover-html lint fmt fmt-check install snapshot release-check tidy

build:
go build -o shipkit ./cmd/shipkit

test:
go test ./...

# Run the full suite with an atomic coverage profile.
cover:
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

# Produce a browsable HTML coverage report.
cover-html: cover
go tool cover -html=coverage.out -o coverage.html
@echo "Wrote coverage.html"

# Static analysis. Requires golangci-lint (https://golangci-lint.run).
lint:
golangci-lint run ./...

# Format the codebase with gofumpt (stricter gofmt).
fmt:
gofumpt -w .

# Fail if any file is not gofumpt-clean.
fmt-check:
@unformatted="$$(gofumpt -l .)"; \
if [ -n "$$unformatted" ]; then \
echo "These files are not gofumpt-clean:"; \
echo "$$unformatted"; \
exit 1; \
fi

tidy:
go mod tidy

install:
go install ./cmd/shipkit

Expand Down
7 changes: 4 additions & 3 deletions internal/cli/cli.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"strings"
Expand DownExpand Up@@ -78,7 +79,7 @@ func runWith(ctx context.Context, r runner.Runner, args []string, stdin io.Reade
}
}

const releaseUsage = "usage: shipkit release android|ios|all [--dry-run]"
var errReleaseUsage = errors.New("usage: shipkit release android|ios|all [--dry-run]")

// 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
Expand All@@ -94,7 +95,7 @@ func releaseCommands(target string) ([][]string, error) {
ios, _ := releaseCommands("ios")
return append(android, ios...), nil
default:
return nil, fmt.Errorf(releaseUsage)
return nil, errReleaseUsage
}
}

Expand All@@ -108,7 +109,7 @@ func release(ctx context.Context, r runner.Runner, args []string, stdout, stderr
}
}
if len(targets) != 1 {
return fmt.Errorf(releaseUsage)
return errReleaseUsage
}

commands, err := releaseCommands(targets[0])
Expand Down
2 changes: 0 additions & 2 deletions internal/cli/cli_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,6 @@ func Test_Run_releaseAllRunsAndroidThenIOS(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand All@@ -60,7 +59,6 @@ func Test_Run_releaseDryRunExecutesNothing(t *testing.T) {
r := &fakeRunner{}

err := runWith(context.Background(), r, []string{"release", "all", "--dry-run"}, strings.NewReader(""), &stdout, io.Discard, BuildInfo{})

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,5 +100,5 @@ func Write(dir string, cfg AppConfig) (string, error) {
} else if !os.IsNotExist(err) {
return path, err
}
return path, os.WriteFile(path, []byte(Render(cfg)), 0644)
return path, os.WriteFile(path, []byte(Render(cfg)), 0o644)
}
2 changes: 0 additions & 2 deletions internal/guide/guide_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,6 @@ func Test_Run_printsSetupPlanFromAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand All@@ -36,7 +35,6 @@ func Test_Run_usesDefaultsForBlankAnswers(t *testing.T) {
var output bytes.Buffer

answers, err := Run(input, &output)

if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/install/install.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package install

import (
"context"
"errors"
"fmt"
"io"

Expand All@@ -24,7 +25,7 @@ var Tools = []Tool{

func Run(ctx context.Context, r runner.Runner, stdout, stderr io.Writer) error {
if _, err := r.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew is required for automatic install. Install the tools manually from their GitHub repos, then run shipkit doctor")
return errors.New("homebrew is required for automatic install; install the tools manually from their GitHub repos, then run shipkit doctor")
}

for _, tool := range Tools {
Expand Down
1 change: 0 additions & 1 deletion internal/install/install_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,6 @@ func Test_Run_installsMissingToolsWithHomebrew(t *testing.T) {
r := &fakeRunner{paths: map[string]string{"brew": "/opt/homebrew/bin/brew"}}

err := Run(context.Background(), r, &stdout, io.Discard)

if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launch/launch.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ func Evaluate(ctx context.Context, r runner.Runner) Report {
}

for _, result := range doctor.Check(ctx, r) {
detail := result.Message
var detail string
if result.Ready {
detail = "installed at " + result.Message
} else {
Expand Down
4 changes: 2 additions & 2 deletions internal/workflow/github.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,9 +55,9 @@ jobs:

func WriteGitHub(dir string) (string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
if err := os.MkdirAll(workflowDir, 0o755); err != nil {
return "", err
}
path := filepath.Join(workflowDir, "mobile-release.yml")
return path, os.WriteFile(path, []byte(releaseWorkflow), 0644)
return path, os.WriteFile(path, []byte(releaseWorkflow), 0o644)
}
6 changes: 6 additions & 0 deletions website/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
node_modules
.next
out
.DS_Store
*.log
.vercel
33 changes: 33 additions & 0 deletions website/components/Hero.jsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import Link from 'next/link'

const REPO = 'https://github.com/AndroidPoet/shipkit'

const GitHubMark = () => (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
)

export function Hero() {
return (
<div className="sk-hero">
<div className="sk-hero-glow" aria-hidden="true" />
<span className="sk-hero-badge">Go CLI · One surface for every store</span>
<h1 className="sk-hero-title">Shipkit</h1>
<p className="sk-hero-sub">
The release cockpit for mobile apps — one AI-agent-friendly command surface
for Google Play, App Store Connect, RevenueCat, and the CI glue that makes
releases repeatable.
</p>
<div className="sk-hero-cta">
<Link href="/getting-started" className="sk-btn sk-btn-primary">
Get started →
</Link>
<a href={REPO} target="_blank" rel="noreferrer" className="sk-btn sk-btn-ghost">
<GitHubMark />
View on GitHub
</a>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions website/next.config.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import nextra from 'nextra'

const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true,
})

// Served from https://androidpoet.github.io/shipkit/ — a GitHub Pages project
// site lives under a sub-path, so set basePath/assetPrefix accordingly.
const basePath = '/shipkit'

export default withNextra({
output: 'export',
images: { unoptimized: true },
reactStrictMode: true,
basePath,
assetPrefix: basePath,
})
23 changes: 23 additions & 0 deletions website/package.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
{
"name": "shipkit-docs",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.5.18",
"nextra": "^3.3.1",
"nextra-theme-docs": "^3.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"pnpm": {
"overrides": {
"postcss@<8.5.10": "^8.5.10"
}
}
}
8 changes: 8 additions & 0 deletions website/pages/404.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
title: Page Not Found
---

# 404 — Page Not Found

This page does not exist. Head back to the [introduction](/) or jump to
[Getting Started](/getting-started).
Loading
Loading