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
36 changes: 29 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,31 @@ internal/
- **Build Profiles**: `profiles.<name>` overrides `ios.configuration`/`ios.scheme`/`provider` and adds
`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`
- **Profile Transport**: the `profile` dispatch input is one JSON object (`{"name","env","distribution","hooks"}`)
to stay under the ten-input limit and is sent only when a profile is selected or a hook is set, since an
older workflow rejects unknown inputs (`triggerError`). `runner.sh` reads `BUILD_ENV`, `DISTRIBUTION`,
`BUILD_PROFILE` and `BUILD_HOOKS`
- **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
- **Build Hooks**: `hooks.preBuild`/`hooks.postBuild` (top level, overridden per field by a profile's;
`config.resolveHooks`, whitespace-only = absent) run on the runner only, from the checkout root as
`bash -eo pipefail -c`, with the profile env plus `BUILDER_{HOOK,BUILD_ID,PROFILE,CONFIGURATION,
DISTRIBUTION,IOS_PATH,PROJECT_TYPE,BUILD_NUMBER}` and `BUILDER_IPA` for postBuild. Transport: the
`profile` dispatch input gains a `hooks` key (`ProfileInput` sends `{"name":"",...,"hooks":…}` for
top-level hooks without a profile), a tag build merges them from builder.json in jq, and runner.sh
gets `BUILD_HOOKS` (JSON) and `BUILD_PROFILE` (both reserved). The Resolve step exports them as
`BUILDER_HOOK_PRE_BUILD`/`BUILDER_HOOK_POST_BUILD` through the same base64 heredoc as the env.
A command must be a string (null = absent): the merge and the export keep only non-blank strings,
so the Resolve step and `check_build_hooks` fail a number or list by name first, as the CLI's
`json.Unmarshal` into `Hooks` would. `run_hook` runs from `BUILDER_WORKSPACE` (runner.sh's
checkout), else `GITHUB_WORKSPACE`, and fails when both are empty, since `cd ""` succeeds silently.
Placement: preBuild after `pod install`/project selection and before `apply_build_number`,
signing settings and the archive (`Build IPA` step; top of `build_ipa` in runner.sh, not `prepare`,
which the simulator mode shares); postBuild after `Created … IPA`, before the artifact upload. The
`run_hook` function lives in a `# >>> build hooks` … `# <<< build hooks` block that is byte-identical
in `ios-build.yml` and `runner.sh` (`TestBuildHooksBlockIdentical`; `TestRunHook` executes it) and
only uses `fail` plus variables both runners set. `ios share`/`runner.sh simulator` never run hooks.
- **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 @@ -436,10 +455,12 @@ internal/
"github": { "owner": "username", "repo": "my-ios-app" },
"ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" },
"defaultProfile": "development",
"hooks": { "preBuild": "./scripts/prebuild.sh", "postBuild": "./scripts/upload-symbols.sh" },
"profiles": {
"development": { "distribution": "development" },
"preview": { "distribution": "internal", "env": { "API_URL": "https://staging.example.com" } },
"production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" }
"production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic",
"hooks": { "postBuild": "./scripts/notify.sh" } }
}
}
```
Expand All @@ -451,7 +472,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`, `hooks` (`preBuild`/`postBuild`, each overriding the
top-level one when non-blank). `runner`/`submit` are planned on `config.Profile`, not read.

## Workflow Features

