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
158 changes: 158 additions & 0 deletions .github/actions/ci-status-gate/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: CI Status Gate
description: Evaluate required CI job results and fail on unexpected skips or failed jobs.

inputs:
needs-json:
description: JSON representation of the calling job's needs context.
required: true
requirement-context-json:
description: JSON representation of get-requirements outputs.
required: true
e2e-job-regex:
description: Regex matching E2E build/test jobs whose skipped result is allowed. Failed or cancelled E2E jobs still fail.
required: false
default: '^e2e-'
event-name:
description: GitHub event name for the current workflow run.
required: true
is-fork:
description: Whether the current pull request originates from a fork. When true, skipped jobs are treated as allowed skips.
required: false
default: 'false'

runs:
using: composite
steps:
- name: Evaluate CI status
shell: bash
env:
NEEDS_JSON: ${{ inputs.needs-json }}
REQUIREMENT_CONTEXT_JSON: ${{ inputs.requirement-context-json }}
E2E_JOB_REGEX: ${{ inputs.e2e-job-regex }}
EVENT_NAME: ${{ inputs.event-name }}
IS_FORK: ${{ inputs.is-fork }}
run: |
set -euo pipefail

get_requirement() {
local key="$1"
jq -nr --arg key "$key" 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] // "false"'
}

sanitize_markdown_cell() {
local value="$1"
value="${value//$'\n'/ }"
value="${value//|/\\|}"
printf '%s' "$value"
}

add_summary_row() {
local job_name result decision reason
job_name="$(sanitize_markdown_cell "$1")"
result="$(sanitize_markdown_cell "$2")"
decision="$(sanitize_markdown_cell "$3")"
reason="$(sanitize_markdown_cell "$4")"

printf '| `%s` | `%s` | %s | %s |\n' \
"$job_name" "$result" "$decision" "$reason" >> "$summary_file"
}

mark_failure() {
local message="$1"
failed="true"
echo "::error::$message"
}

validate_json_type() {
local variable_name="$1"
local expected_type="$2"

if ! jq -en --arg variable_name "$variable_name" --arg expected_type "$expected_type" \
'(env[$variable_name] | fromjson | type) == $expected_type' >/dev/null 2>&1; then
echo "::error::$variable_name is not a valid JSON $expected_type"
exit 1
fi
}

require_requirement_key() {
local key="$1"

if ! jq -en --arg key "$key" \
'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] != null' >/dev/null 2>&1; then
echo "::error::REQUIREMENT_CONTEXT_JSON is missing or null for required key: $key"
exit 1
fi
}

validate_json_type NEEDS_JSON object
validate_json_type REQUIREMENT_CONTEXT_JSON object

for required_key in skip_everything block_merge_for_e2e_readiness; do
require_requirement_key "$required_key"
done

skip_everything="$(get_requirement skip_everything)"
block_merge_for_e2e_readiness="$(get_requirement block_merge_for_e2e_readiness)"

if [[ "$block_merge_for_e2e_readiness" == "true" ]]; then
echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging."
exit 1
fi

if [[ "$skip_everything" == "true" ]]; then
echo "skip_everything=true; treating all jobs as passed"
exit 0
fi

failed="false"
summary_file="$(mktemp)"
trap 'if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "$summary_file" ]]; then cat "$summary_file" >> "$GITHUB_STEP_SUMMARY"; fi; rm -f "$summary_file"' EXIT
job_count=0

{
echo "### CI Status Gate"
echo
echo "| Job | Result | Decision | Reason |"
echo "| --- | --- | --- | --- |"
} >> "$summary_file"

while IFS=$'\t' read -r job_name result; do
job_count=$((job_count + 1))

