Build one component instead of every one with a command

Pressing Update on one client of a multi-client project ran every
component's build to get the one that was actually asked for -- cheap
while a project had one APK, expensive the moment it had two and one of
them was slow (an ARM cross-compile, say). /prepare and /build now take
?component= to restrict a run to one named component; absent still means
the whole project, which is what Pull & Build, the project-row Rebuild,
and every single-component project keep doing. There is deliberately no
component-scoped Rebuild -- forcing one component's build without
touching the rest happens by pressing Update on it, which now runs
/prepare scoped to that component.

Verified against a live server driving a scratch two-Apk project:
building one component leaves the other's marker untouched, an unknown
?component= answers 404 naming the project and the component, and naming
none still builds both.

That verification surfaced a second, sharper bug the scoping change had
not caused but did make newly visible: component_is_stale's "never built
at all" check still asked find_apks of the whole project root, left
behind when per-component discovery (c4e19c2) moved everywhere else to
each component's own directory. A project with two Apk components has
one's output sitting under the root-anchored patterns too, so the moment
either component had ever been built, the whole project read as
"something is built here" -- and the other, never built, silently stopped
being offered its own first build. /prepare saw a component with a
command and no output and declared it current. Fixed by scoping the same
check to the component's own directory, guarded to Apk components only:
a Server never has an APK to find under its directory by definition, and
asking would have reported every server "never built" forever, which
broke two existing tests before the guard was added. Component::dir is
now the one definition of what a component's directory is, used by the
build command, the staleness check, and discovery alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet 5 committed 2026-09-01 00:28:36 -04:00
1 parent c4e19c2e2b
commit 7eaf79370e
7 files changed
+326 -49

No files matched your search

+48 -2
View File
@@ -14,7 +14,9 @@
//! POST /apps/{key}/pull fetch, fast-forward, and build
//! ?force=true resets onto the upstream
//! POST /apps/{key}/prepare run the on-demand build step, if any
//! ?component= restricts it to one
//! POST /apps/{key}/build run it whether or not it looks stale
//! ?component= restricts it to one
//! POST /apps/{key}/approve accept the build step it asks for
//! POST /apps/{key}/recheck ask that one project's remote and
//! services again
@@ -1198,6 +1200,46 @@ fn lookup(state: &AppState, key: Option<UrlPath<String>>) -> Result<Arc<AppEntry
state.entry(&key).ok_or(ApiError::UnknownApp(key))
}
/// Which component a `/prepare` or `/build` request is about, when it is
/// about one rather than the whole project.
///
/// Absent means every component with a build command, which is what both
/// routes have always done and what a project with one component (still
/// nearly all of them) never has reason to change. A project with more
/// than one -- two independent Android clients sharing a checkout, say --
/// otherwise pays for every component's build to get one of them: this is
/// how the phone opts out of that, by naming the one it actually wants
/// before it is willing to wait on the others too.
#[derive(Deserialize)]
struct ComponentQuery {
component: Option<String>,
}
/// Resolves a `?component=` against the project's own components, or says
/// why it could not -- the one place both build routes turn a name from
/// the phone into something [`crate::build_state::BuildState`] accepts, so
/// they cannot disagree about what an unknown name means.
fn named_component<'a>(
entry: &'a AppEntry,
named: Option<&'a str>,
) -> Result<Option<&'a str>, ApiError> {
match named {
Some(name)
if entry
.components
.iter()
.any(|component| component.name() == name) =>
{
Ok(Some(name))
}
Some(name) => Err(ApiError::UnknownComponent(
entry.label.clone(),
name.to_string(),
)),
None => Ok(None),
}
}
/// The callback a build reports each finished component through.
///
/// Made here because this is where both halves are in scope: the app list
@@ -1213,6 +1255,7 @@ fn records_builds(state: &Arc<AppState>, key: &str) -> crate::build_state::Recor
async fn build_prepare(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<ComponentQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
// Checked here rather than trusted from the entry's build state: the
@@ -1225,11 +1268,12 @@ async fn build_prepare(
entry.label,
)));
}
let component = named_component(&entry, query.component.as_deref())?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
build.trigger_if_needed(records_builds(&state, &entry.key));
build.trigger_if_needed(component, records_builds(&state, &entry.key));
Ok(Json(build.status()))
}
@@ -1244,6 +1288,7 @@ async fn build_prepare(
async fn build_now(
State(state): State<Arc<AppState>>,
key: UrlPath<String>,
Query(query): Query<ComponentQuery>,
) -> Result<Json<BuildStatus>, ApiError> {
let entry = lookup(&state, Some(key))?;
if entry.pending_declaration().is_some() {
@@ -1253,11 +1298,12 @@ async fn build_now(
entry.label,
)));
}
let component = named_component(&entry, query.component.as_deref())?;
let build = entry
.build
.as_ref()
.ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?;
build.build_now(records_builds(&state, &entry.key));
build.build_now(component, records_builds(&state, &entry.key));
Ok(Json(build.status()))
}