Skip to content
Open
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
25 changes: 21 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ go install ./cmd/builder
./builder asc testers add <email>... --group <name> # also: testers remove, users invite
./builder asc testers invite <email>... # send/resend the TestFlight email
./builder asc builds expire --build-number N --yes # groups delete needs --yes too
./builder env set API_URL https://... [--profile production] # also: env unset, env list [--json]
./builder secret set SENTRY_TOKEN [--profile p] [--provider x] [--value-stdin] # value on the provider, name in builder.json
./builder secret unset SENTRY_TOKEN [--profile p] # secret list [--json]: names + presence only
```

## Architecture
Expand Down Expand Up @@ -247,11 +250,24 @@ internal/
`env` and `distribution` (`config.ResolveProfile`: `--profile`, else `defaultProfile`, else top level
unchanged). A profile signs iff it has a `distribution`; `ios.signing` is only the no-profile path
- **Profile Transport**: the `profile` dispatch input is one JSON object (`{"name","env","distribution"}`)
to stay under the ten-input limit and is sent only when a profile is selected, since an older
workflow rejects unknown inputs (`triggerError`). `runner.sh` reads `BUILD_ENV` and `DISTRIBUTION`
to stay under the ten-input limit and is sent only when a profile is selected (or a top-level `env`/
`secrets` exists), since an older workflow rejects unknown inputs (`triggerError`). It also carries
`"secrets"` (names). `runner.sh` reads `BUILD_ENV`, `DISTRIBUTION`, `BUILDER_SECRETS`, `BUILDER_SECRET_SUFFIX`
- **Profile Env**: entries are base64 per key/value on the runner and the `$GITHUB_ENV` heredoc uses a
random delimiter; names must match `^[A-Za-z_][A-Za-z0-9_]*$` and not hit `reservedEnv`/
`reservedEnvPrefixes` (`internal/config/profile.go`), which must track what the runners read
`reservedEnvPrefixes` (`internal/config/profile.go`), which must track what the runners read.
A top-level `env` applies to every build; the profile's wins per key (`config.resolveEnv`)
- **Secrets Are Names Only**: builder.json `secrets` (top level, per profile; union) lists names;
values live on the provider (`ci.SecretStore`: GitHub Actions secrets, Codemagic secure vars in the
`builder` group via v3 `variable-groups`, Bitrise protected app secrets). `secret set --profile P`
stores `NAME__<SUFFIX>` (`config.SecretStorageName`: upper-cased, non-alnum `_`); runners prefer it
over `NAME` and export as `NAME`. Names: `^[A-Z_][A-Z0-9_]*$`, no `__`, reserved rules, never also env.
Value from a hidden prompt or `--value-stdin`, never argv. `secretStoreFor` is a var for tests
- **Secrets On The Runner**: GitHub's Resolve step gets `BUILDER_SECRETS_JSON: ${{ toJSON(secrets) }}`
(that step only; `TestResolveStepReceivesAllSecrets`), exports only listed names with
`::add-mask::` per value line, fails naming `builder secret set` for a missing one. `runner.sh`
`export_build_secrets` runs after `export_build_env`. `ios build` refuses secrets when the local
`ios-build.yml` lacks `toJSON(secrets)` (`checkWorkflowExportsSecrets`). `ios share` gets none
- **Signing Sets**: one trio per distribution, `IOS_{CERTIFICATE,CERTIFICATE_PASSWORD,PROVISIONING_PROFILE}_<SET>`
(DEVELOPMENT, AD_HOC, STORE, ENTERPRISE); the unsuffixed names serve only the legacy no-profile path.
The table lives in `config.SigningSet` and the shell `signing_set` (both templates) and must agree
Expand Down Expand Up @@ -459,7 +475,8 @@ the first IPA exists. `signing.dir` is the last automatic `signing setup`'s `--o