case "$result" in
success)
add_summary_row "$job_name" "$result" "pass" "job succeeded"
;;
failure|cancelled)
mark_failure "$job_name finished with result: $result"
add_summary_row "$job_name" "$result" "fail" "job did not complete successfully"
;;
skipped)
if [[ "$job_name" =~ $E2E_JOB_REGEX ]]; then
add_summary_row "$job_name" "$result" "pass" "skipped E2E jobs are allowed"
elif [[ "$EVENT_NAME" == "merge_group" ]]; then
add_summary_row "$job_name" "$result" "pass" "merge queue skip is allowed"
elif [[ "$IS_FORK" == "true" ]]; then
add_summary_row "$job_name" "$result" "pass" "fork-only skip is allowed"
else
mark_failure "$job_name was skipped unexpectedly"
add_summary_row "$job_name" "$result" "fail" "skip was not expected"
fi
;;
*)
mark_failure "$job_name has unknown result: $result"
add_summary_row "$job_name" "$result" "fail" "job result is unknown"
;;
esac
done < <(jq -nr 'env.NEEDS_JSON | fromjson | to_entries[] | [.key, (.value.result // "")] | @tsv')

if [[ "$job_count" -eq 0 ]]; then
echo "::error::NEEDS_JSON does not contain any jobs"
exit 1
fi

if [[ "$failed" == "true" ]]; then
exit 1
fi

echo "All required jobs passed"
17 changes: 10 additions & 7 deletions .github/actions/setup-e2e-env/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ runs:
node_modules
.yarn/install-state.gz
key: ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-
continue-on-error: true

- name: Install JavaScript dependencies with retry
id: yarn-install
Expand Down Expand Up @@ -387,19 +390,19 @@ runs:
${{ runner.os }}-cocoapods-specs-
continue-on-error: true

- name: Clear CocoaPods trunk to prevent stale specs
if: ${{ inputs.platform == 'ios' }}
run: pod repo remove trunk || true
shell: bash

- name: Install CocoaPods via bundler
if: ${{ inputs.platform == 'ios'}}
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 15
max_attempts: 2
retry_wait_seconds: 30
max_attempts: 3
retry_wait_seconds: 60
on_retry_command: |
echo "::warning::CocoaPods install failed, retrying after trunk cleanup..."
pod repo remove trunk || true
command: cd ios && bundle exec pod install --repo-update
env:
COCOAPODS_DISABLE_STATS: 'true'

- name: Install applesimutils
if: ${{ inputs.platform == 'ios' }}
Expand Down
120 changes: 24 additions & 96 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -937,113 +937,41 @@ jobs:
fi
fi

