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: # 1. for commits on main: no gating needed # 2. for workflow_dispatch: this can only be triggered by users with write access runs-on: ubuntu-latest steps: - name: Fetch latest PR info if: github.event_name == 'pull_request' id: pr 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 if: github.event_name == 'pull_request' 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' && inputs.rate-limit-hours > 0 uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DEFAULT_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 }); let triggeringActor = curRun.data.triggering_actor?.login || context.actor; if (triggeringActor === "github-actions[bot]") { triggeringActor = `${{ steps.pr.outputs.user }}`; core.info( `triggering_actor is github-actions[bot]; substituting PR author '${triggeringActor}'.` ); } 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; } let effectiveCooldownMinutes = DEFAULT_HOURS * 60; let perUserCooldownMinutes = null; try { const contentResp = await github.rest.repos.getContent({ owner, repo, path: ".github/CI_PERMISSIONS.json", ref: "main", }); if (!Array.isArray(contentResp.data) && contentResp.data && "content" in contentResp.data) { const raw = Buffer.from( contentResp.data.content, contentResp.data.encoding || "base64" ).toString(); const ciPermissions = JSON.parse(raw); const userPerm = ciPermissions[triggeringActor]; if (userPerm && typeof userPerm.cooldown_interval_minutes === "number") { perUserCooldownMinutes = userPerm.cooldown_interval_minutes; core.info( `Per-user cooldown for '${triggeringActor}' from CI_PERMISSIONS.json: ${perUserCooldownMinutes} minutes.` ); } else { core.info(`No per-user cooldown found for '${triggeringActor}' in CI_PERMISSIONS.json.`); } } else { core.info("CI_PERMISSIONS.json content response is not a file; skipping per-user cooldown."); } } catch (e) { core.info(`CI_PERMISSIONS.json not found or unreadable: ${e.message}. Using default rate limit only.`); } if (perUserCooldownMinutes !== null) { effectiveCooldownMinutes = Math.min(effectiveCooldownMinutes, perUserCooldownMinutes); } if (effectiveCooldownMinutes <= 0) { core.info( `Effective cooldown for '${triggeringActor}' is 0 minutes; no rate limit enforced for this user.` ); return; } const cutoff = new Date(Date.now() - effectiveCooldownMinutes * 60 * 1000); core.info( `Checking for workflow runs since ${cutoff.toISOString()} (last ${effectiveCooldownMinutes} minutes) 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 ${effectiveCooldownMinutes} minutes before triggering again.` ); } else { core.info( `No recent runs detected for '${triggeringActor}' within the last ${effectiveCooldownMinutes} minutes; proceeding.` ); }