A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the
only signing field, omitted = unsigned), `configuration` (else Debug for development, Release
otherwise), `scheme`, `provider`, `env`. `runner`/`submit` are planned on `config.Profile`, not read.
otherwise), `scheme`, `provider`, `env`, `secrets`. `runner`/`submit` are planned on `config.Profile`,
not read. Top-level `env` (map) and `secrets` (names) apply to every build.

## Workflow Features

Expand Down
75 changes: 69 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ builder ios build --unsigned # Build without code signing (if signing is config
builder ios build --provider codemagic # Build on another provider (also: bitrise)
builder ios build --profile production # Build with a profile from builder.json

# Build environment and secrets (see "Environment and secrets" below)
builder env set API_URL https://api.example.com # Plain value for every build
builder env set API_URL https://staging.example.com --profile preview
builder env unset API_URL [--profile preview]
builder env list [--profile preview] [--json]
builder secret set SENTRY_TOKEN # Hidden prompt; stored on the CI provider
printf %s "$TOKEN" | builder secret set SENTRY_TOKEN --profile production --value-stdin
builder secret unset SENTRY_TOKEN [--profile production]
builder secret list [--json] # Names and where they are stored, never values

# Simulator (free, needs a MOBAI_API_KEY secret)
builder ios share # Try the build on a simulator in the MobAI app
builder ios share --duration 1h # Keep it available longer while unused
Expand Down Expand Up @@ -352,7 +362,8 @@ builder ios build --profile preview
| `configuration` | Overrides the derived configuration: `Debug` for `development`, `Release` for every other distribution, `ios.configuration` for unsigned profiles |
| `scheme` | Overrides `ios.scheme` |
| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) |
| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them |
| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them. Overrides the top-level `env` key by key |
| `secrets` | Names of provider-held secrets this profile's builds also expose (see [Environment and secrets](#environment-and-secrets)) |

How a build's settings are resolved:

Expand All @@ -370,19 +381,71 @@ How a build's settings are resolved:

