on: workflow_call: inputs: require-run-ci: description: "Whether the PR must have the run-ci label" type: boolean default: true rate-limit-hours: description: "Rate limit window size in hours; 0 disables rate limiting" type: number default: 2 jobs: pr-gate: runs-on: ubuntu-latest steps: - name: Fetch latest PR info id: pr if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number }); core.setOutput("labels", JSON.stringify(pr.data.labels.map(l => l.name))); core.setOutput("draft", pr.data.draft); core.setOutput("user", pr.data.user.login); - name: Log PR info run: | echo "===== PR Info =====" echo "PR Event: ${{ github.event_name }}" echo "PR Labels: ${{ steps.pr.outputs.labels }}" echo "PR Draft: ${{ steps.pr.outputs.draft }}" echo "PR User: ${{ steps.pr.outputs.user }}" echo "Require run-ci: ${{ inputs.require-run-ci }}" echo "Rate limit hours: ${{ inputs.rate-limit-hours }}" echo "===================" - name: Block draft PR if: github.event_name == 'pull_request' && fromJson(steps.pr.outputs.draft) run: | echo "PR is draft. Blocking CI." exit 1 - name: Require run-ci label (optional) if: github.event_name == 'pull_request' && inputs.require-run-ci == true run: | labels='${{ steps.pr.outputs.labels }}' if [[ "${{ contains(fromJson(steps.pr.outputs.labels), 'run-ci') }}" == "false" ]]; then echo "Missing required label 'run-ci'." exit 1 fi - name: Enforce rate limit for low-permission actors (optional) if: (github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && inputs.rate-limit-hours != 0 uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const HOURS = Number("${{ inputs.rate-limit-hours }}"); const owner = context.repo.owner; const repo = context.repo.repo; const eventName = context.eventName; const curRun = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId }); const triggeringActor = curRun.data.triggering_actor?.login || context.actor; async function hasHighPermission(username) { try { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); const perm = data.permission || 'none'; return perm === 'write' || perm === 'maintain' || perm === 'admin'; } catch (e) { if (e.status === 404 || e.status === 403) return false; throw e; } } if (await hasHighPermission(triggeringActor)) { core.info(`Triggering user '${triggeringActor}' has high permission. No rate limit applied.`); return; } const cutoff = new Date(Date.now() - HOURS * 60 * 60 * 1000); core.info(`Checking for workflow runs since ${cutoff.toISOString()} (last ${HOURS} hours) for event '${eventName}'.`); const { data } = await github.rest.actions.listWorkflowRuns({ owner, repo, workflow_id: 'pr-test.yml', event: eventName, per_page: 100, }); const runs = data.workflow_runs || []; const recentFound = runs.find((run) => { if (String(run.id) === String(context.runId)) return false; if (new Date(run.created_at) < cutoff) return false; return (run.actor?.login === triggeringActor) || (run.triggering_actor?.login === triggeringActor); }); if (recentFound) { core.setFailed( `User '${triggeringActor}' already triggered '${context.workflow}' via '${eventName}' at ${recentFound.created_at}. ` + `Please wait ${HOURS} hours before triggering again.` ); } else { core.info(`No recent runs detected within the last ${HOURS} hours; proceeding.`); }