Pull request dashboard #198706
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Pull request dashboard | |
| on: | |
| schedule: | |
| - cron: "0 * * * *" # hourly | |
| workflow_dispatch: | |
| inputs: | |
| repository: | |
| description: Target repository. Empty means all configured repositories. | |
| required: false | |
| type: string | |
| pr_number: | |
| description: Pull request number to refresh. Empty means backfill. | |
| required: false | |
| type: string | |
| head_sha: | |
| description: Head commit to resolve to a pull request when the event carries no number. | |
| required: false | |
| type: string | |
| trigger_event: | |
| description: Event that requested the refresh. | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| # During organization-wide runner queueing, redundant webhook runs can | |
| # accumulate into a large backlog before job-level concurrency applies. | |
| # | |
| # Dashboard state is loaded live, so only the latest pending webhook refresh is | |
| # needed. Manual runs remain separate because they can refresh large | |
| # repositories that webhooks skip. Submitted reviews can coalesce with generic | |
| # PR refreshes because the live status comment no longer depends on a specific | |
| # review id. | |
| concurrency: | |
| group: >- | |
| pull-request-dashboard-${{ inputs.repository || 'all-repositories' }}-${{ inputs.pr_number || inputs.head_sha || 'backfill' }}-${{ github.event_name == 'workflow_dispatch' && (inputs.trigger_event == '' || inputs.trigger_event == 'workflow_dispatch') && 'manual' || 'refresh' }} | |
| cancel-in-progress: false | |
| env: | |
| DASHBOARD_CONFIG: .github/scripts/pull-request-dashboard/repositories.json | |
| # Repositories that run dashboard code from the triggering commit, ahead of | |
| # everyone else. Job-level `if` cannot read `env`, so the targeted canary job | |
| # repeats this list inline; test_rollout.py keeps the two in sync. | |
| CANARY_REPOSITORIES: '["opentelemetry-java-instrumentation", "shared-workflows"]' | |
| jobs: | |
| resolve-targets: | |
| # Targeted refreshes go straight to the reusable workflow, which resolves | |
| # its own configuration. Only backfills need a matrix resolved up front. | |
| if: inputs.head_sha == '' && (inputs.repository == '' || inputs.pr_number == '') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| environment: protected | |
| outputs: | |
| canary_matrix: ${{ steps.targets.outputs.canary_matrix }} | |
| canary_count: ${{ steps.targets.outputs.canary_count }} | |
| stable_matrix: ${{ steps.targets.outputs.stable_matrix }} | |
| stable_count: ${{ steps.targets.outputs.stable_count }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Resolve trigger inputs | |
| env: | |
| TARGET_REPOSITORY_FROM_INPUT: ${{ inputs.repository }} | |
| PR_FROM_INPUT: ${{ inputs.pr_number }} | |
| TRIGGER_EVENT_FROM_INPUT: ${{ inputs.trigger_event }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "$PR_FROM_INPUT" ]]; then | |
| [[ -n "$TARGET_REPOSITORY_FROM_INPUT" ]] || { echo "repository is required when pr_number is set"; exit 1; } | |
| fi | |
| if [[ -n "$TRIGGER_EVENT_FROM_INPUT" ]]; then | |
| [[ "$TRIGGER_EVENT_FROM_INPUT" =~ ^(schedule|workflow_dispatch|check_suite|status|pull_request|issue_comment|pull_request_review|pull_request_review_comment|pull_request_review_thread)$ ]] \ | |
| || { echo "bad trigger event: $TRIGGER_EVENT_FROM_INPUT"; exit 1; } | |
| fi | |
| - name: Resolve target repositories | |
| id: targets | |
| env: | |
| TARGET_REPOSITORY: ${{ inputs.repository }} | |
| run: | | |
| set -euo pipefail | |
| selected=$(jq -c --arg repo "$TARGET_REPOSITORY" ' | |
| if $repo == "" then | |
| . | |
| else | |
| [ .[] | select(.name == $repo) ] | |
| end | |
| | [ .[] | {name} ] | |
| ' "$DASHBOARD_CONFIG") | |
| if [[ "$selected" == "[]" ]]; then | |
| echo "no configured repository matched: ${TARGET_REPOSITORY:-<all>}" >&2 | |
| exit 1 | |
| fi | |
| canary=$(jq -c --argjson canary "$CANARY_REPOSITORIES" ' | |
| [ .[] | select(.name as $name | $canary | index($name) != null) ] | |
| ' <<< "$selected") | |
| stable=$(jq -c --argjson canary "$CANARY_REPOSITORIES" ' | |
| [ .[] | select(.name as $name | $canary | index($name) == null) ] | |
| ' <<< "$selected") | |
| { | |
| echo "canary_matrix={\"include\":${canary}}" | |
| echo "canary_count=$(jq 'length' <<< "$canary")" | |
| echo "stable_matrix={\"include\":${stable}}" | |
| echo "stable_count=$(jq 'length' <<< "$stable")" | |
| } >> "$GITHUB_OUTPUT" | |
| jq -n --argjson canary "$canary" --argjson stable "$stable" \ | |
| '{canary: [$canary[].name], stable: [$stable[].name]}' | |
| resolve-head-sha: | |
| # Check and status events for a fork head carry no pull request number, so | |
| # the head commit has to be resolved before the reusable workflow runs. | |
| if: inputs.head_sha != '' && inputs.pr_number == '' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| environment: protected | |
| outputs: | |
| pr_number: ${{ steps.trigger.outputs.pr_number }} | |
| channel: ${{ steps.targets.outputs.channel }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Resolve trigger inputs | |
| id: targets | |
| env: | |
| TARGET_REPOSITORY: ${{ inputs.repository }} | |
| HEAD_SHA_FROM_INPUT: ${{ inputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| [[ -n "$TARGET_REPOSITORY" ]] || { echo "repository is required when head_sha is set"; exit 1; } | |
| [[ "$HEAD_SHA_FROM_INPUT" =~ ^[0-9a-f]{40}$ ]] || { echo "bad head SHA: $HEAD_SHA_FROM_INPUT"; exit 1; } | |
| name=$(jq -r --arg repo "$TARGET_REPOSITORY" ' | |
| [ .[] | select(.name == $repo) ] | first | .name // empty | |
| ' "$DASHBOARD_CONFIG") | |
| if [[ -z "$name" ]]; then | |
| echo "no configured repository matched: $TARGET_REPOSITORY" >&2 | |
| exit 1 | |
| fi | |
| echo "repository=$name" >> "$GITHUB_OUTPUT" | |
| if jq -e -n --arg repo "$name" --argjson canary "$CANARY_REPOSITORIES" \ | |
| '$canary | index($repo) != null' > /dev/null; then | |
| echo "channel=canary" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "channel=stable" >> "$GITHUB_OUTPUT" | |
| fi | |
| - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| id: head-sha-token | |
| with: | |
| client-id: ${{ vars.PR_DASHBOARD_CLIENT_ID }} | |
| private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| owner: open-telemetry | |
| repositories: ${{ steps.targets.outputs.repository }} | |
| permission-pull-requests: read | |
| - name: Resolve head commit | |
| id: trigger | |
| env: | |
| HEAD_SHA: ${{ inputs.head_sha }} | |
| REPO_NAME: ${{ steps.targets.outputs.repository }} | |
| GH_TOKEN: ${{ steps.head-sha-token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| matches=$(gh api "repos/open-telemetry/${REPO_NAME}/commits/${HEAD_SHA}/pulls" \ | |
| | jq -r --arg sha "$HEAD_SHA" '[ .[] | select(.state == "open" and .head.sha == $sha) | .number ] | sort | join(" ")') | |
| pr_number="${matches%% *}" | |
| if [[ -z "$pr_number" ]]; then | |
| echo "no open pull request has head $HEAD_SHA; skipping refresh." | |
| elif [[ "$matches" == *" "* ]]; then | |
| # One head branch can be open against several base branches. The | |
| # rest keep refreshing on their own events and on the backfill. | |
| echo "head $HEAD_SHA belongs to open pull requests $matches; refreshing #$pr_number" | |
| else | |
| echo "resolved head $HEAD_SHA to pull request #$pr_number" | |
| fi | |
| echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" | |
| # Rollout channels. Canary repositories run the workflow and scripts at the | |
| # commit that triggered the run; every other repository runs them at the | |
| # promoted rollout ref, so a change reaches the fleet only after it has soaked | |
| # on the canary repositories. | |
| # | |
| # `uses` cannot take an expression, so each entry path has one job per | |
| # channel. Promoting means cutting a release and opening a pull request that | |
| # points every stable job at that release: | |
| # | |
| # uses: open-telemetry/shared-workflows/.github/workflows/pull-request-dashboard-repo.yml@<sha> # vX.Y.Z | |
| # with: | |
| # code_ref: <sha> # vX.Y.Z | |
| # | |
| # Both halves name the same commit so the scripts always match the workflow | |
| # YAML that invoked them; test_rollout.py fails if they disagree. | |
| run-repo-dashboard-canary: | |
| needs: resolve-targets | |
| if: needs.resolve-targets.outputs.canary_count != '0' | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| strategy: | |
| fail-fast: false | |
| # Repositories hold separate state branches, so they only contend for the | |
| # shared GitHub App rate limit and the Copilot CLI token. The canary set | |
| # is small enough that one at a time keeps its added contention with the | |
| # stable channel's slots negligible. | |
| max-parallel: 1 | |
| matrix: ${{ fromJSON(needs.resolve-targets.outputs.canary_matrix) }} | |
| uses: ./.github/workflows/pull-request-dashboard-repo.yml | |
| with: | |
| repository: ${{ matrix.name }} | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run-repo-dashboard-stable: | |
| needs: resolve-targets | |
| if: needs.resolve-targets.outputs.stable_count != '0' | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 3 | |
| matrix: ${{ fromJSON(needs.resolve-targets.outputs.stable_matrix) }} | |
| uses: open-telemetry/shared-workflows/.github/workflows/pull-request-dashboard-repo.yml@8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| with: | |
| repository: ${{ matrix.name }} | |
| code_ref: 8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run-targeted-dashboard-canary: | |
| # Skipping resolve-targets removes a runner acquisition from the highest | |
| # volume path, where webhook refreshes already know their single target. | |
| # That is also why the canary list is inlined here instead of resolved. | |
| if: >- | |
| inputs.repository != '' && | |
| inputs.pr_number != '' && | |
| contains(fromJSON('["opentelemetry-java-instrumentation", "shared-workflows"]'), inputs.repository) | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| uses: ./.github/workflows/pull-request-dashboard-repo.yml | |
| with: | |
| repository: ${{ inputs.repository }} | |
| pr_number: ${{ inputs.pr_number }} | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run-targeted-dashboard-stable: | |
| needs: run-targeted-dashboard-canary | |
| # Reading the skip keeps the canary list in one place, and a skipped | |
| # reusable workflow call acquires no runner. | |
| if: >- | |
| always() && | |
| inputs.repository != '' && | |
| inputs.pr_number != '' && | |
| needs.run-targeted-dashboard-canary.result == 'skipped' | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| uses: open-telemetry/shared-workflows/.github/workflows/pull-request-dashboard-repo.yml@8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| with: | |
| repository: ${{ inputs.repository }} | |
| pr_number: ${{ inputs.pr_number }} | |
| code_ref: 8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run-head-sha-dashboard-canary: | |
| needs: resolve-head-sha | |
| # A head commit that matches no open pull request is not a request to | |
| # refresh the whole repository. | |
| if: >- | |
| needs.resolve-head-sha.outputs.pr_number != '' && | |
| needs.resolve-head-sha.outputs.channel == 'canary' | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| uses: ./.github/workflows/pull-request-dashboard-repo.yml | |
| with: | |
| repository: ${{ inputs.repository }} | |
| pr_number: ${{ needs.resolve-head-sha.outputs.pr_number }} | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| run-head-sha-dashboard-stable: | |
| needs: resolve-head-sha | |
| if: >- | |
| needs.resolve-head-sha.outputs.pr_number != '' && | |
| needs.resolve-head-sha.outputs.channel == 'stable' | |
| permissions: | |
| contents: write | |
| copilot-requests: write | |
| uses: open-telemetry/shared-workflows/.github/workflows/pull-request-dashboard-repo.yml@8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| with: | |
| repository: ${{ inputs.repository }} | |
| pr_number: ${{ needs.resolve-head-sha.outputs.pr_number }} | |
| code_ref: 8079110d7e793bd38b1a5219ef8f8280dab9139e # v0.7.0 | |
| secrets: | |
| PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| notify-hourly-failure: | |
| needs: | |
| - resolve-targets | |
| - run-repo-dashboard-canary | |
| - run-repo-dashboard-stable | |
| # Notify for backfills: scheduled runs and unfiltered manual runs. | |
| if: >- | |
| always() && | |
| ( | |
| github.event_name == 'schedule' || | |
| ( | |
| github.event_name == 'workflow_dispatch' && | |
| inputs.repository == '' && | |
| inputs.pr_number == '' | |
| ) | |
| ) | |
| permissions: | |
| issues: write # needed to open/close the hourly failure tracking issue | |
| uses: ./.github/workflows/workflow-failure-issue.yml | |
| with: | |
| # GitHub replaces an older pending per-repo publisher even with | |
| # cancel-in-progress disabled. Its replacement drains all durable work, | |
| # while genuine matrix failures take precedence over cancellation. A | |
| # channel with no repositories is skipped, which is not a failure. | |
| success: ${{ needs.resolve-targets.result == 'success' && (needs.run-repo-dashboard-canary.result == 'success' || needs.run-repo-dashboard-canary.result == 'cancelled' || needs.run-repo-dashboard-canary.result == 'skipped') && (needs.run-repo-dashboard-stable.result == 'success' || needs.run-repo-dashboard-stable.result == 'cancelled' || needs.run-repo-dashboard-stable.result == 'skipped') }} |