skip to content

Your fastlane release lane fails halfway on CI and leaves the runner dirty — how do you restructure the Fastfile?

level: seniorimportance: should knowfreq 41%

answer

  1. failure skips the success hook
  2. cleanup lives with the failure hook
  3. one lane per external effect
  4. lane_context dies with the process
  5. retry needs an explicit path parameter

basics

~20 s

Move cleanup out of after_all, which is skipped on failure, into the error hook. Split the long lane into re-runnable lanes that take the artifact path as a parameter, and assert the run's prerequisites in before_all so failures happen early and cheaply.

solid answer

~40 s

Three changes, in order. First, **cleanup belongs in `error`**: `after_all` runs only when the run succeeded, so cleanup written there never executes on exactly the runs that leave a shared runner dirty. Put the work in a private lane and call it from both `error` and `after_all`. Second, **split the monolith** — one lane per externally visible effect, so a failed upload can be retried without redoing the iOS archive that `build_app` already produced. Third, **make each lane re-runnable**: `lane_context` lives in the fastlane process and is empty on a fresh invocation, so a retry lane must accept the artifact path as a parameter rather than expecting `SharedValues::IPA_OUTPUT_PATH` to survive. Then move prerequisites into `before_all` so a missing credential fails in seconds instead of after a long archive.

code

ruby · 13 lines
ruby
platform :ios do
  lane :archive do
    build_app(scheme: "SprayDiary", output_directory: "artifacts")
  end

  lane :ship_beta do |options|
    upload_to_testflight(ipa: options.fetch(:ipa, "artifacts/SprayDiary.ipa"))
  end
end

error do |lane, exception|
  puts "spray-diary: lane #{lane} failed - #{exception.message}"
end

go deeper

for a junior

Be ready to state the one fact this scenario turns on: after_all does not run when a lane fails, so cleanup written only there is skipped on exactly the runs that needed it.

for a middle

Explain the failure sequence — action raises, lane aborts, error runs, process exits non-zero — and why lane_context being process-local forces a retry lane to take the artifact path as a parameter.

for a senior

Show the restructure: one lane per externally visible effect, defaults that make a retry work standalone, cheap assertions in before_all, and cleanup factored into a private lane both hooks call.

for a principal

Own where the seam sits. Argue which recovery responsibilities belong to the repository's lanes and which to the pipeline's retry policy, and how you keep that convention consistent across many app teams.

## What actually happens when a lane fails The mechanics decide the design, so state them first. An action raises; the lane aborts; the remaining actions never run; any lane that called it aborts too. fastlane then runs the `error` hook if one is declared, and the process exits non-zero. Two consequences follow, and both are why the runner is dirty: - **`after_all` is skipped.** It is a success hook, not a `finally`, so cleanup written there runs on every run except the ones that needed it. - **Whatever the lane created is still on disk.** A staging directory, a partially written archive, a half-populated output folder — a shared CI runner keeps all of it for the next job. ## Move cleanup to the path that runs Because no hook covers both outcomes, the recoverable shape is to write the cleanup once in a private lane and call it from both hooks: ```ruby private_lane :clean_spray_diary_workspace do FileUtils.rm_rf("artifacts") end after_all do |lane| clean_spray_diary_workspace end error do |lane, exception| clean_spray_diary_workspace puts "spray-diary: lane #{lane} failed - #{exception.message}" end ``` Keep the cleanup itself defensive. Work that can raise inside `after_all` turns a release that actually shipped into a red run, and cleanup that raises inside `error` hides the original exception behind a second one. ## Split one long lane into re-runnable lanes A single `release` lane that signs, archives, uploads and promotes has exactly one retry granularity: everything. That is expensive for the orchard spray-diary app's iOS path, where the archive is the slow part, and it is risky on the upload, where a blind retry may re-submit something a store already accepted. The rule that works is **one lane per externally visible effect**: - `archive` produces the iOS artifact with `build_app` — the canonical name of the action usually called `gym`. - `ship_beta` uploads that artifact with `upload_to_testflight`, the canonical name behind `pilot`, to Apple's beta service. - `promote` handles the Google Play side with `upload_to_play_store`, the canonical name behind `supply`. - Shared preparation stays in private lanes called by each of them. - One thin public lane calls the others in order for the ordinary end-to-end run. Now a failed upload is retried by invoking one lane, and the pipeline can express the release as steps that map to real effects rather than as one opaque command. ## Carry the artifact explicitly This is the detail that separates a design that looks recoverable from one that is. Within a single invocation, actions pass values through `lane_context` — `build_app` publishes the archived iOS artifact path as `SharedValues::IPA_OUTPUT_PATH`, and an upload action later in the same run reads it. But `lane_context` lives in the running fastlane process. A retry is a **new process**, and it starts with that context empty. So a re-runnable upload lane takes the path as a parameter with a stable default: ```ruby lane :ship_beta do |options| upload_to_testflight(ipa: options.fetch(:ipa, "artifacts/SprayDiary.ipa")) end ``` Write the iOS archive to a fixed `output_directory` so that default is meaningful, and have the pipeline preserve that directory between steps. The lane then works three ways: inside the full run, as a retry using the default path, and as a manual invocation with an explicit path. ## Fail early instead of halfway Half the pain of a mid-release failure is that it happened after the expensive part. Move the cheap checks to the front: 1. Assert in `before_all` that the environment the run needs is present, so a missing credential fails in seconds rather than after a long archive. 2. Validate lane parameters at the top of the lane and fail with a message naming the parameter. 3. Check preconditions that must hold per lane in `before_each`, keeping them cheap because they repeat. 4. Order actions so anything verifiable without side effects happens before anything that publishes. ## What the error hook should actually say The `error` block receives the lane and the exception, so use both. A message naming the failing lane, the exception message and the run identifier stamped in `before_all` turns a red pipeline into a diagnosis. Two boundaries are worth stating in an interview: the hook does **not** rescue — the process still exits non-zero, which is correct, because the CI step must fail — and retry policy across runs is the pipeline's decision, not the Fastfile's. The Fastfile's job is to make each lane safe to invoke again.

  • Why can a retry not simply read the built artifact path out of lane_context?
    Because `lane_context` is state in the running fastlane process. Keys such as `SharedValues::IPA_OUTPUT_PATH`, published by `build_app` for the iOS archive, are readable by later actions in the same invocation only. A retry is a new process with an empty context, so the path must arrive as a lane parameter, or be derivable from a fixed output directory the pipeline preserved.
  • Which parts of this recovery story belong in the Fastfile and which in the pipeline?
    The Fastfile owns lane granularity, parameters with sensible defaults, cleanup in the failure hook and early assertions — everything that makes a lane safe to invoke again. The pipeline owns whether and when to retry, how many times, which steps preserve the artifact directory between them, and how failures reach people. Splitting them that way keeps both readable.

saying these in an interview costs you the question

  • Puts required cleanup only in after_all
  • Believes lane_context survives into a second fastlane invocation
  • Retries the whole release lane instead of the failed step
  • Assumes the error hook makes the run exit zero
  • Keeps one enormous lane because CI invokes one name
  • Leaves the expensive archive before any credential check