**`env` values are build-time configuration, not secrets.** They are stored in
`builder.json`, sent to the CI provider as plain workflow inputs, and visible in
the run's inputs and logs. Keep tokens and passwords in the provider's secrets
(`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets
guide](docs/provider-secrets.md)); the build reads those as environment
variables too. Names the runner owns are rejected: its own parameters (`SCHEME`,
the run's inputs and logs. Keep tokens and passwords in secrets (next section).
Names the runner owns are rejected: its own parameters (`SCHEME`,
`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`,
`HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes.

### Environment and secrets

Plain values go in `builder.json`: a top-level `env` every build gets, and a
profile's `env` on top of it, key by key. `builder env set|unset|list` edits
them with the same name checks a build applies.

Secret values never touch `builder.json` or Builder's servers (there are none):
`builder secret set NAME` reads the value from a hidden prompt (or stdin with
`--value-stdin`; never an argument, never printed), stores it on the CI
provider, and adds only the **name** to `"secrets"`:

| Provider | Where the value goes |
|----------|----------------------|
| GitHub | A repository Actions secret, sealed with the repository's public key |
| Codemagic | A secure variable in the app's `builder` variable group (the one the generated `codemagic.yaml` imports; created if missing) |
| Bitrise | A protected app secret, with "replace variables in inputs" and pull-request exposure off |

The provider is `--provider`, else the profile's `provider`, else the top-level
`provider`, else GitHub. Each build exports the names it lists as environment
variables before dependencies install, and fails by name when one has no value.

```json
{
"env": { "API_URL": "https://api.example.com" },
"secrets": ["SENTRY_TOKEN"],
"profiles": {
"production": { "distribution": "store", "secrets": ["STRIPE_KEY"] }
}
}
```

**Per-profile values** use a name suffix. `builder secret set SENTRY_TOKEN
--profile production` stores the value as `SENTRY_TOKEN__PRODUCTION` (the
profile name upper-cased, other characters as `_`) and lists `SENTRY_TOKEN`
under that profile. A build with the profile takes `NAME__<PROFILE>` when the
provider has it, else `NAME`, and exports it as `NAME` either way, so the app
reads one name. This works the same on all three providers; GitHub
Environments are not used.

Secret names are upper case letters, digits and single underscores (GitHub
stores names upper case, and `__` separates the profile suffix), and the
reserved names above apply, so a secret cannot shadow `IOS_CERTIFICATE_*`,
`MOBAI_API_KEY` or the runner's own variables. A name cannot be both a plain
`env` value and a secret.

On GitHub the `Resolve parameters` step receives `${{ toJSON(secrets) }}` (the
only way a workflow can read secrets whose names are not written in it),
exports just the listed names, and registers every line of each value with
`::add-mask::` first. `builder secret list` shows each listed name, where it is
stored and whether the provider has it. Secrets apply to `ios build`, not to
`ios share`.

Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow
file from this version of Builder, which declares a `profile` input; an older
committed workflow rejects the dispatch. Run `builder init` again to refresh
`.github/workflows/ios-build.yml` (or `builder init --provider ...` for
`runner.sh`) in a project set up earlier, then commit and push it to the
default branch.
default branch. The same goes for a top-level `env` or any `secrets`: an older
workflow ignores the secret list, so `ios build` refuses to dispatch while the
local `.github/workflows/ios-build.yml` predates it.

### MobAI Configuration

Expand Down
25 changes: 16 additions & 9 deletions cmd/builder/auth_prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,19 @@ import (
// Hidden terminal input avoids line-editor redraws and wrapping when pasting
// long API tokens. Pipes must explicitly opt in with --token-stdin.
func readProviderToken(ctx context.Context, input io.Reader, output io.Writer) (string, error) {
return readHidden(ctx, input, output, "API token", "--token-stdin")
}

// readHidden reads one value from the terminal without echoing it. what names
// the value in the prompt and errors; stdinFlag is the flag that takes it from
// a pipe instead.
func readHidden(ctx context.Context, input io.Reader, output io.Writer, what, stdinFlag string) (string, error) {
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("API token input canceled: %w", err)
return "", fmt.Errorf("%s input canceled: %w", what, err)
}
file, ok := input.(*os.File)
if !ok || !term.IsTerminal(int(file.Fd())) {
return "", fmt.Errorf("API token input requires a terminal; use --token-stdin for piped input")
return "", fmt.Errorf("%s input requires a terminal; use %s for piped input", what, stdinFlag)
}
fd := int(file.Fd())
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
Expand All @@ -35,9 +42,9 @@ func readProviderToken(ctx context.Context, input io.Reader, output io.Writer) (
_ = term.Restore(fd, state)
fmt.Fprintln(output)
}()
fmt.Fprint(output, "API token (input hidden; paste once, then press Enter): ")
fmt.Fprintf(output, "%s (input hidden; paste once, then press Enter): ", what)
type result struct {
token string
value string
err error
}
done := make(chan result, 1)
Expand All @@ -48,16 +55,16 @@ func readProviderToken(ctx context.Context, input io.Reader, output io.Writer) (
io.Reader
io.Writer
}{file, io.Discard}, "")
token, err := terminal.ReadPassword("")
done <- result{token, err}
value, err := terminal.ReadPassword("")
done <- result{value, err}
}()
select {
case <-ctx.Done():
return "", fmt.Errorf("API token input canceled: %w", ctx.Err())
return "", fmt.Errorf("%s input canceled: %w", what, ctx.Err())
case r := <-done:
if r.err != nil {
return "", fmt.Errorf("read API token: %w", r.err)
return "", fmt.Errorf("read %s: %w", what, r.err)
}
return r.token, nil
return r.value, nil
}
}
157 changes: 157 additions & 0 deletions cmd/builder/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package main

import (
"cmp"
"encoding/json"
"fmt"
"maps"
"slices"
"text/tabwriter"

"github.com/MobAI-App/ios-builder/internal/config"
"github.com/spf13/cobra"
)

