From f9692f7bccd349160170b929ad65477b75b552db Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:01:32 +0530 Subject: [PATCH 1/5] feat(cli): add the fastlane APNs key setup on spaceship The fastlane adapter creates APNs keys with spaceship, fastlane's open-source Apple Developer portal client, through the fastlane the person already has installed: a lane in an embedded Fastfile runs in a private temp folder, so a gem, Homebrew or bundler installation all work and no Ruby ships with the CLI. It needs fastlane 2.226.0 or later, the first to sign in with SRP, and reports apns.ErrUnavailable otherwise. The adapter asks for the Apple ID and password when the request has none and passes them as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane never offers to store them in the Keychain; spaceship asks for the two-factor code on the terminal. The lane calls create_key! directly so the requested environment is honoured (Key.create always asks for all), maps InvalidUserCredentialsError to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError with the team's keys. --- src/SDK/Language/CLI.php | 15 ++ .../cli/internal/apns/fastlane/adapter.go | 225 ++++++++++++++++++ .../internal/apns/fastlane/adapter_test.go | 166 +++++++++++++ .../internal/apns/fastlane/helper/Fastfile | 54 +++++ 4 files changed, 460 insertions(+) create mode 100644 templates/cli/internal/apns/fastlane/adapter.go create mode 100644 templates/cli/internal/apns/fastlane/adapter_test.go create mode 100644 templates/cli/internal/apns/fastlane/helper/Fastfile diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 946e66a73e..fb86987483 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1161,6 +1161,21 @@ public function getFiles(): array 'destination' => 'internal/apns/apnstest/apnstest.go', 'template' => 'cli/internal/apns/apnstest/apnstest.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..dde7ebe681 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,225 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed, so it works with a gem, Homebrew or bundler +// installation and brings no Ruby of its own. spaceship signs in with SRP, +// since fastlane 2.226.0. +package fastlane + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// The first fastlane that signs in with SRP, which Apple has required since +// October 2024. +var minimumVersion = [3]int{2, 226, 0} + +// Adapter creates APNs keys with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means "fastlane" from PATH. + Fastlane string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// CreateKey runs the embedded lane. The Apple ID and password are asked here +// when the request has none, and handed to fastlane as FASTLANE_USER and +// FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if err := a.checkFastlane(ctx); err != nil { + return apns.Key{}, err + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return apns.Key{}, err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return apns.Key{}, err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return apns.Key{}, err + } + out := filepath.Join(scratch, "result.json") + + arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + command := exec.CommandContext(ctx, a.fastlane(), arguments...) + command.Dir = scratch + command.Stdin, command.Stdout, command.Stderr = a.terminal() + command.Env = append(os.Environ(), + "FASTLANE_USER="+appleID, + "FASTLANE_PASSWORD="+password, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane failed: %w", runErr) + } + + return apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + case "invalid-credentials": + return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) + default: + if decoded.Message == "" { + decoded.Message = decoded.Error + } + + return apns.Key{}, errors.New(decoded.Message) + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) fastlane() string { + if a.Fastlane != "" { + return a.Fastlane + } + + return "fastlane" +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func (a *Adapter) checkFastlane(ctx context.Context) error { + command := exec.CommandContext(ctx, a.fastlane(), "--version") + command.Env = append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + output, err := command.Output() + if err != nil { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) + } + + return supportedVersion(string(output)) +} + +func supportedVersion(output string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + for index := range version { + if version[index] != minimumVersion[index] { + if version[index] < minimumVersion[index] { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Run fastlane update_fastlane", apns.ErrUnavailable, versionString(minimumVersion), versionString(version)) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..66ed53b867 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,166 @@ +package fastlane + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.226.0": true, + "fastlane 3.0.0": true, + "fastlane 2.225.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output) + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..57ad078f83 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,54 @@ +# Creates a team-scoped APNs key for `appwrite init apns --create-key +# --provider fastlane` with spaceship, fastlane's Apple Developer portal +# client, so any fastlane installation (gem, Homebrew or bundler) can run it. +# +# spaceship asks for the two-factor code, and for a team when the account has +# several, on the terminal. The Apple ID and password come from FASTLANE_USER +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + result = begin + create_apns_key_with_spaceship(options) + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +def create_apns_key_with_spaceship(options) + require 'spaceship' + + Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + team_id = options[:team_id].to_s + if team_id.empty? + Spaceship::Portal.select_team + else + Spaceship::Portal.select_team(team_id: team_id) + end + client = Spaceship::Portal.client + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + raise unless error.message.include?('maximum allowed number of Keys') + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end From 84a5d767845b25a805c1dc00c7ec247e67ee13c9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:01:32 +0530 Subject: [PATCH 2/5] feat(cli): offer the fastlane APNs key setup in init apns Registers the fastlane adapter, picked with --provider fastlane or APPWRITE_APNS_SETUP=fastlane. --- templates/cli/internal/cmd/initpush.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 197c0b4d49..f7d298d0ab 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -23,6 +23,7 @@ import ( "time" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" @@ -565,7 +566,7 @@ const ( // apnsAdapters lists the APNs key setups this build offers. Each lives in its // own package under internal/apns and is added here. func apnsAdapters() *apns.Registry { - return apns.NewRegistry() + return apns.NewRegistry(fastlane.New()) } // apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then From b55c767b326733952b56d49d26836d69e834f93e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:02:40 +0530 Subject: [PATCH 3/5] fix(cli): require fastlane 2.225.0 for the fastlane APNs key setup 2.225.0 (October 2024) is the release that added SRP sign-in, per its notes: earlier versions get 503 from Apple. The minimum was set one release too high. --- templates/cli/internal/apns/fastlane/adapter.go | 11 ++++++----- templates/cli/internal/apns/fastlane/adapter_test.go | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index dde7ebe681..418b2c92be 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -3,8 +3,8 @@ // // It runs a lane from a Fastfile embedded in the binary with the fastlane the // person already has installed, so it works with a gem, Homebrew or bundler -// installation and brings no Ruby of its own. spaceship signs in with SRP, -// since fastlane 2.226.0. +// installation and brings no Ruby of its own. It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. package fastlane import ( @@ -27,9 +27,10 @@ import ( //go:embed helper/Fastfile var fastfile []byte -// The first fastlane that signs in with SRP, which Apple has required since -// October 2024. -var minimumVersion = [3]int{2, 226, 0} +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} // Adapter creates APNs keys with an installed fastlane. type Adapter struct { diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 66ed53b867..82913dc2fc 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -15,9 +15,9 @@ import ( func TestSupportedVersion(t *testing.T) { for output, supported := range map[string]bool{ "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, - "fastlane 2.226.0": true, + "fastlane 2.225.0": true, "fastlane 3.0.0": true, - "fastlane 2.225.9": false, + "fastlane 2.224.9": false, "fastlane 1.300.0": false, "something else": false, } { From 5dfc509cad8f2221a3cb5537d8cb869fd3fe4331 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:09:59 +0530 Subject: [PATCH 4/5] fix(cli): run a project's Bundler fastlane with bundle exec The fastlane setup ran fastlane from PATH in a temporary folder, so a fastlane installed only through a project's Gemfile was reported as unavailable. When the Gemfile in the folder the CLI runs in, or in its ios folder as Flutter and React Native projects keep it, lists fastlane and bundle is installed, the lane now runs with bundle exec fastlane and BUNDLE_GEMFILE pointing at that Gemfile. Otherwise fastlane from PATH is used as before. A Bundler fastlane that does not start asks for bundle install. --- .../cli/internal/apns/fastlane/adapter.go | 91 ++++++++++++++++--- .../internal/apns/fastlane/adapter_test.go | 73 +++++++++++++++ 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index 418b2c92be..eae7f906c3 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -2,8 +2,9 @@ // open-source Apple Developer portal client. // // It runs a lane from a Fastfile embedded in the binary with the fastlane the -// person already has installed, so it works with a gem, Homebrew or bundler -// installation and brings no Ruby of its own. It needs fastlane 2.225.0 or +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or // later, the first release whose spaceship signs in with SRP. package fastlane @@ -34,8 +35,14 @@ var minimumVersion = [3]int{2, 225, 0} // Adapter creates APNs keys with an installed fastlane. type Adapter struct { - // Fastlane is the command to run. Empty means "fastlane" from PATH. + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string // spaceship prompts for the two-factor code, and for a team when the // account has several, on the terminal. Nil means the process's own. Stdin io.Reader @@ -70,9 +77,13 @@ type result struct { // when the request has none, and handed to fastlane as FASTLANE_USER and // FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - if err := a.checkFastlane(ctx); err != nil { + runner := a.runner() + if err := runner.check(ctx); err != nil { return apns.Key{}, err } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } appleID := strings.TrimSpace(request.AppleID) if appleID == "" { @@ -109,10 +120,10 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key if request.TeamID != "" { arguments = append(arguments, "team_id:"+request.TeamID) } - command := exec.CommandContext(ctx, a.fastlane(), arguments...) + command := runner.command(ctx, arguments...) command.Dir = scratch command.Stdin, command.Stdout, command.Stderr = a.terminal() - command.Env = append(os.Environ(), + command.Env = append(command.Env, "FASTLANE_USER="+appleID, "FASTLANE_PASSWORD="+password, "FASTLANE_SKIP_UPDATE_CHECK=1", @@ -178,27 +189,79 @@ func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { return stdin, stdout, stderr } -func (a *Adapter) fastlane() string { +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. +func (a *Adapter) runner() runner { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") if a.Fastlane != "" { - return a.Fastlane + return runner{name: a.Fastlane, env: base} + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err == nil { + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile} + } } - return "fastlane" + return runner{name: "fastlane", env: base} } -var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) -func (a *Adapter) checkFastlane(ctx context.Context) error { - command := exec.CommandContext(ctx, a.fastlane(), "--version") - command.Env = append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") - output, err := command.Output() + return command +} + +func (r runner) check(ctx context.Context) error { + output, err := r.command(ctx, "--version").Output() if err != nil { + if r.gemfile != "" { + return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) + } + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later installed (https://docs.fastlane.tools)", apns.ErrUnavailable, versionString(minimumVersion)) } return supportedVersion(string(output)) } +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + func supportedVersion(output string) error { match := versionPattern.FindStringSubmatch(output) if match == nil { diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 82913dc2fc..5710afae68 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -3,6 +3,7 @@ package fastlane import ( "context" "errors" + "fmt" "os" "path/filepath" "runtime" @@ -164,3 +165,75 @@ func TestCreateKeyNeedsARecentFastlane(t *testing.T) { t.Fatalf("missing fastlane err = %v", err) } } + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} From b90e5bf055f3e41474227287119d2e9a2f912b50 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 14:29:40 +0530 Subject: [PATCH 5/5] fix(cli): do not run another fastlane when the project pins one When the project's Gemfile lists fastlane but Bundler is missing, the fastlane setup fell back to fastlane from PATH, which can be a different version from the pinned one. It now reports apns.ErrUnavailable naming the Gemfile and how to install Bundler. --- .../cli/internal/apns/fastlane/adapter.go | 21 ++++++++++++------- .../internal/apns/fastlane/adapter_test.go | 14 +++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go index eae7f906c3..ee72b6d0d2 100644 --- a/templates/cli/internal/apns/fastlane/adapter.go +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -77,7 +77,10 @@ type result struct { // when the request has none, and handed to fastlane as FASTLANE_USER and // FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { - runner := a.runner() + runner, err := a.runner() + if err != nil { + return apns.Key{}, err + } if err := runner.check(ctx); err != nil { return apns.Key{}, err } @@ -202,23 +205,27 @@ var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) // runner picks the configured command, then the project's Bundler fastlane, // then fastlane from PATH. A Bundler fastlane must run with bundle exec and -// the project's Gemfile, since the lane runs in a temporary folder. -func (a *Adapter) runner() runner { +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") if a.Fastlane != "" { - return runner{name: a.Fastlane, env: base} + return runner{name: a.Fastlane, env: base}, nil } bundle := a.Bundle if bundle == "" { bundle = "bundle" } if gemfile := projectGemfile(a.WorkDir); gemfile != "" { - if _, err := exec.LookPath(bundle); err == nil { - return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile} + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil } - return runner{name: "fastlane", env: base} + return runner{name: "fastlane", env: base}, nil } // projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go index 5710afae68..8a95dafa4a 100644 --- a/templates/cli/internal/apns/fastlane/adapter_test.go +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -237,3 +237,17 @@ done t.Errorf("logged %v", logged) } } + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +}