From 061999a9b66026a968f4d0ef0531d63a79d11ef2 Mon Sep 17 00:00:00 2001 From: ethanwee1 Date: Mon, 18 May 2026 14:25:27 +0000 Subject: [PATCH 1/5] [CI] Add parity auto-trigger workflow Add a scheduled scanner that dispatches one parity report per ready upstream PyTorch main commit, with PR dry-runs to validate readiness without creating reports. --- .github/workflows/parity-auto.yml | 351 ++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 .github/workflows/parity-auto.yml diff --git a/.github/workflows/parity-auto.yml b/.github/workflows/parity-auto.yml new file mode 100644 index 0000000000000..f3802335f4330 --- /dev/null +++ b/.github/workflows/parity-auto.yml @@ -0,0 +1,351 @@ +name: Parity Auto Trigger +run-name: "Parity auto-trigger · pytorch/pytorch main" + +# Polls completed pytorch/pytorch trunk.yml pushes on main and dispatches +# parity.yml once for each SHA where all CI consumed by the report has finished, +# covering every arch whose test shards actually ran on it. +# +# Arch participation is detected at the workflow_run level: if rocm-mi300 +# never ran on a SHA, we don't wait for it or include it. Readiness is then +# evaluated at the *check-run* level because ROCm test shards post check-runs +# independently, and a single failing shard flips the parent workflow_run to +# conclusion=failure while siblings are still executing. +# +# Two gates: +# 1. Detect which ROCm arch workflows actually ran on this SHA. +# 2. Require every test check-run for those ROCm arch workflows, plus every +# CUDA test check-run consumed by download_testlogs, to be status=completed. +# We don't want to dispatch mi355 while a mi300 workflow that ran on the +# same SHA is still creating or running test shards. +# +# We dispatch at most once per SHA with the ready subset of arches, so mi355 +# (run as part of trunk) gets a parity report per commit, and mi300/ +# mi200 join the same dispatch whenever their periodic workflow +# happens to finish on that SHA. + +on: + schedule: + - cron: '*/10 * * * *' + pull_request: + paths: + - '.github/workflows/parity-auto.yml' + workflow_dispatch: + inputs: + max_commits: + description: 'How many of the most recent completed upstream trunk.yml pushes on main to scan.' + required: false + default: '200' + type: string + max_dispatches: + description: 'Maximum number of ready upstream commits to dispatch in one scan.' + required: false + default: '50' + type: string + max_age_hours: + description: 'Skip commits older than this (avoid back-filling ancient SHAs).' + required: false + default: '72' + type: string + archs: + description: 'Architectures to consider (comma/space separated).' + required: false + default: 'mi355, mi300, mi200' + type: string + arch_jobname_regex_map: + description: 'JSON: arch -> PCRE regex that matches the check-run names of that arch''s ROCm test shards on pytorch/pytorch. An arch is considered "ready" only when every check-run whose name matches has status=completed (so we wait for all test shards, not just workflow completion).' + required: false + default: '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' + type: string + arch_workflow_regex_map: + description: 'JSON: arch -> PCRE regex that matches workflow file paths for upstream ROCm workflows that mean this arch ran on the SHA. Missing workflows mean the arch is not expected for that commit.' + required: false + default: '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' + type: string + target_ref: + description: 'Ref of this repo to dispatch parity.yml against. Leave blank to use this workflow run''s ref.' + required: false + default: '' + type: string + dry_run: + description: 'Scan and log, but do not actually dispatch parity.yml.' + required: false + default: false + type: boolean + +permissions: + contents: read + actions: write + +concurrency: + group: parity-auto-trigger + cancel-in-progress: false + +jobs: + scan-and-dispatch: + runs-on: ubuntu-latest + steps: + - name: Find ready arches per upstream commit and dispatch parity.yml + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM: pytorch/pytorch + BRANCH: main + MAX_COMMITS: ${{ github.event_name == 'pull_request' && '20' || inputs.max_commits || '200' }} + MAX_DISPATCHES: ${{ github.event_name == 'pull_request' && '5' || inputs.max_dispatches || '50' }} + MAX_AGE_HOURS: ${{ inputs.max_age_hours || '72' }} + ARCHS_IN: ${{ inputs.archs || 'mi355, mi300, mi200' }} + ARCH_JOBNAME_REGEX_MAP: ${{ inputs.arch_jobname_regex_map || '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' }} + ARCH_WORKFLOW_REGEX_MAP: ${{ inputs.arch_workflow_regex_map || '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' }} + TARGET_REF_IN: ${{ inputs.target_ref || '' }} + DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || inputs.dry_run || 'false' }} + run: | + # GitHub Actions launches this with `bash -e {0}`, so -e is already on + # from the shebang. It's too aggressive for the many pipelines here + # (grep -q returning 1, date -d edge cases, paginated API calls, + # etc.) and has caused the loop to silently abort after the first + # "no ready archs" commit. Explicitly turn -e OFF and keep -u + + # pipefail so undefined-variable bugs still surface. + set +e + set -uo pipefail + + NOW_EPOCH=$(date -u +%s) + MAX_AGE_EPOCH=$((NOW_EPOCH - MAX_AGE_HOURS * 3600)) + TARGET_REF="${TARGET_REF_IN:-$GITHUB_REF_NAME}" + ARCHS=$(echo "$ARCHS_IN" | tr ',' ' ' | xargs) + + echo "Upstream: $UPSTREAM@$BRANCH" + echo "Target ref: $TARGET_REF" + echo "Scope archs: $ARCHS" + echo "Max trunk runs: $MAX_COMMITS" + echo "Max dispatches: $MAX_DISPATCHES" + echo "Max age: ${MAX_AGE_HOURS}h" + echo "Dry run: $DRY_RUN" + echo "Arch jobs: $ARCH_JOBNAME_REGEX_MAP" + echo "Arch workflows: $ARCH_WORKFLOW_REGEX_MAP" + echo + + # --- 1. Recent completed upstream trunk pushes ----------------------- + # Use trunk.yml as the candidate source instead of raw main commits. + # The parity report consumes trunk's CUDA/ROCm jobs, so a completed + # trunk push is the first point where a SHA can reasonably be ready. + COMMITS=$(gh api \ + "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=$MAX_COMMITS" \ + --jq ' + reduce .workflow_runs[] as $run ({seen:{}, rows:[]}; + if .seen[$run.head_sha] then . + else .seen[$run.head_sha] = true | .rows += [$run] + end + ) + | .rows[] + | "\(.head_sha) \(.created_at)" + ') + + if [ -z "$COMMITS" ]; then + echo "::warning::No completed trunk.yml push runs returned from $UPSTREAM@$BRANCH" + exit 0 + fi + + # --- 2. Already-dispatched SHAs in our repo -------------------------- + # Pull recent parity runs. Run titles look like: + # " · mi355, mi300, mi200" + # Once any parity run exists for a SHA, we do not dispatch another + # report for that SHA. This keeps the dashboard to one report per + # upstream commit. + EXISTING=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow parity.yml \ + --limit 1000 \ + --json displayTitle 2>/dev/null || echo '[]') + + sha_already_dispatched() { + local sha="$1" + echo "$EXISTING" | jq -e --arg sha "$sha" \ + 'any(.[]; .displayTitle | contains($sha))' >/dev/null + } + + # --- 3. Walk trunk SHAs, dispatch each ready unprocessed SHA --------- + DISPATCHED_COUNT=0 + DISPATCHED_SUMMARY="" + while IFS=' ' read -r SHA DATE; do + [ -z "$SHA" ] && continue + SHORT=$(echo "$SHA" | cut -c1-8) + COMMIT_EPOCH=$(date -u -d "$DATE" +%s 2>/dev/null || echo 0) + + if [ "$COMMIT_EPOCH" -ne 0 ] && [ "$COMMIT_EPOCH" -lt "$MAX_AGE_EPOCH" ]; then + echo "[$SHORT] $DATE too old (>${MAX_AGE_HOURS}h) - stopping scan" + break + fi + + if sha_already_dispatched "$SHA"; then + echo "[$SHORT] parity report already exists for this SHA - skip" + continue + fi + + # First determine which ROCm arch workflows actually ran on this + # SHA. If a periodic arch workflow never ran, the arch is not + # expected for the report. If it did run, we must wait for its + # matching test shards below. + ALL_WORKFLOW_RUNS=$(gh api --paginate \ + "repos/$UPSTREAM/actions/runs?head_sha=$SHA&per_page=100" \ + --jq '.workflow_runs[] | {name,path,status,conclusion}' \ + 2>/dev/null | jq -s '.' || echo '[]') + + RUN_ARCHS="" + NOT_RUN_NOTES="" + for ARCH in $ARCHS; do + WF_REGEX=$(echo "$ARCH_WORKFLOW_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + if [ -z "$WF_REGEX" ]; then + NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow-regex" + continue + fi + WF_TOTAL=$(echo "$ALL_WORKFLOW_RUNS" | jq --arg rx "$WF_REGEX" \ + 'map(select((.path // "") | test($rx))) | length') + if [ "$WF_TOTAL" -eq 0 ]; then + NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow" + else + RUN_ARCHS="$RUN_ARCHS $ARCH" + fi + done + RUN_ARCHS=$(echo "$RUN_ARCHS" | xargs) + NOT_RUN_NOTES=$(echo "$NOT_RUN_NOTES" | xargs) + + if [ -z "$RUN_ARCHS" ]; then + echo "[$SHORT] $DATE no in-scope ROCm workflows ran on upstream (${NOT_RUN_NOTES:-none}) - skip" + continue + fi + + # Pull relevant upstream check-runs for this SHA. Test shards post + # check-runs independently, and workflow_run conclusion can flip to + # failure before sibling shards finish. We need per-shard state. + ALL_CHECK_RUNS=$(gh api --paginate \ + "repos/$UPSTREAM/commits/$SHA/check-runs?per_page=100" \ + --jq '.check_runs[] | {name,status,conclusion}' \ + 2>/dev/null | jq -s '.' || echo '[]') + + CHECK_RUNS='[]' + for ARCH in $RUN_ARCHS; do + REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + [ -z "$REGEX" ] && continue + ARCH_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$REGEX" \ + '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') + CHECK_RUNS=$(jq -s 'add | unique_by(.name)' \ + <(echo "$CHECK_RUNS") \ + <(echo "$ARCH_CHECK_RUNS")) + done + + CUDA_JOBNAME_REGEX='(linux-jammy-cuda13[.]0-py3[.]10-gcc11 / (test-osdc|test) [(](default|distributed),|unit-test / inductor-test / (test-osdc|test) [(]inductor,)' + CUDA_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$CUDA_JOBNAME_REGEX" \ + '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') + + if [ "$(echo "$CHECK_RUNS" | jq 'length')" -eq 0 ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no parity check-runs yet - skip" + continue + fi + + if [ "$(echo "$CUDA_CHECK_RUNS" | jq 'length')" -eq 0 ]; then + echo "[$SHORT] $DATE no CUDA parity check-runs yet on upstream - skip" + continue + fi + + # Gate 1: require EVERY upstream check-run consumed by the + # parity report for this SHA to be status=completed (ROCm test + # shards for arch workflows that ran, plus CUDA default/ + # distributed/inductor tests). Once we dispatch for a SHA the + # parity report is authored, so dispatching before CUDA or + # another arch finishes produces partial reports. + GATE_CHECK_RUNS=$(jq -s 'add' \ + <(echo "$CHECK_RUNS") \ + <(echo "$CUDA_CHECK_RUNS")) + TOTAL_CR=$(echo "$GATE_CHECK_RUNS" | jq 'length') + PENDING_CR=$(echo "$GATE_CHECK_RUNS" | jq 'map(select(.status != "completed")) | length') + if [ "$PENDING_CR" -ne 0 ]; then + PENDING_SAMPLE=$(echo "$GATE_CHECK_RUNS" | jq -r ' + map(select(.status != "completed")) + | .[0:3] + | map(.name) + | join(", ")') + echo "[$SHORT] $DATE ${PENDING_CR}/${TOTAL_CR} parity check-runs still pending - skip (e.g. $PENDING_SAMPLE)" + continue + fi + + # Gate 2: every arch workflow that ran on this SHA must have + # matching test shards before we author the one-and-only report + # for the SHA. Missing arch workflows are not expected; missing + # shards for a workflow that ran means the workflow is not ready. + READY="" + NOT_READY_NOTES="" + for ARCH in $RUN_ARCHS; do + REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + if [ -z "$REGEX" ]; then + NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:no-regex" + continue + fi + TOTAL=$(echo "$CHECK_RUNS" | jq --arg rx "$REGEX" \ + 'map(select(.name | test($rx))) | length') + if [ "$TOTAL" -eq 0 ]; then + NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:workflow-run-no-shards-yet" + else + READY="$READY $ARCH" + fi + done + READY=$(echo "$READY" | xargs) + NOT_READY_NOTES=$(echo "$NOT_READY_NOTES" | xargs) + + if [ -n "$NOT_READY_NOTES" ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but some test shards are missing - skip (${NOT_READY_NOTES})" + continue + fi + + if [ -z "$READY" ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no in-scope arches are ready" + continue + fi + + ARCH_DISPATCH=$(echo "$READY" | sed 's/ /, /g') + CSV_NAME="autoparity-$(date -u +%Y%m%d)-$SHA" + echo "[$SHORT] READY archs: '$(echo "$READY" | tr ' ' ',')' (committed $DATE; not-run: ${NOT_RUN_NOTES:-none})" + echo "[$SHORT] dispatching for: '$(echo "$READY" | tr ' ' ',')'" + + if [ "$DRY_RUN" = "true" ]; then + echo "[$SHORT] DRY_RUN=true - not dispatching" + else + gh workflow run parity.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$TARGET_REF" \ + -f sha="$SHA" \ + -f arch="$ARCH_DISPATCH" \ + -f csv_name="$CSV_NAME" + fi + + DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) + DISPATCHED_SUMMARY="${DISPATCHED_SUMMARY}${SHORT}:${ARCH_DISPATCH}"$'\n' + if [ "$DISPATCHED_COUNT" -ge "$MAX_DISPATCHES" ]; then + echo "Reached max dispatches for this scan ($MAX_DISPATCHES); stopping" + break + fi + done <<< "$COMMITS" + + # --- 4. Summary ------------------------------------------------------- + { + echo "### Parity auto-trigger" + echo "" + echo "- Upstream: \`$UPSTREAM@$BRANCH\`" + echo "- Scope archs: \`$ARCHS\`" + echo "- Max commits: $MAX_COMMITS" + echo "- Max dispatches: $MAX_DISPATCHES" + echo "- Max age: ${MAX_AGE_HOURS}h" + echo "- Target ref: \`$TARGET_REF\`" + if [ "$DISPATCHED_COUNT" -gt 0 ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "- Result: would dispatch $DISPATCHED_COUNT parity run(s) (dry-run)" + else + echo "- Result: dispatched $DISPATCHED_COUNT parity run(s)" + fi + echo "" + echo "$DISPATCHED_SUMMARY" | while IFS= read -r LINE; do + [ -z "$LINE" ] && continue + echo "- $LINE" + done + else + echo "- Result: no ready unprocessed SHAs found" + fi + } >> "$GITHUB_STEP_SUMMARY" From 1945a347d4438c0daf2364a2d3c97c27801f2e10 Mon Sep 17 00:00:00 2001 From: ethanwee1 Date: Wed, 27 May 2026 14:30:42 +0000 Subject: [PATCH 2/5] [CI] Simplify parity auto trigger scope Scope auto parity to completed trunk mi355 default reports, remove user-facing regex inputs, and use paginated workflow APIs for candidate and dedupe windows. --- .github/workflows/parity-auto.yml | 269 ++++++------------------------ 1 file changed, 53 insertions(+), 216 deletions(-) diff --git a/.github/workflows/parity-auto.yml b/.github/workflows/parity-auto.yml index f3802335f4330..83c321a876797 100644 --- a/.github/workflows/parity-auto.yml +++ b/.github/workflows/parity-auto.yml @@ -2,26 +2,9 @@ name: Parity Auto Trigger run-name: "Parity auto-trigger · pytorch/pytorch main" # Polls completed pytorch/pytorch trunk.yml pushes on main and dispatches -# parity.yml once for each SHA where all CI consumed by the report has finished, -# covering every arch whose test shards actually ran on it. -# -# Arch participation is detected at the workflow_run level: if rocm-mi300 -# never ran on a SHA, we don't wait for it or include it. Readiness is then -# evaluated at the *check-run* level because ROCm test shards post check-runs -# independently, and a single failing shard flips the parent workflow_run to -# conclusion=failure while siblings are still executing. -# -# Two gates: -# 1. Detect which ROCm arch workflows actually ran on this SHA. -# 2. Require every test check-run for those ROCm arch workflows, plus every -# CUDA test check-run consumed by download_testlogs, to be status=completed. -# We don't want to dispatch mi355 while a mi300 workflow that ran on the -# same SHA is still creating or running test shards. -# -# We dispatch at most once per SHA with the ready subset of arches, so mi355 -# (run as part of trunk) gets a parity report per commit, and mi300/ -# mi200 join the same dispatch whenever their periodic workflow -# happens to finish on that SHA. +# parity.yml once per upstream SHA. This PR intentionally scopes automation to +# the trunk/default mi355 report; other ROCm arch/workflow coverage can be added +# later without exposing workflow/job regexes as manual inputs. on: schedule: @@ -32,7 +15,7 @@ on: workflow_dispatch: inputs: max_commits: - description: 'How many of the most recent completed upstream trunk.yml pushes on main to scan.' + description: 'How many recent completed upstream trunk.yml pushes on main to scan.' required: false default: '200' type: string @@ -42,32 +25,17 @@ on: default: '50' type: string max_age_hours: - description: 'Skip commits older than this (avoid back-filling ancient SHAs).' + description: 'Skip commits older than this.' required: false default: '72' type: string - archs: - description: 'Architectures to consider (comma/space separated).' - required: false - default: 'mi355, mi300, mi200' - type: string - arch_jobname_regex_map: - description: 'JSON: arch -> PCRE regex that matches the check-run names of that arch''s ROCm test shards on pytorch/pytorch. An arch is considered "ready" only when every check-run whose name matches has status=completed (so we wait for all test shards, not just workflow completion).' - required: false - default: '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' - type: string - arch_workflow_regex_map: - description: 'JSON: arch -> PCRE regex that matches workflow file paths for upstream ROCm workflows that mean this arch ran on the SHA. Missing workflows mean the arch is not expected for that commit.' - required: false - default: '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' - type: string target_ref: - description: 'Ref of this repo to dispatch parity.yml against. Leave blank to use this workflow run''s ref.' + description: 'Ref of this repo to dispatch parity.yml against. Leave blank to use this workflow run ref.' required: false default: '' type: string dry_run: - description: 'Scan and log, but do not actually dispatch parity.yml.' + description: 'Scan and log, but do not dispatch parity.yml.' required: false default: false type: boolean @@ -84,58 +52,52 @@ jobs: scan-and-dispatch: runs-on: ubuntu-latest steps: - - name: Find ready arches per upstream commit and dispatch parity.yml + - name: Find completed trunk commits and dispatch parity.yml env: GH_TOKEN: ${{ github.token }} UPSTREAM: pytorch/pytorch BRANCH: main + PARITY_ARCH: mi355 + AUTOPARITY_PREFIX: autoparity MAX_COMMITS: ${{ github.event_name == 'pull_request' && '20' || inputs.max_commits || '200' }} MAX_DISPATCHES: ${{ github.event_name == 'pull_request' && '5' || inputs.max_dispatches || '50' }} MAX_AGE_HOURS: ${{ inputs.max_age_hours || '72' }} - ARCHS_IN: ${{ inputs.archs || 'mi355, mi300, mi200' }} - ARCH_JOBNAME_REGEX_MAP: ${{ inputs.arch_jobname_regex_map || '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' }} - ARCH_WORKFLOW_REGEX_MAP: ${{ inputs.arch_workflow_regex_map || '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' }} TARGET_REF_IN: ${{ inputs.target_ref || '' }} DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || inputs.dry_run || 'false' }} run: | - # GitHub Actions launches this with `bash -e {0}`, so -e is already on - # from the shebang. It's too aggressive for the many pipelines here - # (grep -q returning 1, date -d edge cases, paginated API calls, - # etc.) and has caused the loop to silently abort after the first - # "no ready archs" commit. Explicitly turn -e OFF and keep -u + - # pipefail so undefined-variable bugs still surface. - set +e - set -uo pipefail + set -euo pipefail + + case "$MAX_COMMITS" in ''|*[!0-9]*) echo "::error::max_commits must be numeric"; exit 1 ;; esac + case "$MAX_DISPATCHES" in ''|*[!0-9]*) echo "::error::max_dispatches must be numeric"; exit 1 ;; esac + case "$MAX_AGE_HOURS" in ''|*[!0-9]*) echo "::error::max_age_hours must be numeric"; exit 1 ;; esac NOW_EPOCH=$(date -u +%s) MAX_AGE_EPOCH=$((NOW_EPOCH - MAX_AGE_HOURS * 3600)) + SINCE=$(date -u -d "@$MAX_AGE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ') TARGET_REF="${TARGET_REF_IN:-$GITHUB_REF_NAME}" - ARCHS=$(echo "$ARCHS_IN" | tr ',' ' ' | xargs) echo "Upstream: $UPSTREAM@$BRANCH" echo "Target ref: $TARGET_REF" - echo "Scope archs: $ARCHS" - echo "Max trunk runs: $MAX_COMMITS" + echo "Parity arch: $PARITY_ARCH" + echo "Max commits: $MAX_COMMITS" echo "Max dispatches: $MAX_DISPATCHES" echo "Max age: ${MAX_AGE_HOURS}h" echo "Dry run: $DRY_RUN" - echo "Arch jobs: $ARCH_JOBNAME_REGEX_MAP" - echo "Arch workflows: $ARCH_WORKFLOW_REGEX_MAP" echo - # --- 1. Recent completed upstream trunk pushes ----------------------- - # Use trunk.yml as the candidate source instead of raw main commits. - # The parity report consumes trunk's CUDA/ROCm jobs, so a completed - # trunk push is the first point where a SHA can reasonably be ready. - COMMITS=$(gh api \ - "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=$MAX_COMMITS" \ - --jq ' - reduce .workflow_runs[] as $run ({seen:{}, rows:[]}; + # Completed trunk workflow runs are the readiness source for this PR: + # no separate check-run regex matching is needed for trunk-only default + # reports, and pagination keeps MAX_COMMITS truthful above 100. + COMMITS=$(gh api --paginate \ + "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=100" \ + --jq '.workflow_runs[] | {head_sha, created_at}' | + jq -rs --arg max "$MAX_COMMITS" ' + reduce .[] as $run ({seen:{}, rows:[]}; if .seen[$run.head_sha] then . else .seen[$run.head_sha] = true | .rows += [$run] end ) - | .rows[] + | .rows[:($max | tonumber)][] | "\(.head_sha) \(.created_at)" ') @@ -144,25 +106,20 @@ jobs: exit 0 fi - # --- 2. Already-dispatched SHAs in our repo -------------------------- - # Pull recent parity runs. Run titles look like: - # " · mi355, mi300, mi200" - # Once any parity run exists for a SHA, we do not dispatch another - # report for that SHA. This keeps the dashboard to one report per - # upstream commit. - EXISTING=$(gh run list \ - --repo "$GITHUB_REPOSITORY" \ - --workflow parity.yml \ - --limit 1000 \ - --json displayTitle 2>/dev/null || echo '[]') + # Deduplicate only auto-parity-created parity runs. Manual parity.yml + # runs should not suppress automation for a SHA. + EXISTING=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/workflows/parity.yml/runs?event=workflow_dispatch&created=%3E%3D$SINCE&per_page=100" \ + --jq '.workflow_runs[] | {display_title, created_at}' | + jq -s --arg prefix "${AUTOPARITY_PREFIX}-" \ + '[.[] | select((.display_title // "") | startswith($prefix))]') sha_already_dispatched() { local sha="$1" echo "$EXISTING" | jq -e --arg sha "$sha" \ - 'any(.[]; .displayTitle | contains($sha))' >/dev/null + 'any(.[]; (.display_title // "") | contains($sha))' >/dev/null } - # --- 3. Walk trunk SHAs, dispatch each ready unprocessed SHA --------- DISPATCHED_COUNT=0 DISPATCHED_SUMMARY="" while IFS=' ' read -r SHA DATE; do @@ -176,169 +133,49 @@ jobs: fi if sha_already_dispatched "$SHA"; then - echo "[$SHORT] parity report already exists for this SHA - skip" - continue - fi - - # First determine which ROCm arch workflows actually ran on this - # SHA. If a periodic arch workflow never ran, the arch is not - # expected for the report. If it did run, we must wait for its - # matching test shards below. - ALL_WORKFLOW_RUNS=$(gh api --paginate \ - "repos/$UPSTREAM/actions/runs?head_sha=$SHA&per_page=100" \ - --jq '.workflow_runs[] | {name,path,status,conclusion}' \ - 2>/dev/null | jq -s '.' || echo '[]') - - RUN_ARCHS="" - NOT_RUN_NOTES="" - for ARCH in $ARCHS; do - WF_REGEX=$(echo "$ARCH_WORKFLOW_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') - if [ -z "$WF_REGEX" ]; then - NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow-regex" - continue - fi - WF_TOTAL=$(echo "$ALL_WORKFLOW_RUNS" | jq --arg rx "$WF_REGEX" \ - 'map(select((.path // "") | test($rx))) | length') - if [ "$WF_TOTAL" -eq 0 ]; then - NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow" - else - RUN_ARCHS="$RUN_ARCHS $ARCH" - fi - done - RUN_ARCHS=$(echo "$RUN_ARCHS" | xargs) - NOT_RUN_NOTES=$(echo "$NOT_RUN_NOTES" | xargs) - - if [ -z "$RUN_ARCHS" ]; then - echo "[$SHORT] $DATE no in-scope ROCm workflows ran on upstream (${NOT_RUN_NOTES:-none}) - skip" - continue - fi - - # Pull relevant upstream check-runs for this SHA. Test shards post - # check-runs independently, and workflow_run conclusion can flip to - # failure before sibling shards finish. We need per-shard state. - ALL_CHECK_RUNS=$(gh api --paginate \ - "repos/$UPSTREAM/commits/$SHA/check-runs?per_page=100" \ - --jq '.check_runs[] | {name,status,conclusion}' \ - 2>/dev/null | jq -s '.' || echo '[]') - - CHECK_RUNS='[]' - for ARCH in $RUN_ARCHS; do - REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') - [ -z "$REGEX" ] && continue - ARCH_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$REGEX" \ - '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') - CHECK_RUNS=$(jq -s 'add | unique_by(.name)' \ - <(echo "$CHECK_RUNS") \ - <(echo "$ARCH_CHECK_RUNS")) - done - - CUDA_JOBNAME_REGEX='(linux-jammy-cuda13[.]0-py3[.]10-gcc11 / (test-osdc|test) [(](default|distributed),|unit-test / inductor-test / (test-osdc|test) [(]inductor,)' - CUDA_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$CUDA_JOBNAME_REGEX" \ - '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') - - if [ "$(echo "$CHECK_RUNS" | jq 'length')" -eq 0 ]; then - echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no parity check-runs yet - skip" - continue - fi - - if [ "$(echo "$CUDA_CHECK_RUNS" | jq 'length')" -eq 0 ]; then - echo "[$SHORT] $DATE no CUDA parity check-runs yet on upstream - skip" - continue - fi - - # Gate 1: require EVERY upstream check-run consumed by the - # parity report for this SHA to be status=completed (ROCm test - # shards for arch workflows that ran, plus CUDA default/ - # distributed/inductor tests). Once we dispatch for a SHA the - # parity report is authored, so dispatching before CUDA or - # another arch finishes produces partial reports. - GATE_CHECK_RUNS=$(jq -s 'add' \ - <(echo "$CHECK_RUNS") \ - <(echo "$CUDA_CHECK_RUNS")) - TOTAL_CR=$(echo "$GATE_CHECK_RUNS" | jq 'length') - PENDING_CR=$(echo "$GATE_CHECK_RUNS" | jq 'map(select(.status != "completed")) | length') - if [ "$PENDING_CR" -ne 0 ]; then - PENDING_SAMPLE=$(echo "$GATE_CHECK_RUNS" | jq -r ' - map(select(.status != "completed")) - | .[0:3] - | map(.name) - | join(", ")') - echo "[$SHORT] $DATE ${PENDING_CR}/${TOTAL_CR} parity check-runs still pending - skip (e.g. $PENDING_SAMPLE)" - continue - fi - - # Gate 2: every arch workflow that ran on this SHA must have - # matching test shards before we author the one-and-only report - # for the SHA. Missing arch workflows are not expected; missing - # shards for a workflow that ran means the workflow is not ready. - READY="" - NOT_READY_NOTES="" - for ARCH in $RUN_ARCHS; do - REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') - if [ -z "$REGEX" ]; then - NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:no-regex" - continue - fi - TOTAL=$(echo "$CHECK_RUNS" | jq --arg rx "$REGEX" \ - 'map(select(.name | test($rx))) | length') - if [ "$TOTAL" -eq 0 ]; then - NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:workflow-run-no-shards-yet" - else - READY="$READY $ARCH" - fi - done - READY=$(echo "$READY" | xargs) - NOT_READY_NOTES=$(echo "$NOT_READY_NOTES" | xargs) - - if [ -n "$NOT_READY_NOTES" ]; then - echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but some test shards are missing - skip (${NOT_READY_NOTES})" - continue - fi - - if [ -z "$READY" ]; then - echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no in-scope arches are ready" + echo "[$SHORT] auto parity report already exists for this SHA - skip" continue fi - ARCH_DISPATCH=$(echo "$READY" | sed 's/ /, /g') - CSV_NAME="autoparity-$(date -u +%Y%m%d)-$SHA" - echo "[$SHORT] READY archs: '$(echo "$READY" | tr ' ' ',')' (committed $DATE; not-run: ${NOT_RUN_NOTES:-none})" - echo "[$SHORT] dispatching for: '$(echo "$READY" | tr ' ' ',')'" + CSV_NAME="${AUTOPARITY_PREFIX}-$(date -u +%Y%m%d)-$SHA" + echo "[$SHORT] completed trunk run at $DATE - ready for $PARITY_ARCH default parity" if [ "$DRY_RUN" = "true" ]; then - echo "[$SHORT] DRY_RUN=true - not dispatching" + echo "[$SHORT] DRY_RUN=true - would dispatch parity.yml" else gh workflow run parity.yml \ --repo "$GITHUB_REPOSITORY" \ - --ref "$TARGET_REF" \ + --ref "$TARGET_REF" \ -f sha="$SHA" \ - -f arch="$ARCH_DISPATCH" \ + -f arch="$PARITY_ARCH" \ + -f exclude_distributed=true \ + -f exclude_inductor=true \ -f csv_name="$CSV_NAME" fi DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) - DISPATCHED_SUMMARY="${DISPATCHED_SUMMARY}${SHORT}:${ARCH_DISPATCH}"$'\n' + DISPATCHED_SUMMARY="${DISPATCHED_SUMMARY}${SHORT}:${PARITY_ARCH}"$'\n' if [ "$DISPATCHED_COUNT" -ge "$MAX_DISPATCHES" ]; then echo "Reached max dispatches for this scan ($MAX_DISPATCHES); stopping" break fi done <<< "$COMMITS" - # --- 4. Summary ------------------------------------------------------- { echo "### Parity auto-trigger" echo "" - echo "- Upstream: \`$UPSTREAM@$BRANCH\`" - echo "- Scope archs: \`$ARCHS\`" - echo "- Max commits: $MAX_COMMITS" + echo "- Upstream: \`$UPSTREAM@$BRANCH\`" + echo "- Scope: completed \`trunk.yml\` pushes" + echo "- Parity report: \`$PARITY_ARCH\` default tests" + echo "- Max commits: $MAX_COMMITS" echo "- Max dispatches: $MAX_DISPATCHES" - echo "- Max age: ${MAX_AGE_HOURS}h" - echo "- Target ref: \`$TARGET_REF\`" + echo "- Max age: ${MAX_AGE_HOURS}h" + echo "- Target ref: \`$TARGET_REF\`" if [ "$DISPATCHED_COUNT" -gt 0 ]; then if [ "$DRY_RUN" = "true" ]; then - echo "- Result: would dispatch $DISPATCHED_COUNT parity run(s) (dry-run)" + echo "- Result: would dispatch $DISPATCHED_COUNT parity run(s) (dry-run)" else - echo "- Result: dispatched $DISPATCHED_COUNT parity run(s)" + echo "- Result: dispatched $DISPATCHED_COUNT parity run(s)" fi echo "" echo "$DISPATCHED_SUMMARY" | while IFS= read -r LINE; do @@ -346,6 +183,6 @@ jobs: echo "- $LINE" done else - echo "- Result: no ready unprocessed SHAs found" + echo "- Result: no ready unprocessed SHAs found" fi } >> "$GITHUB_STEP_SUMMARY" From a2b268a004d4337ba18c477b8e0327189d5573a8 Mon Sep 17 00:00:00 2001 From: ethanwee1 Date: Wed, 27 May 2026 14:40:50 +0000 Subject: [PATCH 3/5] [CI] Limit parity auto trunk pagination Fetch completed trunk runs page by page only until the configured unique SHA limit is reached, avoiding full workflow-history scans. --- .github/workflows/parity-auto.yml | 36 ++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/parity-auto.yml b/.github/workflows/parity-auto.yml index 83c321a876797..be937b06bc4ad 100644 --- a/.github/workflows/parity-auto.yml +++ b/.github/workflows/parity-auto.yml @@ -87,19 +87,29 @@ jobs: # Completed trunk workflow runs are the readiness source for this PR: # no separate check-run regex matching is needed for trunk-only default - # reports, and pagination keeps MAX_COMMITS truthful above 100. - COMMITS=$(gh api --paginate \ - "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=100" \ - --jq '.workflow_runs[] | {head_sha, created_at}' | - jq -rs --arg max "$MAX_COMMITS" ' - reduce .[] as $run ({seen:{}, rows:[]}; - if .seen[$run.head_sha] then . - else .seen[$run.head_sha] = true | .rows += [$run] - end - ) - | .rows[:($max | tonumber)][] - | "\(.head_sha) \(.created_at)" - ') + # reports. Page manually so MAX_COMMITS can exceed GitHub's per_page + # cap without walking the entire workflow history. + COMMITS_JSON='[]' + PAGE=1 + while [ "$(echo "$COMMITS_JSON" | jq 'length')" -lt "$MAX_COMMITS" ]; do + PAGE_RUNS=$(gh api \ + "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=100&page=$PAGE" \ + --jq '.workflow_runs | map({head_sha, created_at})') + if [ "$(echo "$PAGE_RUNS" | jq 'length')" -eq 0 ]; then + break + fi + COMMITS_JSON=$(jq -s --arg max "$MAX_COMMITS" ' + (.[0] + .[1]) as $runs + | reduce $runs[] as $run ({seen:{}, rows:[]}; + if .seen[$run.head_sha] then . + else .seen[$run.head_sha] = true | .rows += [$run] + end + ) + | .rows[:($max | tonumber)] + ' <(echo "$COMMITS_JSON") <(echo "$PAGE_RUNS")) + PAGE=$((PAGE + 1)) + done + COMMITS=$(echo "$COMMITS_JSON" | jq -r '.[] | "\(.head_sha) \(.created_at)"') if [ -z "$COMMITS" ]; then echo "::warning::No completed trunk.yml push runs returned from $UPSTREAM@$BRANCH" From 1495013b57e9ec3469991cd60c81b1712ac9050f Mon Sep 17 00:00:00 2001 From: ethanwee1 Date: Thu, 28 May 2026 18:09:05 +0000 Subject: [PATCH 4/5] [CI] Preserve parity default CSV naming Stop passing csv_name from parity-auto so auto-dispatched parity reports use the same output names as direct parity.yml runs. --- .github/workflows/parity-auto.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/parity-auto.yml b/.github/workflows/parity-auto.yml index be937b06bc4ad..0a101c6423076 100644 --- a/.github/workflows/parity-auto.yml +++ b/.github/workflows/parity-auto.yml @@ -58,7 +58,6 @@ jobs: UPSTREAM: pytorch/pytorch BRANCH: main PARITY_ARCH: mi355 - AUTOPARITY_PREFIX: autoparity MAX_COMMITS: ${{ github.event_name == 'pull_request' && '20' || inputs.max_commits || '200' }} MAX_DISPATCHES: ${{ github.event_name == 'pull_request' && '5' || inputs.max_dispatches || '50' }} MAX_AGE_HOURS: ${{ inputs.max_age_hours || '72' }} @@ -116,18 +115,20 @@ jobs: exit 0 fi - # Deduplicate only auto-parity-created parity runs. Manual parity.yml - # runs should not suppress automation for a SHA. + # Deduplicate auto-parity-created parity runs without changing + # parity.yml's own output naming. New auto-dispatched runs are created + # by github-actions[bot]; keep the old autoparity-* title match so + # runs created before this workflow stopped passing csv_name still + # suppress duplicate dispatches. EXISTING=$(gh api --paginate \ "repos/$GITHUB_REPOSITORY/actions/workflows/parity.yml/runs?event=workflow_dispatch&created=%3E%3D$SINCE&per_page=100" \ - --jq '.workflow_runs[] | {display_title, created_at}' | - jq -s --arg prefix "${AUTOPARITY_PREFIX}-" \ - '[.[] | select((.display_title // "") | startswith($prefix))]') + --jq '.workflow_runs[] | {display_title, created_at, actor: .actor.login}' | + jq -s '.') sha_already_dispatched() { local sha="$1" echo "$EXISTING" | jq -e --arg sha "$sha" \ - 'any(.[]; (.display_title // "") | contains($sha))' >/dev/null + 'any(.[]; ((.display_title // "") | contains($sha)) and (((.display_title // "") | startswith("autoparity-")) or (.actor == "github-actions[bot]")))' >/dev/null } DISPATCHED_COUNT=0 @@ -147,7 +148,6 @@ jobs: continue fi - CSV_NAME="${AUTOPARITY_PREFIX}-$(date -u +%Y%m%d)-$SHA" echo "[$SHORT] completed trunk run at $DATE - ready for $PARITY_ARCH default parity" if [ "$DRY_RUN" = "true" ]; then @@ -159,8 +159,7 @@ jobs: -f sha="$SHA" \ -f arch="$PARITY_ARCH" \ -f exclude_distributed=true \ - -f exclude_inductor=true \ - -f csv_name="$CSV_NAME" + -f exclude_inductor=true fi DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) From d7ff112ed2257d50c0031bdd24d25675b3f89e52 Mon Sep 17 00:00:00 2001 From: ethanwee1 Date: Thu, 28 May 2026 18:16:09 +0000 Subject: [PATCH 5/5] [CI] Keep multi-arch auto parity dispatch Restore the auto parity ready-arch dispatch behavior while preserving parity.yml's default CSV naming by omitting csv_name. --- .github/workflows/parity-auto.yml | 555 +++++++++++++++++++----------- 1 file changed, 358 insertions(+), 197 deletions(-) diff --git a/.github/workflows/parity-auto.yml b/.github/workflows/parity-auto.yml index 0a101c6423076..7f60256257df3 100644 --- a/.github/workflows/parity-auto.yml +++ b/.github/workflows/parity-auto.yml @@ -1,197 +1,358 @@ -name: Parity Auto Trigger -run-name: "Parity auto-trigger · pytorch/pytorch main" - -# Polls completed pytorch/pytorch trunk.yml pushes on main and dispatches -# parity.yml once per upstream SHA. This PR intentionally scopes automation to -# the trunk/default mi355 report; other ROCm arch/workflow coverage can be added -# later without exposing workflow/job regexes as manual inputs. - -on: - schedule: - - cron: '*/10 * * * *' - pull_request: - paths: - - '.github/workflows/parity-auto.yml' - workflow_dispatch: - inputs: - max_commits: - description: 'How many recent completed upstream trunk.yml pushes on main to scan.' - required: false - default: '200' - type: string - max_dispatches: - description: 'Maximum number of ready upstream commits to dispatch in one scan.' - required: false - default: '50' - type: string - max_age_hours: - description: 'Skip commits older than this.' - required: false - default: '72' - type: string - target_ref: - description: 'Ref of this repo to dispatch parity.yml against. Leave blank to use this workflow run ref.' - required: false - default: '' - type: string - dry_run: - description: 'Scan and log, but do not dispatch parity.yml.' - required: false - default: false - type: boolean - -permissions: - contents: read - actions: write - -concurrency: - group: parity-auto-trigger - cancel-in-progress: false - -jobs: - scan-and-dispatch: - runs-on: ubuntu-latest - steps: - - name: Find completed trunk commits and dispatch parity.yml - env: - GH_TOKEN: ${{ github.token }} - UPSTREAM: pytorch/pytorch - BRANCH: main - PARITY_ARCH: mi355 - MAX_COMMITS: ${{ github.event_name == 'pull_request' && '20' || inputs.max_commits || '200' }} - MAX_DISPATCHES: ${{ github.event_name == 'pull_request' && '5' || inputs.max_dispatches || '50' }} - MAX_AGE_HOURS: ${{ inputs.max_age_hours || '72' }} - TARGET_REF_IN: ${{ inputs.target_ref || '' }} - DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || inputs.dry_run || 'false' }} - run: | - set -euo pipefail - - case "$MAX_COMMITS" in ''|*[!0-9]*) echo "::error::max_commits must be numeric"; exit 1 ;; esac - case "$MAX_DISPATCHES" in ''|*[!0-9]*) echo "::error::max_dispatches must be numeric"; exit 1 ;; esac - case "$MAX_AGE_HOURS" in ''|*[!0-9]*) echo "::error::max_age_hours must be numeric"; exit 1 ;; esac - - NOW_EPOCH=$(date -u +%s) - MAX_AGE_EPOCH=$((NOW_EPOCH - MAX_AGE_HOURS * 3600)) - SINCE=$(date -u -d "@$MAX_AGE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ') - TARGET_REF="${TARGET_REF_IN:-$GITHUB_REF_NAME}" - - echo "Upstream: $UPSTREAM@$BRANCH" - echo "Target ref: $TARGET_REF" - echo "Parity arch: $PARITY_ARCH" - echo "Max commits: $MAX_COMMITS" - echo "Max dispatches: $MAX_DISPATCHES" - echo "Max age: ${MAX_AGE_HOURS}h" - echo "Dry run: $DRY_RUN" - echo - - # Completed trunk workflow runs are the readiness source for this PR: - # no separate check-run regex matching is needed for trunk-only default - # reports. Page manually so MAX_COMMITS can exceed GitHub's per_page - # cap without walking the entire workflow history. - COMMITS_JSON='[]' - PAGE=1 - while [ "$(echo "$COMMITS_JSON" | jq 'length')" -lt "$MAX_COMMITS" ]; do - PAGE_RUNS=$(gh api \ - "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=100&page=$PAGE" \ - --jq '.workflow_runs | map({head_sha, created_at})') - if [ "$(echo "$PAGE_RUNS" | jq 'length')" -eq 0 ]; then - break - fi - COMMITS_JSON=$(jq -s --arg max "$MAX_COMMITS" ' - (.[0] + .[1]) as $runs - | reduce $runs[] as $run ({seen:{}, rows:[]}; - if .seen[$run.head_sha] then . - else .seen[$run.head_sha] = true | .rows += [$run] - end - ) - | .rows[:($max | tonumber)] - ' <(echo "$COMMITS_JSON") <(echo "$PAGE_RUNS")) - PAGE=$((PAGE + 1)) - done - COMMITS=$(echo "$COMMITS_JSON" | jq -r '.[] | "\(.head_sha) \(.created_at)"') - - if [ -z "$COMMITS" ]; then - echo "::warning::No completed trunk.yml push runs returned from $UPSTREAM@$BRANCH" - exit 0 - fi - - # Deduplicate auto-parity-created parity runs without changing - # parity.yml's own output naming. New auto-dispatched runs are created - # by github-actions[bot]; keep the old autoparity-* title match so - # runs created before this workflow stopped passing csv_name still - # suppress duplicate dispatches. - EXISTING=$(gh api --paginate \ - "repos/$GITHUB_REPOSITORY/actions/workflows/parity.yml/runs?event=workflow_dispatch&created=%3E%3D$SINCE&per_page=100" \ - --jq '.workflow_runs[] | {display_title, created_at, actor: .actor.login}' | - jq -s '.') - - sha_already_dispatched() { - local sha="$1" - echo "$EXISTING" | jq -e --arg sha "$sha" \ - 'any(.[]; ((.display_title // "") | contains($sha)) and (((.display_title // "") | startswith("autoparity-")) or (.actor == "github-actions[bot]")))' >/dev/null - } - - DISPATCHED_COUNT=0 - DISPATCHED_SUMMARY="" - while IFS=' ' read -r SHA DATE; do - [ -z "$SHA" ] && continue - SHORT=$(echo "$SHA" | cut -c1-8) - COMMIT_EPOCH=$(date -u -d "$DATE" +%s 2>/dev/null || echo 0) - - if [ "$COMMIT_EPOCH" -ne 0 ] && [ "$COMMIT_EPOCH" -lt "$MAX_AGE_EPOCH" ]; then - echo "[$SHORT] $DATE too old (>${MAX_AGE_HOURS}h) - stopping scan" - break - fi - - if sha_already_dispatched "$SHA"; then - echo "[$SHORT] auto parity report already exists for this SHA - skip" - continue - fi - - echo "[$SHORT] completed trunk run at $DATE - ready for $PARITY_ARCH default parity" - - if [ "$DRY_RUN" = "true" ]; then - echo "[$SHORT] DRY_RUN=true - would dispatch parity.yml" - else - gh workflow run parity.yml \ - --repo "$GITHUB_REPOSITORY" \ - --ref "$TARGET_REF" \ - -f sha="$SHA" \ - -f arch="$PARITY_ARCH" \ - -f exclude_distributed=true \ - -f exclude_inductor=true - fi - - DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) - DISPATCHED_SUMMARY="${DISPATCHED_SUMMARY}${SHORT}:${PARITY_ARCH}"$'\n' - if [ "$DISPATCHED_COUNT" -ge "$MAX_DISPATCHES" ]; then - echo "Reached max dispatches for this scan ($MAX_DISPATCHES); stopping" - break - fi - done <<< "$COMMITS" - - { - echo "### Parity auto-trigger" - echo "" - echo "- Upstream: \`$UPSTREAM@$BRANCH\`" - echo "- Scope: completed \`trunk.yml\` pushes" - echo "- Parity report: \`$PARITY_ARCH\` default tests" - echo "- Max commits: $MAX_COMMITS" - echo "- Max dispatches: $MAX_DISPATCHES" - echo "- Max age: ${MAX_AGE_HOURS}h" - echo "- Target ref: \`$TARGET_REF\`" - if [ "$DISPATCHED_COUNT" -gt 0 ]; then - if [ "$DRY_RUN" = "true" ]; then - echo "- Result: would dispatch $DISPATCHED_COUNT parity run(s) (dry-run)" - else - echo "- Result: dispatched $DISPATCHED_COUNT parity run(s)" - fi - echo "" - echo "$DISPATCHED_SUMMARY" | while IFS= read -r LINE; do - [ -z "$LINE" ] && continue - echo "- $LINE" - done - else - echo "- Result: no ready unprocessed SHAs found" - fi - } >> "$GITHUB_STEP_SUMMARY" +name: Parity Auto Trigger +run-name: "Parity auto-trigger · pytorch/pytorch main" + +# Polls completed pytorch/pytorch trunk.yml pushes on main and dispatches +# parity.yml once for each SHA where all CI consumed by the report has finished, +# covering every arch whose test shards actually ran on it. +# +# Arch participation is detected at the workflow_run level: if rocm-mi300 +# never ran on a SHA, we don't wait for it or include it. Readiness is then +# evaluated at the *check-run* level because ROCm test shards post check-runs +# independently, and a single failing shard flips the parent workflow_run to +# conclusion=failure while siblings are still executing. +# +# Two gates: +# 1. Detect which ROCm arch workflows actually ran on this SHA. +# 2. Require every test check-run for those ROCm arch workflows, plus every +# CUDA test check-run consumed by download_testlogs, to be status=completed. +# We don't want to dispatch mi355 while a mi300 workflow that ran on the +# same SHA is still creating or running test shards. +# +# We dispatch at most once per SHA with the ready subset of arches, so mi355 +# (run as part of trunk) gets a parity report per commit, and mi300/ +# mi200 join the same dispatch whenever their periodic workflow +# happens to finish on that SHA. + +on: + schedule: + - cron: '*/10 * * * *' + pull_request: + paths: + - '.github/workflows/parity-auto.yml' + workflow_dispatch: + inputs: + max_commits: + description: 'How many of the most recent completed upstream trunk.yml pushes on main to scan.' + required: false + default: '200' + type: string + max_dispatches: + description: 'Maximum number of ready upstream commits to dispatch in one scan.' + required: false + default: '50' + type: string + max_age_hours: + description: 'Skip commits older than this (avoid back-filling ancient SHAs).' + required: false + default: '72' + type: string + archs: + description: 'Architectures to consider (comma/space separated).' + required: false + default: 'mi355, mi300, mi200' + type: string + arch_jobname_regex_map: + description: 'JSON: arch -> PCRE regex that matches the check-run names of that arch''s ROCm test shards on pytorch/pytorch. An arch is considered "ready" only when every check-run whose name matches has status=completed (so we wait for all test shards, not just workflow completion).' + required: false + default: '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' + type: string + arch_workflow_regex_map: + description: 'JSON: arch -> PCRE regex that matches workflow file paths for upstream ROCm workflows that mean this arch ran on the SHA. Missing workflows mean the arch is not expected for that commit.' + required: false + default: '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' + type: string + target_ref: + description: 'Ref of this repo to dispatch parity.yml against. Leave blank to use this workflow run''s ref.' + required: false + default: '' + type: string + dry_run: + description: 'Scan and log, but do not actually dispatch parity.yml.' + required: false + default: false + type: boolean + +permissions: + contents: read + actions: write + +concurrency: + group: parity-auto-trigger + cancel-in-progress: false + +jobs: + scan-and-dispatch: + runs-on: ubuntu-latest + steps: + - name: Find ready arches per upstream commit and dispatch parity.yml + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM: pytorch/pytorch + BRANCH: main + MAX_COMMITS: ${{ github.event_name == 'pull_request' && '20' || inputs.max_commits || '200' }} + MAX_DISPATCHES: ${{ github.event_name == 'pull_request' && '5' || inputs.max_dispatches || '50' }} + MAX_AGE_HOURS: ${{ inputs.max_age_hours || '72' }} + ARCHS_IN: ${{ inputs.archs || 'mi355, mi300, mi200' }} + ARCH_JOBNAME_REGEX_MAP: ${{ inputs.arch_jobname_regex_map || '{"mi355":"rocm.*mi355.*/ test [(](default|distributed|inductor),","mi300":"rocm.*mi300.*/ test [(](default|distributed|inductor),","mi200":"(rocm.*(mi200|mi210).*/ test [(](default|distributed|inductor),|linux-jammy-rocm-py3[.]10 / test [(](default|distributed|inductor),)","navi31":"rocm.*navi31.*/ test [(]default,","nightly":"rocm-nightly.*/ test [(](default|distributed|inductor),"}' }} + ARCH_WORKFLOW_REGEX_MAP: ${{ inputs.arch_workflow_regex_map || '{"mi355":"(^|/)(trunk|rocm-mi355|periodic-rocm-mi355|inductor-rocm-mi355)[.]yml$","mi300":"(^|/)(rocm-mi300|periodic-rocm-mi300|inductor-rocm-mi300)[.]yml$","mi200":"(^|/)(trunk-rocm-sandbox|rocm-mi200|periodic-rocm-mi200|inductor-rocm-mi200)[.]yml$","navi31":"(^|/)(rocm-navi31|periodic-rocm-navi31|inductor-rocm-navi31)[.]yml$","nightly":"(^|/)rocm-nightly[.]yml$"}' }} + TARGET_REF_IN: ${{ inputs.target_ref || '' }} + DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || inputs.dry_run || 'false' }} + run: | + # GitHub Actions launches this with `bash -e {0}`, so -e is already on + # from the shebang. It's too aggressive for the many pipelines here + # (grep -q returning 1, date -d edge cases, paginated API calls, + # etc.) and has caused the loop to silently abort after the first + # "no ready archs" commit. Explicitly turn -e OFF and keep -u + + # pipefail so undefined-variable bugs still surface. + set +e + set -uo pipefail + + NOW_EPOCH=$(date -u +%s) + MAX_AGE_EPOCH=$((NOW_EPOCH - MAX_AGE_HOURS * 3600)) + TARGET_REF="${TARGET_REF_IN:-$GITHUB_REF_NAME}" + ARCHS=$(echo "$ARCHS_IN" | tr ',' ' ' | xargs) + + echo "Upstream: $UPSTREAM@$BRANCH" + echo "Target ref: $TARGET_REF" + echo "Scope archs: $ARCHS" + echo "Max trunk runs: $MAX_COMMITS" + echo "Max dispatches: $MAX_DISPATCHES" + echo "Max age: ${MAX_AGE_HOURS}h" + echo "Dry run: $DRY_RUN" + echo "Arch->jobs: $ARCH_JOBNAME_REGEX_MAP" + echo "Arch->workflows: $ARCH_WORKFLOW_REGEX_MAP" + echo + + # --- 1. Recent completed upstream trunk pushes ----------------------- + # Use trunk.yml as the candidate source instead of raw main commits. + # The parity report consumes trunk's CUDA/ROCm jobs, so a completed + # trunk push is the first point where a SHA can reasonably be ready. + COMMITS_JSON='[]' + PAGE=1 + while [ "$(echo "$COMMITS_JSON" | jq 'length')" -lt "$MAX_COMMITS" ]; do + PAGE_RUNS=$(gh api \ + "repos/$UPSTREAM/actions/workflows/trunk.yml/runs?branch=$BRANCH&event=push&status=completed&per_page=100&page=$PAGE" \ + --jq '.workflow_runs | map({head_sha, created_at})') + if [ "$(echo "$PAGE_RUNS" | jq 'length')" -eq 0 ]; then + break + fi + COMMITS_JSON=$(jq -s --arg max "$MAX_COMMITS" ' + (.[0] + .[1]) as $runs + | reduce $runs[] as $run ({seen:{}, rows:[]}; + if .seen[$run.head_sha] then . + else .seen[$run.head_sha] = true | .rows += [$run] + end + ) + | .rows[:($max | tonumber)] + ' <(echo "$COMMITS_JSON") <(echo "$PAGE_RUNS")) + PAGE=$((PAGE + 1)) + done + COMMITS=$(echo "$COMMITS_JSON" | jq -r '.[] | "\(.head_sha) \(.created_at)"') + + if [ -z "$COMMITS" ]; then + echo "::warning::No completed trunk.yml push runs returned from $UPSTREAM@$BRANCH" + exit 0 + fi + + # --- 2. Already-dispatched SHAs in our repo -------------------------- + # Deduplicate auto-parity-created parity runs without changing + # parity.yml's own output naming. New auto-dispatched runs are created + # by github-actions[bot]; keep the old autoparity-* title match so + # runs created before this workflow stopped passing csv_name still + # suppress duplicate dispatches. + EXISTING=$(gh api --paginate \ + "repos/$GITHUB_REPOSITORY/actions/workflows/parity.yml/runs?event=workflow_dispatch&created=%3E%3D$(date -u -d "@$MAX_AGE_EPOCH" '+%Y-%m-%dT%H:%M:%SZ')&per_page=100" \ + --jq '.workflow_runs[] | {display_title, actor: .actor.login}' | + jq -s '.') + + sha_already_dispatched() { + local sha="$1" + echo "$EXISTING" | jq -e --arg sha "$sha" \ + 'any(.[]; ((.display_title // "") | contains($sha)) and (((.display_title // "") | startswith("autoparity-")) or (.actor == "github-actions[bot]")))' >/dev/null + } + + # --- 3. Walk trunk SHAs, dispatch each ready unprocessed SHA --------- + DISPATCHED_COUNT=0 + DISPATCHED_SUMMARY="" + while IFS=' ' read -r SHA DATE; do + [ -z "$SHA" ] && continue + SHORT=$(echo "$SHA" | cut -c1-8) + COMMIT_EPOCH=$(date -u -d "$DATE" +%s 2>/dev/null || echo 0) + + if [ "$COMMIT_EPOCH" -ne 0 ] && [ "$COMMIT_EPOCH" -lt "$MAX_AGE_EPOCH" ]; then + echo "[$SHORT] $DATE too old (>${MAX_AGE_HOURS}h) - stopping scan" + break + fi + + if sha_already_dispatched "$SHA"; then + echo "[$SHORT] parity report already exists for this SHA - skip" + continue + fi + + # First determine which ROCm arch workflows actually ran on this + # SHA. If a periodic arch workflow never ran, the arch is not + # expected for the report. If it did run, we must wait for its + # matching test shards below. + ALL_WORKFLOW_RUNS=$(gh api --paginate \ + "repos/$UPSTREAM/actions/runs?head_sha=$SHA&per_page=100" \ + --jq '.workflow_runs[] | {name,path,status,conclusion}' \ + 2>/dev/null | jq -s '.' || echo '[]') + + RUN_ARCHS="" + NOT_RUN_NOTES="" + for ARCH in $ARCHS; do + WF_REGEX=$(echo "$ARCH_WORKFLOW_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + if [ -z "$WF_REGEX" ]; then + NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow-regex" + continue + fi + WF_TOTAL=$(echo "$ALL_WORKFLOW_RUNS" | jq --arg rx "$WF_REGEX" \ + 'map(select((.path // "") | test($rx))) | length') + if [ "$WF_TOTAL" -eq 0 ]; then + NOT_RUN_NOTES="$NOT_RUN_NOTES $ARCH:no-workflow" + else + RUN_ARCHS="$RUN_ARCHS $ARCH" + fi + done + RUN_ARCHS=$(echo "$RUN_ARCHS" | xargs) + NOT_RUN_NOTES=$(echo "$NOT_RUN_NOTES" | xargs) + + if [ -z "$RUN_ARCHS" ]; then + echo "[$SHORT] $DATE no in-scope ROCm workflows ran on upstream (${NOT_RUN_NOTES:-none}) - skip" + continue + fi + + # Pull relevant upstream check-runs for this SHA. Test shards post + # check-runs independently, and workflow_run conclusion can flip to + # failure before sibling shards finish. We need per-shard state. + ALL_CHECK_RUNS=$(gh api --paginate \ + "repos/$UPSTREAM/commits/$SHA/check-runs?per_page=100" \ + --jq '.check_runs[] | {name,status,conclusion}' \ + 2>/dev/null | jq -s '.' || echo '[]') + + CHECK_RUNS='[]' + for ARCH in $RUN_ARCHS; do + REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + [ -z "$REGEX" ] && continue + ARCH_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$REGEX" \ + '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') + CHECK_RUNS=$(jq -s 'add | unique_by(.name)' \ + <(echo "$CHECK_RUNS") \ + <(echo "$ARCH_CHECK_RUNS")) + done + + CUDA_JOBNAME_REGEX='(linux-jammy-cuda13[.]0-py3[.]10-gcc11 / (test-osdc|test) [(](default|distributed),|unit-test / inductor-test / (test-osdc|test) [(]inductor,)' + CUDA_CHECK_RUNS=$(echo "$ALL_CHECK_RUNS" | jq --arg rx "$CUDA_JOBNAME_REGEX" \ + '[.[] | select((.name | test($rx)) and (.name | test("mem_leak_check|rerun_disabled_tests") | not))]') + + if [ "$(echo "$CHECK_RUNS" | jq 'length')" -eq 0 ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no parity check-runs yet - skip" + continue + fi + + if [ "$(echo "$CUDA_CHECK_RUNS" | jq 'length')" -eq 0 ]; then + echo "[$SHORT] $DATE no CUDA parity check-runs yet on upstream - skip" + continue + fi + + # Gate 1: require EVERY upstream check-run consumed by the + # parity report for this SHA to be status=completed (ROCm test + # shards for arch workflows that ran, plus CUDA default/ + # distributed/inductor tests). Once we dispatch for a SHA the + # parity report is authored, so dispatching before CUDA or + # another arch finishes produces partial reports. + GATE_CHECK_RUNS=$(jq -s 'add' \ + <(echo "$CHECK_RUNS") \ + <(echo "$CUDA_CHECK_RUNS")) + TOTAL_CR=$(echo "$GATE_CHECK_RUNS" | jq 'length') + PENDING_CR=$(echo "$GATE_CHECK_RUNS" | jq 'map(select(.status != "completed")) | length') + if [ "$PENDING_CR" -ne 0 ]; then + PENDING_SAMPLE=$(echo "$GATE_CHECK_RUNS" | jq -r ' + map(select(.status != "completed")) + | .[0:3] + | map(.name) + | join(", ")') + echo "[$SHORT] $DATE ${PENDING_CR}/${TOTAL_CR} parity check-runs still pending - skip (e.g. $PENDING_SAMPLE)" + continue + fi + + # Gate 2: every arch workflow that ran on this SHA must have + # matching test shards before we author the one-and-only report + # for the SHA. Missing arch workflows are not expected; missing + # shards for a workflow that ran means the workflow is not ready. + READY="" + NOT_READY_NOTES="" + for ARCH in $RUN_ARCHS; do + REGEX=$(echo "$ARCH_JOBNAME_REGEX_MAP" | jq -r --arg a "$ARCH" '.[$a] // ""') + if [ -z "$REGEX" ]; then + NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:no-regex" + continue + fi + TOTAL=$(echo "$CHECK_RUNS" | jq --arg rx "$REGEX" \ + 'map(select(.name | test($rx))) | length') + if [ "$TOTAL" -eq 0 ]; then + NOT_READY_NOTES="$NOT_READY_NOTES $ARCH:workflow-run-no-shards-yet" + else + READY="$READY $ARCH" + fi + done + READY=$(echo "$READY" | xargs) + NOT_READY_NOTES=$(echo "$NOT_READY_NOTES" | xargs) + + if [ -n "$NOT_READY_NOTES" ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but some test shards are missing - skip (${NOT_READY_NOTES})" + continue + fi + + if [ -z "$READY" ]; then + echo "[$SHORT] $DATE ROCm workflows ran ($RUN_ARCHS) but no in-scope arches are ready" + continue + fi + + ARCH_DISPATCH=$(echo "$READY" | sed 's/ /, /g') + echo "[$SHORT] READY archs: '$(echo "$READY" | tr ' ' ',')' (committed $DATE; not-run: ${NOT_RUN_NOTES:-none})" + echo "[$SHORT] dispatching for: '$(echo "$READY" | tr ' ' ',')'" + + if [ "$DRY_RUN" = "true" ]; then + echo "[$SHORT] DRY_RUN=true - not dispatching" + else + gh workflow run parity.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$TARGET_REF" \ + -f sha="$SHA" \ + -f arch="$ARCH_DISPATCH" + fi + + DISPATCHED_COUNT=$((DISPATCHED_COUNT + 1)) + DISPATCHED_SUMMARY="${DISPATCHED_SUMMARY}${SHORT}:${ARCH_DISPATCH}"$'\n' + if [ "$DISPATCHED_COUNT" -ge "$MAX_DISPATCHES" ]; then + echo "Reached max dispatches for this scan ($MAX_DISPATCHES); stopping" + break + fi + done <<< "$COMMITS" + + # --- 4. Summary ------------------------------------------------------- + { + echo "### Parity auto-trigger" + echo "" + echo "- Upstream: \`$UPSTREAM@$BRANCH\`" + echo "- Scope archs: \`$ARCHS\`" + echo "- Max commits: $MAX_COMMITS" + echo "- Max dispatches: $MAX_DISPATCHES" + echo "- Max age: ${MAX_AGE_HOURS}h" + echo "- Target ref: \`$TARGET_REF\`" + if [ "$DISPATCHED_COUNT" -gt 0 ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "- Result: would dispatch $DISPATCHED_COUNT parity run(s) (dry-run)" + else + echo "- Result: dispatched $DISPATCHED_COUNT parity run(s)" + fi + echo "" + echo "$DISPATCHED_SUMMARY" | while IFS= read -r LINE; do + [ -z "$LINE" ] && continue + echo "- $LINE" + done + else + echo "- Result: no ready unprocessed SHAs found" + fi + } >> "$GITHUB_STEP_SUMMARY"