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"