Expand All @@ -470,8 +492,8 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
`use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the
tagged tree, applying `defaultProfile` (a tag cannot pick a profile per run; `build_number` is
always empty there); every later step reads `steps.params.outputs.*`, never `inputs.*`. The same
step exports the profile's `env` to `$GITHUB_ENV` and outputs `profile`, `distribution` and
`signing_set`. The job deletes
step exports the profile's `env` and the merged hooks (`BUILDER_HOOK_PRE_BUILD`/`_POST_BUILD`) to
`$GITHUB_ENV` and outputs `profile`, `distribution` and `signing_set`. The job deletes
the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an
unfiltered `on: push` also fires on these tags.
- Runs on `macos-latest`
Expand Down
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@ machine-readable output and never prompts, so agents and CI jobs can drive them.
"development": { "distribution": "development" },
"store": { "distribution": "store" }
},
"hooks": {
"preBuild": "./scripts/prebuild.sh",
"postBuild": "./scripts/upload-symbols.sh"
},
"mobai": {
"url": "http://localhost:8686",
"device_id": ""
Expand Down Expand Up @@ -374,7 +378,7 @@ 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`,
`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing secrets, `PATH`,
`CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, `BUILD_HOOKS`, ...), the signing secrets, `PATH`,
`HOME`, `DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`, `CM_`, `BITRISE_`, `BUILDER_` prefixes.

Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow
Expand All @@ -384,6 +388,48 @@ committed workflow rejects the dispatch. Run `builder init` again to refresh
`runner.sh`) in a project set up earlier, then commit and push it to the
default branch.

### Build hooks

Hooks are shell commands the runner executes around the build — to generate
code, fetch a config file, upload dSYMs, or notify a chat:

```json
{
"hooks": {
"preBuild": "./scripts/prebuild.sh",
"postBuild": "./scripts/upload-symbols.sh"
},
"profiles": {
"store": { "distribution": "store", "hooks": { "postBuild": "./scripts/notify.sh" } }
}
}
```

- `preBuild` runs once every dependency is in place (`flutter pub get`,
`npm install`, `expo prebuild`, XcodeGen, `pod install`) and before the
build number is applied, the signing settings are written and the archive
starts. `postBuild` runs once the IPA exists and before it is uploaded as an
artifact; `BUILDER_IPA` is its absolute path.
- Hooks run on the runner, never on your machine, from the repository root of
the checkout, as `bash -eo pipefail -c "<command>"`: a multi-line command
stops at its first failing line, and a failing hook fails the build (its
exit code is in the job log, under a foldable group on GitHub).
- Each hook sees the profile's `env` plus `BUILDER_HOOK` (`preBuild` or
`postBuild`), `BUILDER_BUILD_ID`, `BUILDER_PROFILE` (empty without a
profile), `BUILDER_CONFIGURATION`, `BUILDER_DISTRIBUTION` (canonical, so
`internal` arrives as `ad-hoc`; empty for unsigned builds and for the legacy
`ios.signing` path), `BUILDER_IOS_PATH`, `BUILDER_PROJECT_TYPE` (`flutter`,
`expo`, `reactnative`, `kmp` or `native`) and `BUILDER_BUILD_NUMBER` (what
`ios release` sent, else empty).
- Top-level `hooks` apply to every build. A profile's `hooks` override them
field by field: a profile's non-empty `preBuild` replaces the top-level
`preBuild`, an absent or blank one keeps it. Whitespace-only is absent. Each
command is one string; a number or a list fails the build by name.
- Hooks are for `ios build` (and `ios release`) only; `ios share` and the
simulator builds never run them. Like `env`, the commands are stored in
`builder.json` and sent to the provider as plain workflow inputs, so keep
secrets in the provider's secrets and read them from the hook.

### MobAI Configuration

| Field | Description | Default |
Expand Down
55 changes: 50 additions & 5 deletions internal/build/inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,27 +144,72 @@ func TestRemoteInputsMapping(t *testing.T) {

s, _, _ = c.settings("preview", "", false)
got = c.inputs("abcdef12", "ref", "sha", s)
if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" {
if got["USE_SIGNING"] != "true" || got["SCHEME"] != "AppPreview" || got["CONFIGURATION"] != "Release" || got["DISTRIBUTION"] != "ad-hoc" || got["BUILD_PROFILE"] != "preview" {
t.Fatalf("profile mapping: %v", got)
}
var env map[string]string
if err := json.Unmarshal([]byte(got["BUILD_ENV"]), &env); err != nil || env["API_URL"] != "https://staging.example.com" {
t.Fatalf("BUILD_ENV: %q %v", got["BUILD_ENV"], err)
}
// The hooks are a build-only variable, added by buildRemote; inputs itself
// never sends them, since a simulator build shares it.
c.config.Hooks = &config.Hooks{PreBuild: "./scripts/prebuild.sh"}
s, _, _ = c.settings("preview", "", false)
if got = c.inputs("abcdef12", "ref", "sha", s); got["BUILD_HOOKS"] != "" {
t.Fatalf("shared inputs carry the hooks: %v", got)
}
}

func TestGitHubInputsCarryHooks(t *testing.T) {
cfg := profiledConfig()
cfg.Hooks = &config.Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo top"}
p := cfg.Profiles["preview"]
p.Hooks = &config.Hooks{PostBuild: "./scripts/notify.sh"}
cfg.Profiles["preview"] = p
c := NewCoordinatorWithOutput(cfg, nil, io.Discard)
var profile struct {
Name string
Hooks map[string]string
}
// Top-level hooks travel without a profile, under an empty name.
s, _, _ := c.settings("", "", false)
got := c.buildInputs("abcdef12", "ref", s, "")
if err := json.Unmarshal([]byte(got["profile"]), &profile); err != nil || profile.Name != "" || profile.Hooks["preBuild"] != "./scripts/prebuild.sh" || profile.Hooks["postBuild"] != "echo top" {
t.Fatalf("profile input without a profile: %q %+v %v", got["profile"], profile, err)
}
s, _, _ = c.settings("preview", "", false)
got = c.buildInputs("abcdef12", "ref", s, "")
if err := json.Unmarshal([]byte(got["profile"]), &profile); err != nil || profile.Name != "preview" || profile.Hooks["preBuild"] != "./scripts/prebuild.sh" || profile.Hooks["postBuild"] != "./scripts/notify.sh" {
t.Fatalf("profile input with a profile: %q %+v %v", got["profile"], profile, err)
}
// The simulator workflow declares no profile input, hooks or not.
if _, ok := c.workflowInputs("abcdef12", "ref", s)["profile"]; ok {
t.Fatal("hooks reached the share inputs")
}
}

func TestSettingsPrinted(t *testing.T) {
var out bytes.Buffer
p := NewProgress(&out)
p.Start("abcdef12")
p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github")
for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} {
p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc", Hooks: config.Hooks{PreBuild: "./scripts/prebuild.sh", PostBuild: "echo done"}}, "github")
for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc", "Hooks: preBuild, postBuild"} {
if !strings.Contains(out.String(), want) {
t.Errorf("missing %q in:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") {
t.Fatal("env values should not be printed, only names")
if strings.Contains(out.String(), "staging") || strings.Contains(out.String(), "=1") || strings.Contains(out.String(), "prebuild.sh") {
t.Fatal("env values and hook commands should not be printed, only names")
}
out.Reset()
p.Settings(&config.BuildSettings{Hooks: config.Hooks{PostBuild: "echo done"}}, "github")
if !strings.Contains(out.String(), "Hooks: postBuild\n") {
t.Errorf("only the hooks set are named:\n%s", out.String())
}
out.Reset()
p.Settings(&config.BuildSettings{}, "github")
if strings.Contains(out.String(), "Hooks:") {
t.Errorf("no hooks, no line:\n%s", out.String())
}

// Signed without a distribution is the legacy path with the unsuffixed
Expand Down
3 changes: 3 additions & 0 deletions internal/build/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ func (p *Progress) Settings(s *config.BuildSettings, provider string) {
if s.Distribution != "" {
fmt.Fprintf(p.writer, " Distribution: %s\n", s.Distribution)
}
if names := s.Hooks.Names(); len(names) > 0 {
fmt.Fprintf(p.writer, " Hooks: %s\n", strings.Join(names, ", "))
}
fmt.Fprintf(p.writer, "\n")
}

Expand Down
12 changes: 10 additions & 2 deletions internal/build/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ func (c *Coordinator) remote(override string) (ci.Provider, config.CIConfig, err
}

// inputs are the variables runner.sh reads on Codemagic and Bitrise. The
// profile's env travels as one JSON object in BUILD_ENV, and DISTRIBUTION
// selects the signing set; both are only set when the profile provides them.
// profile's env travels as one JSON object in BUILD_ENV, DISTRIBUTION selects
// the signing set and BUILD_PROFILE names the profile for the hooks; each is
// only set when the profile provides it.
func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings) map[string]string {
v := map[string]string{"BUILD_ID": buildID, "SNAPSHOT_REF": ref, "SNAPSHOT_SHA": sha,
"IOS_PATH": c.config.IOS.Path, "SCHEME": s.Scheme,
Expand All @@ -88,6 +89,9 @@ func (c *Coordinator) inputs(buildID, ref, sha string, s *config.BuildSettings)
if s.Distribution != "" {
v["DISTRIBUTION"] = s.Distribution
}
if s.Profile != "" {
v["BUILD_PROFILE"] = s.Profile
}
return v
}

Expand Down Expand Up @@ -144,6 +148,10 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *co
if opts.BuildNumber != "" {
v["BUILDER_BUILD_NUMBER"] = opts.BuildNumber
}
// The hooks are for IPA builds only; a simulator build never runs them.
if hooks := s.HooksJSON(); hooks != "" {
v["BUILD_HOOKS"] = hooks
}
c.progress.Update(PhaseTriggering, "Triggering "+p.Name()+" build...")
run, err := p.Start(ctx, ci.Request{Workflow: cfgCI.BuildWorkflow, Variables: v})
if err != nil {
Expand Down
10 changes: 9 additions & 1 deletion internal/build/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ func TestRemoteSnapshotLifecycle(t *testing.T) {
t.Fatal(err)
}
p := &fakeProvider{startErr: tt.startErr, cancelErr: tt.cancelErr, failPolling: tt.failPolling, payload: buf.Bytes(), statuses: []ci.Status{{Done: true, Success: true, State: "finished", Artifacts: []ci.Artifact{{Name: "app.ipa"}}}}}
cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, Codemagic: config.CIConfig{AppID: "app", Branch: "main"}}
cfg := &config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}, Codemagic: config.CIConfig{AppID: "app", Branch: "main"},
Hooks: &config.Hooks{PostBuild: "echo done"}}
c := NewCoordinatorWithProvider(cfg, p, io.Discard)
if tt.name == "artifact failure after completion" {
c.provider = &failedArtifactProvider{p}
Expand Down Expand Up @@ -162,13 +163,20 @@ func TestRemoteSnapshotLifecycle(t *testing.T) {
var result *BuildResult
if strings.HasSuffix(tt.name, "share") {
_, err = c.Share(context.Background(), ShareOptions{})
// A simulator build never runs the hooks, so it is not told about them.
if v := p.request.Variables; v["BUILD_HOOKS"] != "" {
t.Errorf("hooks sent to a simulator build: %v", v)
}
} else {
result, err = c.Build(context.Background(), &BuildOptions{OutputDir: filepath.Join(dir, "dist"), BuildNumber: "1.2.3+42"})
// Codemagic defines BUILD_NUMBER itself; the stamp must travel
// under Builder's own name.
if v := p.request.Variables; v["BUILDER_BUILD_NUMBER"] != "1.2.3+42" || v["BUILD_NUMBER"] != "" {
t.Errorf("build number variables: %v", v)
}
if v := p.request.Variables; v["BUILD_HOOKS"] != `{"postBuild":"echo done"}` {
t.Errorf("hooks variable: %v", v)
}
}
if tt.name == "success" || tt.name == "transient poll recovers" {
if err != nil || result == nil {
Expand Down
Loading
Loading