var envCmd = &cobra.Command{
Use: "env",
Short: "Manage the plain environment variables builds get",
Long: `Plain (non-secret) environment variables live in builder.json: the top-level
"env" applies to every build, and profiles.<name>.env overrides it per key.
They are exported on the runner before dependencies install and the app builds.

Values are committed with builder.json, so never put a secret here; use
builder secret set for those.`,
}

var envSetCmd = &cobra.Command{
Use: "set NAME VALUE",
Short: "Set a variable for every build, or for one profile with --profile",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
profile, _ := cmd.Flags().GetString("profile")
cfg, err := loadConfig()
if err != nil {
return err
}
if err := cfg.SetEnv(profile, args[0], args[1]); err != nil {
return err
}
if err := config.NewManager().Save(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Set %s for %s in builder.json.\n", args[0], scopeName(profile))
return nil
},
}

var envUnsetCmd = &cobra.Command{
Use: "unset NAME",
Short: "Remove a variable from the top level, or from one profile with --profile",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
profile, _ := cmd.Flags().GetString("profile")
cfg, err := loadConfig()
if err != nil {
return err
}
removed, err := cfg.UnsetEnv(profile, args[0])
if err != nil {
return err
}
if !removed {
return fmt.Errorf("%s is not set for %s", args[0], scopeName(profile))
}
if err := config.NewManager().Save(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Removed %s from %s in builder.json.\n", args[0], scopeName(profile))
return nil
},
}

// envEntry is one row of env list. Profile is where the value is set; empty
// for the top level.
type envEntry struct {
Name string `json:"name"`
Value string `json:"value"`
Profile string `json:"profile,omitempty"`
}

var envListCmd = &cobra.Command{
Use: "list",
Short: "List the variables: everything in builder.json, or what one profile's builds get",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
profile, _ := cmd.Flags().GetString("profile")
asJSON, _ := cmd.Flags().GetBool("json")
cfg, err := loadConfig()
if err != nil {
return err
}
entries, err := envEntries(cfg, profile)
if err != nil {
return err
}
out := cmd.OutOrStdout()
if asJSON {
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
if len(entries) == 0 {
fmt.Fprintln(out, "No env variables in builder.json. Add one with: builder env set NAME VALUE")
return nil
}
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tVALUE\tSET FOR")
for _, e := range entries {
fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, e.Value, scopeName(e.Profile))
}
return w.Flush()
},
}

// envEntries lists every level's variables without a profile, and with one
// the variables its builds get, each with the level that sets it.
func envEntries(cfg *config.Config, profile string) ([]envEntry, error) {
entries := []envEntry{}
add := func(level string, env map[string]string, skip map[string]string) {
for _, k := range slices.Sorted(maps.Keys(env)) {
if _, ok := skip[k]; !ok {
entries = append(entries, envEntry{Name: k, Value: env[k], Profile: level})
}
}
}
if profile == "" {
add("", cfg.Env, nil)
for _, name := range cfg.ProfileNames() {
add(name, cfg.Profiles[name].Env, nil)
}
return entries, nil
}
if _, err := cfg.ResolveProfile(profile); err != nil {
return nil, err
}
own := cfg.Profiles[profile].Env
add(profile, own, nil)
add("", cfg.Env, own)
slices.SortFunc(entries, func(a, b envEntry) int { return cmp.Compare(a.Name, b.Name) })
return entries, nil
}

// scopeName describes a level of builder.json for messages.
func scopeName(profile string) string {
if profile == "" {
return "all builds"
}
return "profile " + profile
}

func init() {
for _, c := range []*cobra.Command{envSetCmd, envUnsetCmd, envListCmd} {
c.Flags().String("profile", "", "builder.json profile (default: the top level, which every build gets)")
}
envListCmd.Flags().Bool("json", false, "Print JSON")
envCmd.AddCommand(envSetCmd, envUnsetCmd, envListCmd)
rootCmd.AddCommand(envCmd)
}
Loading
Loading