From e8dc57b22348f979b0d06971a322d7416eb7ff52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 3 Sep 2026 10:22:25 +0200 Subject: [PATCH] ci(android): wait for the producer's platform job and keep the fallback build alive Classify the fixture producer by its platform job (Android Release / iOS Release) instead of the run's aggregate status: the run flickers to "queued" in the gap between its fingerprint job and its platform job, which made the Android consumer bail into an inline build almost instantly (run 33550746596). Both queued and in_progress on the platform job now mean keep waiting. Raise android.yml's wait-for-artifact-seconds to 1800s (matching the iOS workflow's own budget) so a real wait can play out now that the premature "queued" bail is gone. Raise the Android Gradle daemon heap (org.gradle.jvmargs=-Xmx4g) for both the producer build and the inline fallback build: the producer's own Android Release job OOM'd in :app:compileReleaseArtProfile (run 33728318738), and both prior inline fallbacks OOM'd in :app:mergeDexRelease. --- .github/actions/setup-fixture-app/action.yml | 1 + .../setup-fixture-app/fetch-artifact.sh | 8 +- .../setup-fixture-app/trusted-artifact.mjs | 39 ++++++-- .github/workflows/android.yml | 2 +- .github/workflows/test-app-build-cache.yml | 1 + test/ci/trusted-fixture-artifact.test.mjs | 98 ++++++++++++++++++- .../setup-fixture-app-fallback-smoke.sh | 41 +++++++- 7 files changed, 168 insertions(+), 22 deletions(-) diff --git a/.github/actions/setup-fixture-app/action.yml b/.github/actions/setup-fixture-app/action.yml index ec3df21777..6f00f163d2 100644 --- a/.github/actions/setup-fixture-app/action.yml +++ b/.github/actions/setup-fixture-app/action.yml @@ -172,6 +172,7 @@ runs: run: | set -euo pipefail pnpm --dir examples/test-app exec expo prebuild --platform android --no-install + echo "org.gradle.jvmargs=-Xmx4g" >> examples/test-app/android/gradle.properties ( cd examples/test-app/android ./gradlew :app:assembleRelease diff --git a/.github/actions/setup-fixture-app/fetch-artifact.sh b/.github/actions/setup-fixture-app/fetch-artifact.sh index 6ecc66f535..9d27a32ba4 100644 --- a/.github/actions/setup-fixture-app/fetch-artifact.sh +++ b/.github/actions/setup-fixture-app/fetch-artifact.sh @@ -33,7 +33,7 @@ query_artifact() { node "$TRUSTED_ARTIFACT" find "$REPOSITORY" "$NAME" "$EXPECTED_HEAD_SHA" 2>/dev/null } query_producer_state() { - node "$TRUSTED_ARTIFACT" producer-state "$REPOSITORY" "$EXPECTED_HEAD_SHA" 2>/dev/null + node "$TRUSTED_ARTIFACT" producer-state "$REPOSITORY" "$EXPECTED_HEAD_SHA" "$PLATFORM" 2>/dev/null } if ! ART_ID="$(query_artifact)"; then echo "::warning::Could not query the build cache for $NAME; building inline." @@ -50,10 +50,6 @@ if [ -z "$ART_ID" ] && [ "$WAIT_SECONDS" -gt 0 ]; then break fi case "$PRODUCER_STATE" in - queued) - echo "Fixture producer is queued; building inline instead of waiting for a native runner." - break - ;; failed) echo "Fixture producer failed; building inline." break @@ -69,7 +65,7 @@ if [ -z "$ART_ID" ] && [ "$WAIT_SECONDS" -gt 0 ]; then break fi ;; - in_progress) + queued | in_progress) MISSING_PRODUCER_POLLS=0 ;; *) diff --git a/.github/actions/setup-fixture-app/trusted-artifact.mjs b/.github/actions/setup-fixture-app/trusted-artifact.mjs index 5221799c75..c48fe3326e 100644 --- a/.github/actions/setup-fixture-app/trusted-artifact.mjs +++ b/.github/actions/setup-fixture-app/trusted-artifact.mjs @@ -4,6 +4,8 @@ import { pathToFileURL } from 'node:url'; const TRUSTED_WORKFLOW_PATH = '.github/workflows/test-app-build-cache.yml'; +const PLATFORM_JOB_NAMES = { android: 'Android Release', ios: 'iOS Release' }; + export async function findTrustedArtifact({ artifacts, expectedHeadSha, repository, loadRun }) { for (const artifact of artifacts) { if (!artifactBelongsToRepository(artifact, repository.id)) continue; @@ -13,12 +15,23 @@ export async function findTrustedArtifact({ artifacts, expectedHeadSha, reposito return undefined; } -export function classifyProducerState(runs, { expectedHeadSha, repository }) { - const run = runs.find((candidate) => - isExpectedProducerRun(candidate, expectedHeadSha, repository.id), - ); +export function classifyProducerState(runs, jobs, { expectedHeadSha, repository, platform }) { + const run = findExpectedProducerRun(runs, expectedHeadSha, repository.id); if (!run) return 'absent'; - return classifyRunState(run); + const jobName = requirePlatformJobName(platform); + const platformJob = jobs.find((candidate) => candidate.name === jobName); + if (platformJob) return classifyRunState(platformJob); + return run.status === 'completed' ? 'failed' : 'queued'; +} + +function findExpectedProducerRun(runs, expectedHeadSha, repositoryId) { + return runs.find((candidate) => isExpectedProducerRun(candidate, expectedHeadSha, repositoryId)); +} + +function requirePlatformJobName(platform) { + const jobName = PLATFORM_JOB_NAMES[platform]; + if (!jobName) throw new Error(`unknown fixture platform: ${platform}`); + return jobName; } function artifactBelongsToRepository(artifact, repositoryId) { @@ -113,14 +126,24 @@ async function runFindCommand(repositoryName, repository, commandArgs) { } async function runProducerStateCommand(repositoryName, repository, commandArgs) { - const [expectedHeadSha] = commandArgs; - requireArguments([expectedHeadSha], 'expected head SHA'); + const [expectedHeadSha, platform] = commandArgs; + requireArguments([expectedHeadSha, platform], 'expected head SHA and platform'); const result = await requestJson( `repos/${repositoryName}/actions/workflows/test-app-build-cache.yml/runs?head_sha=${encodeURIComponent(expectedHeadSha)}&per_page=20`, ); + const runs = result.workflow_runs || []; + const run = findExpectedProducerRun(runs, expectedHeadSha, repository.id); + const jobs = run ? await loadRunJobs(repositoryName, run.id) : []; process.stdout.write( - classifyProducerState(result.workflow_runs || [], { expectedHeadSha, repository }), + classifyProducerState(runs, jobs, { expectedHeadSha, repository, platform }), + ); +} + +async function loadRunJobs(repositoryName, runId) { + const result = await requestJson( + `repos/${repositoryName}/actions/runs/${runId}/jobs?per_page=100`, ); + return result.jobs || []; } function requireArguments(values, description) { diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 32ae294165..5b375ede7f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -52,7 +52,7 @@ jobs: with: platform: android install: 'false' - wait-for-artifact-seconds: '600' + wait-for-artifact-seconds: '1800' - name: Report fixture cache source run: echo "Android fixture APK source=${{ steps.fixture-app.outputs.source }}" diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index f675985131..ba8f372cb7 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -182,6 +182,7 @@ jobs: run: | set -euo pipefail pnpm --dir examples/test-app exec expo prebuild --platform android --no-install + echo "org.gradle.jvmargs=-Xmx4g" >> examples/test-app/android/gradle.properties ( cd examples/test-app/android ./gradlew :app:assembleRelease diff --git a/test/ci/trusted-fixture-artifact.test.mjs b/test/ci/trusted-fixture-artifact.test.mjs index a60c180e69..d83c0e6a0c 100644 --- a/test/ci/trusted-fixture-artifact.test.mjs +++ b/test/ci/trusted-fixture-artifact.test.mjs @@ -33,6 +33,9 @@ test('producer, consumers, upload, and concurrency use the canonical platform-sc (step) => step.name === 'Build the Android Release APK (fallback)', ); const fingerprintStep = workflow.jobs.fingerprint.steps.find((step) => step.id === 'fingerprint'); + const producerAndroidBuildStep = workflow.jobs.release.steps.find( + (step) => step.name === 'Build the Android Release apk', + ); const uploadStep = workflow.jobs.release.steps.find((step) => step.uses?.startsWith('actions/upload-artifact@'), ); @@ -51,6 +54,9 @@ test('producer, consumers, upload, and concurrency use the canonical platform-sc assert.match(androidFallbackStep.if, /inputs\.platform == 'android'/); assert.match(androidFallbackStep.run, /expo prebuild --platform android --no-install/); assert.match(androidFallbackStep.run, /:app:assembleRelease/); + assert.match(androidFallbackStep.run, /org\.gradle\.jvmargs=-Xmx4g/); + assert.match(producerAndroidBuildStep.run, /org\.gradle\.jvmargs=-Xmx4g/); + assert.match(fetchArtifact, /producer-state "\$REPOSITORY" "\$EXPECTED_HEAD_SHA" "\$PLATFORM"/); assert.match( fingerprintStep.run, /ARTIFACT_NAME_RESOLVER="\.github\/actions\/setup-fixture-app\/resolve-artifact-name\.sh"/, @@ -94,7 +100,7 @@ test('Android smoke consumes the restored APK through catalog fixture E2E', (t) .includes(replayEvidence.invocation), `missing declared Android replay invocation: ${replayEvidence.invocation}`, ); - assert.equal(restoreStep.with['wait-for-artifact-seconds'], '600'); + assert.equal(restoreStep.with['wait-for-artifact-seconds'], '1800'); assert.match(sourceStep.run, /steps\.fixture-app\.outputs\.source/); assert.match(assertion, /metadata\.backend !== 'android-helper'/); assert.match(assertion, /metadata\.helperVersion !== packageVersion/); @@ -573,28 +579,110 @@ test('default-branch producer artifacts remain reusable across native-equivalent test('producer state is derived only from the trusted exact-head workflow run', () => { assert.equal( - classifyProducerState([], { expectedHeadSha: 'current-head', repository }), + classifyProducerState([], [], { + expectedHeadSha: 'current-head', + repository, + platform: 'android', + }), 'absent', ); assert.equal( - classifyProducerState([{ ...trustedRun, status: 'queued' }], { + classifyProducerState( + [trustedRun], + [{ conclusion: null, name: 'Android Release', status: 'in_progress' }], + { expectedHeadSha: 'current-head', repository, platform: 'android' }, + ), + 'in_progress', + ); + assert.equal( + classifyProducerState( + [trustedRun], + [{ conclusion: 'failure', name: 'Android Release', status: 'completed' }], + { expectedHeadSha: 'current-head', repository, platform: 'android' }, + ), + 'failed', + ); + assert.equal( + classifyProducerState( + [trustedRun], + [{ conclusion: 'success', name: 'Android Release', status: 'completed' }], + { expectedHeadSha: 'current-head', repository, platform: 'android' }, + ), + 'success', + ); +}); + +// #2251/run 33550746596: the run's own status flickers to `queued` in the gap +// between its fingerprint job finishing and its platform job starting. A +// classifier keyed on run.status (the pre-fix behavior) mistakes that gap for +// "give up"; keying on the platform job's own status does not. +test('a run that flickers to queued while its platform job is already running classifies as in_progress, not queued', () => { + const flickeringRun = { ...trustedRun, status: 'queued' }; + const runningAndroidJob = { conclusion: null, name: 'Android Release', status: 'in_progress' }; + assert.equal( + classifyProducerState([flickeringRun], [runningAndroidJob], { expectedHeadSha: 'current-head', repository, + platform: 'android', + }), + 'in_progress', + ); +}); + +test('a platform job that has not been scheduled yet classifies by the run instead of going absent', () => { + const fingerprintJob = { + conclusion: null, + name: 'Resolve native fingerprint', + status: 'completed', + }; + assert.equal( + classifyProducerState([trustedRun], [fingerprintJob], { + expectedHeadSha: 'current-head', + repository, + platform: 'android', }), 'queued', ); + const completedRun = { ...trustedRun, conclusion: 'success', status: 'completed' }; assert.equal( - classifyProducerState([{ ...trustedRun, conclusion: 'failure', status: 'completed' }], { + classifyProducerState([completedRun], [fingerprintJob], { expectedHeadSha: 'current-head', repository, + platform: 'android', }), 'failed', ); +}); + +test('the iOS platform job is looked up by its own matrix name, independent of the Android job', () => { + const iosJob = { conclusion: null, name: 'iOS Release', status: 'queued' }; + const androidJob = { conclusion: 'success', name: 'Android Release', status: 'completed' }; assert.equal( - classifyProducerState([{ ...trustedRun, conclusion: 'success', status: 'completed' }], { + classifyProducerState([trustedRun], [iosJob, androidJob], { expectedHeadSha: 'current-head', repository, + platform: 'ios', + }), + 'queued', + ); + assert.equal( + classifyProducerState([trustedRun], [iosJob, androidJob], { + expectedHeadSha: 'current-head', + repository, + platform: 'android', }), 'success', ); }); + +test('an unrecognized fixture platform is rejected rather than silently matching no job', () => { + assert.throws( + () => + classifyProducerState([trustedRun], [], { + expectedHeadSha: 'current-head', + repository, + platform: 'windows', + }), + /unknown fixture platform: windows/, + ); +}); diff --git a/test/scripts/setup-fixture-app-fallback-smoke.sh b/test/scripts/setup-fixture-app-fallback-smoke.sh index abd6e85b0a..23b32015da 100755 --- a/test/scripts/setup-fixture-app-fallback-smoke.sh +++ b/test/scripts/setup-fixture-app-fallback-smoke.sh @@ -151,13 +151,50 @@ run_terminal_producer_case() { } for PLATFORM in ios android; do - run_terminal_producer_case queued 'producer is queued' "$PLATFORM" run_terminal_producer_case failed 'producer failed' "$PLATFORM" run_terminal_producer_case absent 'No fixture producer appeared' "$PLATFORM" done if [ "$FAIL" -eq 0 ]; then - echo "PASS: queued, failed, and absent producers exit the wait path early." + echo "PASS: failed and absent producers exit the wait path early." +fi + +run_queued_keeps_waiting() { + PLATFORM="$1" + TEST_PRODUCER_STATE=queued + export TEST_PRODUCER_STATE + TEST_WAIT_SECONDS=2 + export TEST_WAIT_SECONDS + : > "$GITHUB_OUTPUT" + if ! ( + cd "$WORKSPACE" + PATH="$WORK/bin:$PATH" bash "$ACTION_PATH/fetch-artifact.sh" \ + "$PLATFORM" "$WORK/fixture" "$TEST_WAIT_SECONDS" octo/repo current-head "$ACTION_PATH" + ) > "$WORK/log-queued-waits-$PLATFORM" 2>&1; then + echo "FAIL: $PLATFORM queued producer state did not fall back cleanly after its wait budget." >&2 + FAIL=1 + return + fi + if ! grep -q '^source=build$' "$GITHUB_OUTPUT"; then + echo "FAIL: $PLATFORM queued producer state did not select inline build after its wait budget." >&2 + FAIL=1 + fi + if grep -qi 'building inline instead of waiting for a native runner' "$WORK/log-queued-waits-$PLATFORM"; then + echo "FAIL: $PLATFORM queued producer state bailed instead of waiting out its budget." >&2 + FAIL=1 + fi + if ! grep -q 'not in the cache after the configured wait' "$WORK/log-queued-waits-$PLATFORM"; then + echo "FAIL: $PLATFORM queued producer state did not exhaust its wait budget before falling back." >&2 + FAIL=1 + fi +} + +for PLATFORM in ios android; do + run_queued_keeps_waiting "$PLATFORM" +done + +if [ "$FAIL" -eq 0 ]; then + echo "PASS: a queued producer keeps waiting instead of bailing immediately." fi exit "$FAIL"