all-jobs-pass:
name: All jobs pass
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
needs:
[
check-diff,
dedupe,
scripts,
unit-tests,
component-view-tests,
check-workflows,
js-bundle-size-check,
sonar-cloud-quality-gate-status,
]
outputs:
ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }}
steps:
- name: Set jobs passed status
id: jobs-passed-status
env:
NEEDS_CONTEXT: ${{ toJSON(needs) }}
EVENT_NAME: ${{ github.event_name }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: |
# Check results of all required jobs dynamically
# On merge_group events, "skipped" is acceptable (some jobs intentionally skip)
# On fork PRs, "skipped" is acceptable (secret-dependent jobs are intentionally skipped)
# On other events (push to main), all jobs must succeed

FAILED="false"

while read -r job_name result; do
if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then
echo "::error::Job '$job_name' failed with result: $result"
FAILED="true"
elif [[ "$result" == "skipped" ]]; then
if [[ "$EVENT_NAME" == "merge_group" ]] || [[ "$IS_FORK" == "true" ]]; then
echo "Job '$job_name' was skipped (OK for merge_group events and fork PRs)"
else
echo "::error::Job '$job_name' was unexpectedly skipped on $EVENT_NAME event"
FAILED="true"
fi
else
echo "Job '$job_name' passed"
fi
done < <(echo "$NEEDS_CONTEXT" | jq -r 'to_entries[] | "\(.key) \(.value.result)"')

if [[ "$FAILED" == "true" ]]; then
echo "Some required jobs failed"
exit 1
fi

echo "ALL_JOBS_PASSED=true" >> "$GITHUB_OUTPUT"

check-all-jobs-pass:
name: Check all jobs pass
if: ${{ !cancelled() }}
# Run the aggregate gate even when optional dependencies are skipped.
# The composite action decides which skipped jobs are acceptable.
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
needs:
- get_requirements
- all-jobs-pass
- check-diff
- dedupe
- scripts
- unit-tests
- component-view-tests
- check-workflows
- js-bundle-size-check
- sonar-cloud-quality-gate-status
- build-android-apks
- build-ios-apps
- e2e-smoke-tests-android
- e2e-smoke-tests-ios
env:
SKIPPED: ${{ needs.get_requirements.outputs.skip_everything == 'true' }}
steps:
- name: Block merge while pr-not-ready-for-e2e label is applied
if: ${{ needs.get_requirements.outputs.block_merge_for_e2e_readiness == 'true' }}
run: |
echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging."
exit 1
- run: |
# If the merge queue was skipped, consider all jobs as passed
if [[ "$SKIPPED" == "true" ]]; then
echo "Merge queue skipped, considering all jobs as passed"
exit 0
fi

# Check if all non-E2E jobs passed
if [[ "${{ needs.all-jobs-pass.outputs.ALL_JOBS_PASSED }}" != "true" ]]; then
echo "Non-E2E jobs failed"
exit 1
fi

# Check E2E build + smoke results only if E2E should have run.
# 'skipped' is acceptable — covers merge_group, fork PRs, ignorable-only changes,
# platform-only PRs, and AI selection returning zero tags.
# 'failure'/'cancelled' on any of build or smoke must block merge.
if [[ "${{ needs.get_requirements.outputs.skip_e2e }}" != "true" ]]; then
for entry in \
"build-android-apks:${{ needs.build-android-apks.result }}" \
"e2e-smoke-tests-android:${{ needs.e2e-smoke-tests-android.result }}" \
"build-ios-apps:${{ needs.build-ios-apps.result }}" \
"e2e-smoke-tests-ios:${{ needs.e2e-smoke-tests-ios.result }}"; do
name="${entry%%:*}"
result="${entry#*:}"
if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then
echo "::error::Required E2E job '$name' did not succeed (result: $result)"
exit 1
fi
done
fi
- uses: actions/checkout@v6
with:
fetch-depth: 1
sparse-checkout: |
.github/actions/ci-status-gate

echo "All required jobs passed"
- name: Evaluate CI status
uses: ./.github/actions/ci-status-gate
with:
needs-json: ${{ toJSON(needs) }}
requirement-context-json: ${{ toJSON(needs.get_requirements.outputs) }}
e2e-job-regex: '^(build-android-apks|build-ios-apps|e2e-smoke-tests-android|e2e-smoke-tests-ios)$'
event-name: ${{ github.event_name }}
is-fork: ${{ github.event.pull_request.head.repo.fork == true }}

log-merge-group-failure:
name: Log merge group failure
Expand Down
20 changes: 20 additions & 0 deletions app/components/UI/Rewards/RewardsNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import OndoCampaignRwaSelectorView from './Views/OndoCampaignRwaSelectorView';
import OndoCampaignPortfolioView from './Views/OndoCampaignPortfolioView';
import OndoCampaignStatsView from './Views/OndoCampaignStatsView';
import CampaignTourStepView from './Views/CampaignTourStepView';
import PerpsTradingCampaignDetailsView from './Views/PerpsTradingCampaignDetailsView';
import PerpsTradingCampaignLeaderboardView from './Views/PerpsTradingCampaignLeaderboardView';
import PerpsTradingCampaignStatsView from './Views/PerpsTradingCampaignStatsView';
import { useDispatch, useSelector } from 'react-redux';
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
import {
Expand Down Expand Up @@ -92,6 +95,8 @@ const RewardsNavigator: React.FC = () => {
navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW);
} else if (pendingDeeplink?.campaign === 'season1') {
navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW);
} else if (pendingDeeplink?.campaign === 'perps-comp') {
navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW);
} else if (pendingDeeplink?.page === 'musd') {
navigation.navigate(Routes.REWARDS_MUSD_CALCULATOR_VIEW);
} else if (pendingDeeplink?.page === 'benefits') {
Expand Down Expand Up @@ -194,6 +199,21 @@ const RewardsNavigator: React.FC = () => {
component={OndoCampaignStatsView}
options={{ headerShown: false }}
/>
<Stack.Screen
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW}
component={PerpsTradingCampaignDetailsView}
options={{ headerShown: false }}
/>
<Stack.Screen
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD}
component={PerpsTradingCampaignLeaderboardView}
options={{ headerShown: false }}
/>
<Stack.Screen
name={Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS}
component={PerpsTradingCampaignStatsView}
options={{ headerShown: false }}
/>
</>
) : null}
</Stack.Navigator>
Expand Down
Loading
Loading