Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/actions/setup-fixture-app/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions .github/actions/setup-fixture-app/fetch-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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
Expand All @@ -69,7 +65,7 @@ if [ -z "$ART_ID" ] && [ "$WAIT_SECONDS" -gt 0 ]; then
break
fi
;;
in_progress)
queued | in_progress)
MISSING_PRODUCER_POLLS=0
;;
*)
Expand Down
39 changes: 31 additions & 8 deletions .github/actions/setup-fixture-app/trusted-artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-app-build-cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 93 additions & 5 deletions test/ci/trusted-fixture-artifact.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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@'),
);
Expand All @@ -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"/,
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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/,
);
});
41 changes: 39 additions & 2 deletions test/scripts/setup-fixture-app-fallback-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading