diff --git a/.github/ROKT_SAFE_PR_GATE_SETUP.md b/.github/ROKT_SAFE_PR_GATE_SETUP.md new file mode 100644 index 000000000..80292c248 --- /dev/null +++ b/.github/ROKT_SAFE_PR_GATE_SETUP.md @@ -0,0 +1,118 @@ +# Rokt Safe PR Gate Setup + +The Gate is inert until the administrator setup below is complete. Complete the +proof of concept before making the Gate a required status check on `main`. + +## 1. Create and install the Apps + +Create two GitHub Apps. Keeping the Apps and private keys separate prevents an +mParticle check-writing credential from also being able to read Rokt membership +data. + +| App | Installation | Required permissions | +| ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | +| `Rokt Safe PR Gate` | `mParticle`, only `mparticle-apple-sdk` | Actions: read; Checks: write; Contents: read; Members: read; Pull requests: read | +| `Rokt Employee Lookup` | `ROKT` | Members: read | + +Neither App may receive contents-write, administration, merge, push, or +ruleset-bypass permissions. Neither App submits a pull-request review. + +## 2. Configure repository settings + +Add these repository variables: + +| Variable | Value | +| ------------------------------------------- | ----------------------------------------- | +| `ROKT_SAFE_PR_GATE_ENABLED` | `true` only during validation/enforcement | +| `ROKT_SAFE_PR_GATE_MODE` | `audit` initially, then `enforce` | +| `ROKT_SAFE_PR_GATE_MPARTICLE_APP_ID` | `Rokt Safe PR Gate` App ID | +| `ROKT_SAFE_PR_GATE_ROKT_APP_ID` | `Rokt Employee Lookup` App ID | +| `ROKT_SAFE_PR_GATE_EMPLOYEE_TEAM_SLUG` | IdP-synchronised Rokt employee team slug | +| `ROKT_SAFE_PR_GATE_MANUAL_REVIEW_TEAM_SLUG` | `sdk-team` | + +Add these repository secrets: + +| Secret | Value | +| --------------------------------------------- | -------------------------------------- | +| `ROKT_SAFE_PR_GATE_MPARTICLE_APP_PRIVATE_KEY` | `Rokt Safe PR Gate` App private key | +| `ROKT_SAFE_PR_GATE_ROKT_APP_PRIVATE_KEY` | `Rokt Employee Lookup` App private key | + +There is no machine reviewer, reviewer token, or ruleset bypass actor in this +design. + +## 3. Validate before enforcement + +First set `ROKT_SAFE_PR_GATE_ENABLED` to `true` and +`ROKT_SAFE_PR_GATE_MODE` to `audit`, without adding the Gate to the ruleset. +Run this for one to two weeks and inspect the neutral check output. Only then +set the mode to `enforce` and test these fixture pull requests: + +1. A verified Rokt employee changes one allowlisted Markdown file: CI and the + Gate should pass without a review. +2. A non-employee changes that same file: CI should pass and the Gate should + report `action_required` until an SDK-team member approves the current SHA. +3. A Rokt employee changes source code, or mixes source with an allowlisted + Markdown file: the Gate should pass immediately, but the ruleset must + require SDK-team approval. Source CI remains advisory, just as it is today. +4. Any change under `.github/workflows`: the Gate must not classify it as safe. +5. Push a new commit after every passing case: the new SHA must receive a new + Gate decision and cannot inherit a prior approval. +6. Have an SDK-team reviewer request changes on a safe employee PR: the Gate + must report `action_required` until that reviewer approves or dismisses the + request. +7. Repeat the first fixture from a fork: the size report must skip its PR + comment and the Gate must still react to a successful `Pull request` run. +8. Retarget a previously safe pull request: the `edited` event must re-evaluate + the current base diff before the Gate reports success. +9. Make a safe-path-only pull request exceed a Gate limit, rename a file, or + mark it as a draft: the Gate must report `action_required`. The author must + correct it before it can merge without SDK-team review. + A fresh SDK-team approval on the current SHA is the only manual exception; + the Gate never automatically approves this category. + +For fork PRs, the Gate's trusted scheduled run re-evaluates the ten most recently +updated open pull requests every five minutes. Each scheduled run also limits API +pagination and reviewer membership lookups so it can finish within its timeout. +An SDK-team maintainer can use **Actions → Rokt Safe PR Gate → Run workflow** with +the pull request number for an immediate re-evaluation. The workflow never runs a +pull-request review event with secrets; it evaluates only code checked out from +the default branch. + +## 4. Change the active `main` ruleset atomically + +Capture the current ruleset JSON first. In ruleset `6260587`: + +1. Retain pull requests, stale-review dismissal, resolved-thread enforcement, + unattributed-change handling, and allowed merge methods. +2. Set **required approving review count** to `0` and turn off **require Code + Owner review**. `CODEOWNERS` remains unchanged and continues to request the + SDK team; it no longer enforces every path. +3. Add a **required reviewer** entry for `@mParticle/sdk-team`, with one + approval and these ordered file patterns: + + ```text + ** + !README.md + !ARCHITECTURE.md + !CONTRIBUTING.md + !Kits/README.md + !IntegrationTests/README.md + ``` + +4. Add `Rokt Safe PR Gate` as a required, strict status check and pin its + expected source to the `Rokt Safe PR Gate` App. + +The explicit paths above must exactly match +`.github/rokt-safe-pr-gate-policy.json`. A mixed PR matches `**` through its +non-safe file and therefore still needs an SDK-team approval. + +The existing unattributed-change rule remains in force. A successful Gate check +can still require the extra approval that GitHub applies to an unattributed +commit. + +## Rollback + +Remove `Rokt Safe PR Gate` from required status checks first, then restore the +captured pull-request settings (`required_approving_review_count: 1` and +`require_code_owner_review: true`). Finally set +`ROKT_SAFE_PR_GATE_ENABLED` to `false`. diff --git a/.github/actions/rokt-safe-pr-gate/action.yml b/.github/actions/rokt-safe-pr-gate/action.yml new file mode 100644 index 000000000..aab2523f5 --- /dev/null +++ b/.github/actions/rokt-safe-pr-gate/action.yml @@ -0,0 +1,39 @@ +name: Rokt Safe PR Gate +description: Evaluates low-risk pull requests for the required Rokt Safe PR Gate check. +inputs: + api-url: + description: GitHub API base URL. + required: true + event-path: + description: Path to the triggering event payload. + required: true + evaluation-id: + description: Unique identifier for this Gate evaluation. + required: true + policy-path: + description: Path to the trusted Gate policy file. + required: true + mparticle-token: + description: Repository-scoped token for mParticle API requests. + required: true + rokt-token: + description: Organization-scoped token for Rokt membership requests. + required: true + gate-app-id: + description: GitHub App identifier that owns the Gate check. + required: true + employee-team-slug: + description: Rokt team slug used to verify employee membership. + required: true + manual-review-team-slug: + description: mParticle team slug used to verify manual reviews. + required: true + pr-number: + description: Optional pull request number for a manual re-evaluation. + required: false + mode: + description: Gate execution mode, either audit or enforce. + required: true +runs: + using: node20 + main: index.js diff --git a/.github/actions/rokt-safe-pr-gate/index.js b/.github/actions/rokt-safe-pr-gate/index.js new file mode 100644 index 000000000..eb22ad50f --- /dev/null +++ b/.github/actions/rokt-safe-pr-gate/index.js @@ -0,0 +1,779 @@ +const fs = require("node:fs"); +const { + classifyFiles, + evaluateTeamReviewState, + evaluateWorkflows, + getEffectiveReviews, + getIneligibleFileConclusion, + getPaginatedItems, + getPullRequestNumbers, + hasSharedOpenHead, + validatePolicy, +} = require("./lib/gate"); + +const SCHEDULE_MEMBERSHIP_LOOKUP_LIMIT = 50; +const SCHEDULE_PAGINATION_PAGE_LIMIT = 2; +const SCHEDULE_PULL_REQUEST_LIMIT = 10; + +function getInput(name) { + const normalizedName = name.toUpperCase(); + return ( + process.env[`INPUT_${normalizedName}`]?.trim() || + process.env[`INPUT_${normalizedName.replace(/-/g, "_")}`]?.trim() || + "" + ); +} + +function requiredInput(name) { + const value = getInput(name); + + if (!value) { + throw new Error(`Missing required input: ${name}`); + } + + return value; +} + +function optionalPullRequestNumber() { + const value = getInput("pr-number"); + + if (!value) { + return null; + } + + if (!/^\d+$/.test(value) || Number(value) < 1) { + throw new Error("pr-number must be a positive integer."); + } + + return Number(value); +} + +function toQueryPath(path, query) { + const url = new URL(path, "https://github.invalid"); + + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + + return `${url.pathname}${url.search}`; +} + +function nextPage(linkHeader) { + if (!linkHeader) { + return null; + } + + const match = linkHeader.match(/<([^>]+)>; rel="next"/); + return match?.[1] || null; +} + +function createApi( + apiUrl, + token, + { maxPages = Number.POSITIVE_INFINITY } = {}, +) { + async function request(path, options = {}) { + const response = await fetch(new URL(path, apiUrl), { + method: options.method || "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(options.body ? { "Content-Type": "application/json" } : {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const data = response.status === 204 ? null : await response.json(); + + if ( + !response.ok && + !(options.allowStatuses || []).includes(response.status) + ) { + throw new Error( + `GitHub API request failed with status ${response.status}.`, + ); + } + + return { data, headers: response.headers, status: response.status }; + } + + async function paginate( + path, + collectionKey, + { truncateAtLimit = false } = {}, + ) { + const results = []; + let next = path; + let pageCount = 0; + + while (next) { + if (pageCount >= maxPages) { + if (truncateAtLimit) break; + throw new Error("GitHub API pagination exceeded the evaluation limit."); + } + + const response = await request(next); + + results.push(...getPaginatedItems(response.data, collectionKey)); + pageCount += 1; + next = nextPage(response.headers.get("link")); + } + + return results; + } + + return { paginate, request }; +} + +async function getGateCheck(api, owner, repository, sha, gateAppId, checkName) { + const path = toQueryPath( + `/repos/${owner}/${repository}/commits/${sha}/check-runs`, + { check_name: checkName, per_page: "100" }, + ); + const checks = await api.paginate(path, "check_runs", { + truncateAtLimit: true, + }); + + return ( + checks + .filter((check) => String(check.app?.id) === gateAppId) + .sort((left, right) => right.id - left.id)[0] || null + ); +} + +function isCheckCompletedAfterEvaluation(check, evaluationStartedAt) { + if ( + !evaluationStartedAt || + check.status !== "completed" || + !check.completed_at + ) { + return false; + } + + const checkTime = Date.parse(check.completed_at); + const evaluationTime = Date.parse(evaluationStartedAt); + + return ( + Number.isFinite(checkTime) && + Number.isFinite(evaluationTime) && + checkTime >= evaluationTime + ); +} + +function gateCheckExternalId(details) { + return `rokt-safe-pr-gate:${details.prNumber}:${details.sha}:${Date.parse(details.evaluationStartedAt)}:${details.evaluationId}`; +} + +function ownsGateCheck(check, details) { + return check.external_id === gateCheckExternalId(details); +} + +function isCheckClaimedAfterEvaluation(check, evaluationStartedAt) { + if (!evaluationStartedAt) return false; + + const match = check.external_id?.match( + /^rokt-safe-pr-gate:\d+:[a-f0-9]{40}:(\d+):/, + ); + const claimedAt = Number(match?.[1]); + const evaluationTime = Date.parse(evaluationStartedAt); + + return ( + Number.isFinite(claimedAt) && + Number.isFinite(evaluationTime) && + claimedAt >= evaluationTime + ); +} + +function gateCheckBody(details, state) { + const body = { + external_id: gateCheckExternalId(details), + name: details.checkName, + status: state.status, + output: { + title: "Rokt Safe PR Gate", + summary: state.summary, + }, + }; + + if (state.status === "completed") { + body.conclusion = state.conclusion; + body.completed_at = new Date().toISOString(); + } + + return body; +} + +async function createGateCheck(api, details, state) { + await api.request( + `/repos/${details.owner}/${details.repository}/check-runs`, + { + method: "POST", + body: { + ...gateCheckBody(details, state), + head_sha: details.sha, + }, + }, + ); +} + +async function patchGateCheck(api, check, details, state) { + await api.request( + `/repos/${details.owner}/${details.repository}/check-runs/${check.id}`, + { + method: "PATCH", + body: gateCheckBody(details, state), + }, + ); +} + +async function claimCompletedGateCheck(api, check, details, summary) { + await api.request( + `/repos/${details.owner}/${details.repository}/check-runs/${check.id}`, + { + method: "PATCH", + body: { + external_id: gateCheckExternalId(details), + name: details.checkName, + output: { title: "Rokt Safe PR Gate", summary }, + }, + }, + ); +} + +async function upsertGateCheck(api, details, state) { + const check = await getGateCheck( + api, + details.owner, + details.repository, + details.sha, + details.gateAppId, + details.checkName, + ); + if (check) { + if (state.status === "in_progress") { + if (!ownsGateCheck(check, details)) { + if ( + isCheckClaimedAfterEvaluation(check, details.evaluationStartedAt) || + isCheckCompletedAfterEvaluation(check, details.evaluationStartedAt) + ) { + return; + } + + if (check.status === "completed") { + await claimCompletedGateCheck(api, check, details, state.summary); + return; + } + + await patchGateCheck(api, check, details, state); + return; + } + + if (check.status !== "completed") { + await patchGateCheck(api, check, details, state); + } + return; + } + + if (!ownsGateCheck(check, details)) { + return; + } + + await patchGateCheck(api, check, details, state); + return; + } + + await createGateCheck(api, details, state); +} + +async function completeGate(api, details, conclusion, summary) { + await upsertGateCheck(api, details, { + conclusion, + status: "completed", + summary, + }); +} + +async function ensureGatePending(api, details, summary) { + const check = await getGateCheck( + api, + details.owner, + details.repository, + details.sha, + details.gateAppId, + details.checkName, + ); + + const state = { status: "in_progress", summary }; + + if (check && ownsGateCheck(check, details)) { + if (check.status === "completed") { + await createGateCheck(api, details, state); + } else { + await patchGateCheck(api, check, details, state); + } + return; + } + + if ( + check && + (isCheckClaimedAfterEvaluation(check, details.evaluationStartedAt) || + isCheckCompletedAfterEvaluation(check, details.evaluationStartedAt)) + ) { + return; + } + + if (!check || check.status === "completed") { + await createGateCheck(api, details, state); + } +} + +async function isActiveTeamMember(api, organization, teamSlug, login) { + const path = `/orgs/${organization}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`; + const response = await api.request(path, { allowStatuses: [404] }); + + return response.status === 200 && response.data?.state === "active"; +} + +async function getTeamReviewState( + api, + organization, + teamSlug, + reviews, + pr, + membershipLookupBudget, +) { + const reviewerLogins = [ + ...new Set( + getEffectiveReviews(reviews) + .map((review) => review.user?.login) + .filter( + (login) => + login && login.toLowerCase() !== pr.user.login.toLowerCase(), + ), + ), + ]; + + if (reviewerLogins.length > membershipLookupBudget.remaining) { + throw new Error( + "Reviewer membership lookups exceeded the evaluation limit.", + ); + } + membershipLookupBudget.remaining -= reviewerLogins.length; + + const memberships = await Promise.all( + reviewerLogins.map(async (login) => ({ + login, + member: await isActiveTeamMember(api, organization, teamSlug, login), + })), + ); + const teamLogins = new Set( + memberships.filter(({ member }) => member).map(({ login }) => login), + ); + + return evaluateTeamReviewState( + reviews, + teamLogins, + pr.user.login, + pr.head.sha, + ); +} + +async function getCurrentTeamReviewState(context, prNumber, pr) { + const reviews = await context.mparticleApi.paginate( + toQueryPath( + `/repos/${context.owner}/${context.repository}/pulls/${prNumber}/reviews`, + { per_page: "100" }, + ), + ); + + return getTeamReviewState( + context.mparticleApi, + context.owner, + context.manualReviewTeamSlug, + reviews, + pr, + context.membershipLookupBudget, + ); +} + +async function resolvePullRequestNumbers( + event, + api, + owner, + repository, + requestedPullRequestNumber, +) { + if (requestedPullRequestNumber) { + return [requestedPullRequestNumber]; + } + + const eventPullRequestNumbers = getPullRequestNumbers(event); + + if (eventPullRequestNumbers.length > 0) { + return eventPullRequestNumbers; + } + + if (event.workflow_run?.head_sha) { + const pullRequests = await api.paginate( + toQueryPath( + `/repos/${owner}/${repository}/commits/${event.workflow_run.head_sha}/pulls`, + { per_page: "100" }, + ), + ); + return [ + ...new Set( + pullRequests + .filter((pullRequest) => pullRequest.state === "open") + .map((pullRequest) => pullRequest.number) + .filter(Number.isInteger), + ), + ]; + } + + if (event.schedule) { + const response = await api.request( + toQueryPath(`/repos/${owner}/${repository}/pulls`, { + direction: "desc", + per_page: String(SCHEDULE_PULL_REQUEST_LIMIT), + sort: "updated", + state: "open", + }), + ); + const pullRequests = getPaginatedItems(response.data); + + return pullRequests + .slice(0, SCHEDULE_PULL_REQUEST_LIMIT) + .filter((pullRequest) => Number.isInteger(pullRequest.number)) + .map((pullRequest) => pullRequest.number); + } + + console.log("No open pull request is associated with this event."); + return []; +} + +function requiredMode() { + const mode = requiredInput("mode"); + + if (!["audit", "enforce"].includes(mode)) { + throw new Error("mode must be either audit or enforce."); + } + + return mode; +} + +async function completeDecision(context, details, conclusion, summary) { + if (context.mode === "audit") { + return completeGate( + context.mparticleApi, + details, + "neutral", + `Audit only: would report ${conclusion}. ${summary}`, + ); + } + + return completeGate(context.mparticleApi, details, conclusion, summary); +} + +async function evaluatePullRequest(context, prNumber) { + const evaluationStartedAt = new Date().toISOString(); + const { mparticleApi, owner, policy, repository, roktApi } = context; + const pr = ( + await mparticleApi.request( + `/repos/${owner}/${repository}/pulls/${prNumber}`, + ) + ).data; + + if (pr.state !== "open") { + return true; + } + + const details = { + checkName: policy.gateCheckName, + evaluationId: context.evaluationId, + evaluationStartedAt, + gateAppId: context.gateAppId, + owner, + prNumber, + repository, + sha: pr.head.sha, + }; + + try { + await upsertGateCheck(mparticleApi, details, { + status: "in_progress", + summary: "Evaluating the current pull request head SHA.", + }); + + const pullRequestsAtHead = await mparticleApi.paginate( + toQueryPath( + `/repos/${owner}/${repository}/commits/${pr.head.sha}/pulls`, + { per_page: "100" }, + ), + ); + + if (hasSharedOpenHead(pullRequestsAtHead, prNumber, pr.head.sha)) { + await completeDecision( + context, + details, + "failure", + "The head commit is associated with multiple open pull requests, so it cannot receive a PR-specific Gate decision.", + ); + return true; + } + + if (pr.draft) { + await completeDecision( + context, + details, + "action_required", + "Draft pull request; it cannot receive a passing Gate decision until it is ready for review.", + ); + return true; + } + + const [files, tree] = await Promise.all([ + mparticleApi.paginate( + toQueryPath(`/repos/${owner}/${repository}/pulls/${prNumber}/files`, { + per_page: "100", + }), + ), + mparticleApi.request( + `/repos/${owner}/${repository}/git/trees/${pr.head.sha}?recursive=1`, + ), + ]); + + if (tree.data.truncated) { + await completeDecision( + context, + details, + "failure", + "Unable to safely inspect the full file tree.", + ); + return true; + } + + const fileState = classifyFiles(files, tree.data.tree, policy); + + if (!fileState.eligible) { + const conclusion = getIneligibleFileConclusion(files, policy); + + if (conclusion === "success") { + await completeDecision( + context, + details, + conclusion, + "The ruleset requires SDK-team approval for this pull request.", + ); + return true; + } + + const teamReviewState = await getCurrentTeamReviewState( + context, + prNumber, + pr, + ); + + if (teamReviewState.hasBlockingChangeRequest) { + await completeDecision( + context, + details, + "action_required", + "An SDK-team review requests changes on this pull request.", + ); + return true; + } + + if (teamReviewState.hasFreshApproval) { + await completeDecision( + context, + details, + "success", + "A fresh SDK-team approval approved this safe-path exception for the current head SHA.", + ); + return true; + } + + await completeDecision( + context, + details, + "action_required", + "This safe-path-only pull request does not meet the Gate's safety requirements and requires a fresh SDK-team approval.", + ); + return true; + } + + const workflowRuns = await mparticleApi.paginate( + toQueryPath(`/repos/${owner}/${repository}/actions/runs`, { + event: "pull_request", + head_sha: pr.head.sha, + per_page: "100", + }), + "workflow_runs", + ); + const workflowState = evaluateWorkflows( + workflowRuns, + policy.requiredWorkflows, + ); + + if (workflowState.state === "pending") { + await ensureGatePending( + mparticleApi, + details, + `Waiting for required workflow completion: ${workflowState.reason}.`, + ); + console.log( + `Waiting for required workflow completion on PR #${prNumber}.`, + ); + return true; + } + + if (workflowState.state === "failed") { + await completeDecision( + context, + details, + "failure", + "A required workflow did not succeed.", + ); + return true; + } + + const teamReviewState = await getCurrentTeamReviewState( + context, + prNumber, + pr, + ); + + if (teamReviewState.hasBlockingChangeRequest) { + await completeDecision( + context, + details, + "action_required", + "An SDK-team review requests changes on this pull request.", + ); + return true; + } + + const employee = await isActiveTeamMember( + roktApi, + policy.roktOrganization, + context.employeeTeamSlug, + pr.user.login, + ); + + if (employee) { + await completeDecision( + context, + details, + "success", + "Eligible Rokt employee pull request passed the Gate for the current head SHA.", + ); + return true; + } + + if (teamReviewState.hasFreshApproval) { + await completeDecision( + context, + details, + "success", + "A fresh SDK-team approval satisfied the Gate for the current head SHA.", + ); + return true; + } + + await completeDecision( + context, + details, + "action_required", + "Awaiting a fresh SDK-team approval for this safe-path pull request.", + ); + return true; + } catch (error) { + try { + await completeDecision( + context, + details, + "failure", + "The Gate could not safely complete its evaluation.", + ); + } catch {} + + console.error(`Rokt Safe PR Gate failed for PR #${prNumber}.`, error); + return false; + } +} + +async function main() { + const apiUrl = requiredInput("api-url"); + const policy = validatePolicy( + JSON.parse(fs.readFileSync(requiredInput("policy-path"), "utf8")), + ); + const event = JSON.parse( + fs.readFileSync(requiredInput("event-path"), "utf8"), + ); + const owner = event.repository?.owner?.login; + const repository = event.repository?.name; + + if (!owner || !repository) { + throw new Error("Event does not identify a repository."); + } + + const scheduled = Boolean(event.schedule); + const apiOptions = scheduled + ? { maxPages: SCHEDULE_PAGINATION_PAGE_LIMIT } + : undefined; + const mparticleApi = createApi( + apiUrl, + requiredInput("mparticle-token"), + apiOptions, + ); + const context = { + employeeTeamSlug: requiredInput("employee-team-slug"), + evaluationId: requiredInput("evaluation-id"), + gateAppId: requiredInput("gate-app-id"), + manualReviewTeamSlug: requiredInput("manual-review-team-slug"), + membershipLookupBudget: { + remaining: scheduled + ? SCHEDULE_MEMBERSHIP_LOOKUP_LIMIT + : Number.POSITIVE_INFINITY, + }, + mparticleApi, + mode: requiredMode(), + owner, + policy, + repository, + roktApi: createApi(apiUrl, requiredInput("rokt-token"), apiOptions), + }; + const prNumbers = await resolvePullRequestNumbers( + event, + mparticleApi, + owner, + repository, + optionalPullRequestNumber(), + ); + let succeeded = true; + + for (const prNumber of prNumbers) { + succeeded = (await evaluatePullRequest(context, prNumber)) && succeeded; + } + + if (!succeeded) { + process.exitCode = 1; + } +} + +if (require.main === module) { + main().catch((error) => { + console.error("Rokt Safe PR Gate failed.", error); + process.exitCode = 1; + }); +} + +module.exports = { + createApi, + ensureGatePending, + evaluatePullRequest, + getInput, + resolvePullRequestNumbers, + upsertGateCheck, +}; diff --git a/.github/actions/rokt-safe-pr-gate/lib/gate.js b/.github/actions/rokt-safe-pr-gate/lib/gate.js new file mode 100644 index 000000000..cd18a5255 --- /dev/null +++ b/.github/actions/rokt-safe-pr-gate/lib/gate.js @@ -0,0 +1,282 @@ +const ALLOWED_FILE_STATUSES = new Set(["added", "modified"]); + +function validatePolicy(policy) { + if (!policy || typeof policy !== "object") { + throw new Error("Policy must be an object."); + } + + if (!Array.isArray(policy.safePaths) || policy.safePaths.length === 0) { + throw new Error("Policy must define at least one safe path."); + } + + if ( + typeof policy.gateCheckName !== "string" || + policy.gateCheckName.trim().length === 0 + ) { + throw new Error("Policy must define a gate check name."); + } + + if ( + typeof policy.roktOrganization !== "string" || + policy.roktOrganization.trim().length === 0 + ) { + throw new Error("Policy must define the Rokt organization login."); + } + + if ( + !Number.isSafeInteger(policy.maxFiles) || + policy.maxFiles < 1 || + !Number.isSafeInteger(policy.maxChangedLines) || + policy.maxChangedLines < 1 + ) { + throw new Error("Policy file and line limits must be positive integers."); + } + + if ( + !Array.isArray(policy.requiredWorkflows) || + policy.requiredWorkflows.length === 0 + ) { + throw new Error("Policy must define at least one required workflow."); + } + + for (const path of policy.safePaths) { + if ( + typeof path !== "string" || + path.length === 0 || + path.includes("*") || + path.startsWith(".github/") || + !path.endsWith(".md") + ) { + throw new Error( + "Policy safe paths must be explicit Markdown, non-workflow paths.", + ); + } + } + + for (const workflow of policy.requiredWorkflows) { + if ( + !workflow || + workflow.event !== "pull_request" || + typeof workflow.path !== "string" || + !workflow.path.startsWith(".github/workflows/") || + !workflow.path.endsWith(".yml") + ) { + throw new Error( + "Policy required workflows must identify a pull-request workflow path.", + ); + } + } + + return policy; +} + +function classifyFiles(files, treeEntries, policy) { + const reasons = []; + const safePaths = new Set(policy.safePaths); + const treeByPath = new Map(treeEntries.map((entry) => [entry.path, entry])); + const changedLines = files.reduce( + (total, file) => total + (file.changes || 0), + 0, + ); + + if (files.length === 0) { + reasons.push("no changed files"); + } + + if (files.length > policy.maxFiles) { + reasons.push("too many changed files"); + } + + if (changedLines > policy.maxChangedLines) { + reasons.push("too many changed lines"); + } + + for (const file of files) { + const treeEntry = treeByPath.get(file.filename); + + if (!safePaths.has(file.filename)) { + reasons.push("path is not allowlisted"); + } + + if (!ALLOWED_FILE_STATUSES.has(file.status)) { + reasons.push("file operation is not allowed"); + } + + if (typeof file.patch !== "string") { + reasons.push("file diff is unavailable"); + } + + if ( + !treeEntry || + treeEntry.type !== "blob" || + treeEntry.mode !== "100644" + ) { + reasons.push("file mode or type is not allowed"); + } + } + + return { + eligible: reasons.length === 0, + reasons: [...new Set(reasons)], + }; +} + +function getIneligibleFileConclusion(files, policy) { + const safePaths = new Set(policy.safePaths); + + return files.every((file) => safePaths.has(file.filename)) + ? "action_required" + : "success"; +} + +function evaluateWorkflows(runs, requirements) { + for (const requirement of requirements) { + const matchingRuns = runs + .filter( + (run) => + run.path === requirement.path && run.event === requirement.event, + ) + .sort( + (left, right) => + Date.parse(right.updated_at) - Date.parse(left.updated_at), + ); + + if (matchingRuns.length === 0) { + return { state: "pending", reason: "required workflow has not started" }; + } + + const latestRun = matchingRuns[0]; + + if (latestRun.status !== "completed") { + return { state: "pending", reason: "required workflow is still running" }; + } + + if (["cancelled", "skipped", "stale"].includes(latestRun.conclusion)) { + return { + state: "pending", + reason: "required workflow must be re-run after cancellation", + }; + } + + if (latestRun.conclusion !== "success") { + return { state: "failed", reason: "required workflow did not succeed" }; + } + } + + return { state: "success" }; +} + +function getPullRequestNumber(event) { + return getPullRequestNumbers(event)[0] || null; +} + +function getPullRequestNumbers(event) { + if (Number.isInteger(event?.pull_request?.number)) { + return [event.pull_request.number]; + } + + const pullRequests = event?.workflow_run?.pull_requests; + + if (Array.isArray(pullRequests)) { + return [ + ...new Set( + pullRequests + .map((pullRequest) => pullRequest.number) + .filter(Number.isInteger), + ), + ]; + } + + return []; +} + +function hasSharedOpenHead(pullRequests, pullRequestNumber, headSha) { + return pullRequests.some( + (pullRequest) => + pullRequest.state === "open" && + pullRequest.number !== pullRequestNumber && + Number.isInteger(pullRequest.number) && + pullRequest.head?.sha === headSha, + ); +} + +function getPaginatedItems(data, collectionKey) { + const items = collectionKey ? data?.[collectionKey] : data; + + if (!Array.isArray(items)) { + throw new Error("Expected a paginated GitHub API response."); + } + + return items; +} + +function getEffectiveReviews(reviews) { + const effectiveReviews = new Map(); + + for (const review of reviews) { + const login = review.user?.login?.toLowerCase(); + + if ( + !login || + !["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(review.state) + ) { + continue; + } + + const currentReview = effectiveReviews.get(login); + const currentTime = currentReview + ? Date.parse(currentReview.submitted_at || 0) || 0 + : Number.NEGATIVE_INFINITY; + const reviewTime = Date.parse(review.submitted_at || 0) || 0; + const currentId = Number(currentReview?.id) || 0; + const reviewId = Number(review.id) || 0; + + if ( + !currentReview || + reviewTime > currentTime || + (reviewTime === currentTime && reviewId > currentId) + ) { + effectiveReviews.set(login, review); + } + } + + return [...effectiveReviews.values()]; +} + +function evaluateTeamReviewState(reviews, teamLogins, authorLogin, headSha) { + const normalizedTeamLogins = new Set( + [...teamLogins].map((login) => login.toLowerCase()), + ); + const normalizedAuthorLogin = authorLogin.toLowerCase(); + const teamReviews = getEffectiveReviews(reviews).filter((review) => { + const login = review.user?.login?.toLowerCase(); + + return ( + login && + login !== normalizedAuthorLogin && + normalizedTeamLogins.has(login) + ); + }); + + return { + hasBlockingChangeRequest: teamReviews.some( + (review) => review.state === "CHANGES_REQUESTED", + ), + hasFreshApproval: teamReviews.some( + (review) => review.state === "APPROVED" && review.commit_id === headSha, + ), + }; +} + +module.exports = { + classifyFiles, + evaluateTeamReviewState, + evaluateWorkflows, + getEffectiveReviews, + getIneligibleFileConclusion, + getPaginatedItems, + getPullRequestNumber, + getPullRequestNumbers, + hasSharedOpenHead, + validatePolicy, +}; diff --git a/.github/actions/rokt-safe-pr-gate/test/gate.test.js b/.github/actions/rokt-safe-pr-gate/test/gate.test.js new file mode 100644 index 000000000..a4776c049 --- /dev/null +++ b/.github/actions/rokt-safe-pr-gate/test/gate.test.js @@ -0,0 +1,890 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + classifyFiles, + evaluateTeamReviewState, + evaluateWorkflows, + getEffectiveReviews, + getIneligibleFileConclusion, + getPaginatedItems, + getPullRequestNumber, + getPullRequestNumbers, + hasSharedOpenHead, + validatePolicy, +} = require("../lib/gate"); +const { + createApi, + ensureGatePending, + evaluatePullRequest, + getInput, + resolvePullRequestNumbers, + upsertGateCheck, +} = require("../index"); + +const policy = { + gateCheckName: "Rokt Safe PR Gate", + maxChangedLines: 500, + maxFiles: 10, + requiredWorkflows: [ + { event: "pull_request", path: ".github/workflows/pull-request.yml" }, + ], + roktOrganization: "ROKT", + safePaths: ["README.md"], +}; + +const safeFile = { + changes: 10, + filename: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + status: "modified", +}; + +const safeTree = [{ mode: "100644", path: "README.md", type: "blob" }]; + +test("accepts an explicit safe Markdown modification", () => { + assert.deepEqual(classifyFiles([safeFile], safeTree, policy), { + eligible: true, + reasons: [], + }); +}); + +test("rejects a mixed documentation and source change", () => { + const sourceFile = { + changes: 1, + filename: "mParticle-Apple-SDK/MPBaseController.m", + patch: "@@ -1 +1 @@\n-old\n+new", + status: "modified", + }; + const tree = [ + ...safeTree, + { mode: "100644", path: sourceFile.filename, type: "blob" }, + ]; + + assert.equal( + classifyFiles([safeFile, sourceFile], tree, policy).eligible, + false, + ); + assert.equal( + getIneligibleFileConclusion([safeFile, sourceFile], policy), + "success", + ); + assert.equal( + getIneligibleFileConclusion([safeFile], policy), + "action_required", + ); +}); + +test("rejects a renamed file, executable file, and unavailable diff", () => { + const unsafeFile = { + changes: 1, + filename: "README.md", + status: "renamed", + }; + + const state = classifyFiles( + [unsafeFile], + [{ mode: "100755", path: "README.md", type: "blob" }], + policy, + ); + + assert.equal(state.eligible, false); + assert.deepEqual(state.reasons.sort(), [ + "file diff is unavailable", + "file mode or type is not allowed", + "file operation is not allowed", + ]); +}); + +test("waits for a required workflow and fails on its unsuccessful conclusion", () => { + const pending = evaluateWorkflows( + [ + { + event: "pull_request", + path: ".github/workflows/pull-request.yml", + status: "in_progress", + }, + ], + policy.requiredWorkflows, + ); + const failed = evaluateWorkflows( + [ + { + conclusion: "failure", + event: "pull_request", + path: ".github/workflows/pull-request.yml", + status: "completed", + updated_at: "2026-08-29T00:00:00Z", + }, + ], + policy.requiredWorkflows, + ); + + assert.equal(pending.state, "pending"); + assert.equal(failed.state, "failed"); +}); + +test("uses only the latest successful matching workflow run", () => { + const result = evaluateWorkflows( + [ + { + conclusion: "failure", + event: "pull_request", + path: ".github/workflows/pull-request.yml", + status: "completed", + updated_at: "2026-08-29T00:00:00Z", + }, + { + conclusion: "success", + event: "pull_request", + path: ".github/workflows/pull-request.yml", + status: "completed", + updated_at: "2026-08-29T00:01:00Z", + }, + ], + policy.requiredWorkflows, + ); + + assert.deepEqual(result, { state: "success" }); +}); + +test("waits for a cancelled workflow to be re-run", () => { + assert.deepEqual( + evaluateWorkflows( + [ + { + conclusion: "cancelled", + event: "pull_request", + path: ".github/workflows/pull-request.yml", + status: "completed", + updated_at: "2026-08-29T00:01:00Z", + }, + ], + policy.requiredWorkflows, + ), + { + state: "pending", + reason: "required workflow must be re-run after cancellation", + }, + ); +}); + +test("resolves pull request numbers from both supported events", () => { + assert.equal(getPullRequestNumber({ pull_request: { number: 42 } }), 42); + assert.equal( + getPullRequestNumber({ workflow_run: { pull_requests: [{ number: 43 }] } }), + 43, + ); + assert.equal(getPullRequestNumber({}), null); + assert.deepEqual( + getPullRequestNumbers({ + workflow_run: { pull_requests: [{ number: 43 }, { number: 44 }] }, + }), + [43, 44], + ); +}); + +test("rejects a Gate decision shared by multiple open pull requests", () => { + const headSha = "shared-head"; + assert.equal( + hasSharedOpenHead( + [ + { head: { sha: headSha }, number: 42, state: "open" }, + { head: { sha: headSha }, number: 43, state: "open" }, + { head: { sha: headSha }, number: 44, state: "closed" }, + ], + 42, + headSha, + ), + true, + ); + assert.equal( + hasSharedOpenHead( + [ + { head: { sha: headSha }, number: 42, state: "open" }, + { head: { sha: "nested-head" }, number: 43, state: "open" }, + ], + 42, + headSha, + ), + false, + ); +}); + +test("reads array and wrapped GitHub API pagination responses", () => { + assert.deepEqual(getPaginatedItems([{ id: 1 }]), [{ id: 1 }]); + assert.deepEqual( + getPaginatedItems({ check_runs: [{ id: 2 }] }, "check_runs"), + [{ id: 2 }], + ); + assert.deepEqual( + getPaginatedItems({ workflow_runs: [{ id: 3 }] }, "workflow_runs"), + [{ id: 3 }], + ); + assert.throws(() => getPaginatedItems({ check_runs: [] }), /paginated/); +}); + +test("rejects pagination beyond the configured page limit", async () => { + const previousFetch = global.fetch; + let requestCount = 0; + global.fetch = async () => { + requestCount += 1; + return { + headers: { + get: () => + '; rel="next"', + }, + json: async () => [], + ok: true, + status: 200, + }; + }; + + try { + const api = createApi("https://api.github.test", "token", { + maxPages: 1, + }); + await assert.rejects( + api.paginate("/repos/mParticle/mparticle-apple-sdk/pulls"), + /pagination exceeded/, + ); + assert.equal(requestCount, 1); + } finally { + global.fetch = previousFetch; + } +}); + +test("bounds scheduled rechecks to recently updated open pull requests", async () => { + const calls = []; + const api = { + request: async (path) => { + calls.push(path); + return { + data: Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + state: "open", + })), + }; + }, + }; + + assert.deepEqual( + await resolvePullRequestNumbers( + { schedule: "*/5 * * * *" }, + api, + "mParticle", + "mparticle-apple-sdk", + null, + ), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ); + assert.match(calls[0], /state=open/); + assert.match(calls[0], /per_page=10/); + assert.match(calls[0], /sort=updated/); + assert.match(calls[0], /direction=desc/); +}); + +test("requires a fresh non-author SDK-team approval on the current head SHA", () => { + const reviews = [ + { + commit_id: "old", + id: 1, + state: "APPROVED", + user: { login: "app/rokt-safe-pr-gate" }, + submitted_at: "2026-08-29T00:00:00Z", + }, + { + commit_id: "current", + id: 2, + state: "APPROVED", + user: { login: "sdk-reviewer" }, + submitted_at: "2026-08-29T00:01:00Z", + }, + ]; + + assert.deepEqual( + evaluateTeamReviewState( + reviews, + new Set(["SDK-REVIEWER"]), + "author", + "current", + ), + { hasBlockingChangeRequest: false, hasFreshApproval: true }, + ); +}); + +test("uses each reviewer's latest substantive review state", () => { + const reviews = [ + { + commit_id: "current", + id: 1, + state: "APPROVED", + submitted_at: "2026-08-29T00:00:00Z", + user: { login: "sdk-reviewer" }, + }, + { + commit_id: "current", + id: 2, + state: "COMMENTED", + submitted_at: "2026-08-29T00:01:00Z", + user: { login: "sdk-reviewer" }, + }, + { + commit_id: "current", + id: 3, + state: "CHANGES_REQUESTED", + submitted_at: "2026-08-29T00:02:00Z", + user: { login: "sdk-reviewer" }, + }, + { + commit_id: "current", + id: 4, + state: "APPROVED", + submitted_at: "2026-08-29T00:03:00Z", + user: { login: "author" }, + }, + ]; + + assert.deepEqual( + getEffectiveReviews(reviews).map((review) => review.id), + [3, 4], + ); + assert.deepEqual( + evaluateTeamReviewState( + reviews, + new Set(["sdk-reviewer", "author"]), + "AUTHOR", + "current", + ), + { hasBlockingChangeRequest: true, hasFreshApproval: false }, + ); +}); + +test("rejects an unsafe policy definition", () => { + assert.throws( + () => validatePolicy({ ...policy, safePaths: ["*.md"] }), + /explicit Markdown/, + ); + assert.throws( + () => + validatePolicy({ ...policy, safePaths: [".github/workflows/gate.yml"] }), + /explicit Markdown/, + ); + assert.throws( + () => validatePolicy({ ...policy, maxFiles: undefined }), + /positive integers/, + ); + assert.throws( + () => validatePolicy({ ...policy, gateCheckName: "" }), + /gate check name/, + ); + assert.throws( + () => validatePolicy({ ...policy, roktOrganization: "" }), + /Rokt organization login/, + ); + assert.throws( + () => + validatePolicy({ + ...policy, + requiredWorkflows: [{ event: "pull_request", name: "Pull request" }], + }), + /workflow path/, + ); +}); + +test("reads hyphenated composite-action input names", () => { + const inputName = "INPUT_EVENT-PATH"; + const previousValue = process.env[inputName]; + process.env[inputName] = "/tmp/event.json"; + assert.equal(getInput("event-path"), "/tmp/event.json"); + if (previousValue === undefined) delete process.env[inputName]; + else process.env[inputName] = previousValue; +}); + +test("replaces a completed Gate check while required CI is pending", async () => { + const requests = []; + const api = { + paginate: async () => [ + { + app: { id: 99 }, + id: 42, + name: "Rokt Safe PR Gate", + status: "completed", + }, + ], + request: async (path, options) => requests.push({ options, path }), + }; + await ensureGatePending( + api, + { + checkName: "Rokt Safe PR Gate", + evaluationId: "pending-run", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha: "b".repeat(40), + }, + "Waiting for CI.", + ); + + assert.equal(requests.length, 1); + assert.match(requests[0].path, /\/check-runs$/); + assert.equal(requests[0].options.method, "POST"); + assert.equal(requests[0].options.body.status, "in_progress"); +}); + +test("reopens a completed Gate check claimed by the current evaluation", async () => { + const sha = "d".repeat(40); + const check = { + app: { id: 99 }, + completed_at: "2026-09-08T01:00:00.000Z", + external_id: `rokt-safe-pr-gate:7:${sha}:1788829200000:older-run`, + id: 42, + name: "Rokt Safe PR Gate", + status: "completed", + }; + const requests = []; + const api = { + paginate: async () => [check], + request: async (path, options) => { + requests.push({ options, path }); + if (options.method === "PATCH") Object.assign(check, options.body); + }, + }; + const details = { + checkName: "Rokt Safe PR Gate", + evaluationId: "current-run", + evaluationStartedAt: "2026-09-08T01:01:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha, + }; + + await upsertGateCheck(api, details, { + status: "in_progress", + summary: "Claiming the completed check.", + }); + await ensureGatePending(api, details, "Waiting for CI."); + + assert.equal(requests.length, 2); + assert.match(requests[0].path, /\/check-runs\/42$/); + assert.equal(requests[0].options.body.status, undefined); + assert.match(requests[1].path, /\/check-runs$/); + assert.equal(requests[1].options.method, "POST"); + assert.equal(requests[1].options.body.status, "in_progress"); +}); + +test("does not let an older evaluation reopen a newer completed Gate check", async () => { + const requests = []; + const api = { + paginate: async () => [ + { + app: { id: 99 }, + completed_at: "2026-09-08T01:00:00.000Z", + id: 42, + name: "Rokt Safe PR Gate", + status: "completed", + }, + ], + request: async (path, options) => requests.push({ options, path }), + }; + const details = { + checkName: "Rokt Safe PR Gate", + evaluationId: "old-run", + evaluationStartedAt: "2026-09-08T00:59:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha: "b".repeat(40), + }; + + await ensureGatePending(api, details, "Waiting for CI."); + await upsertGateCheck(api, details, { + conclusion: "success", + status: "completed", + summary: "An older evaluator should not overwrite this check.", + }); + + assert.equal(requests.length, 0); +}); + +test("completes an in-progress Gate check created by the same evaluation", async () => { + const requests = []; + const api = { + paginate: async () => [ + { + app: { id: 99 }, + external_id: `rokt-safe-pr-gate:7:${"b".repeat(40)}:1788829140000:current-run`, + id: 42, + name: "Rokt Safe PR Gate", + started_at: "2026-09-08T01:00:00.000Z", + status: "in_progress", + }, + ], + request: async (path, options) => requests.push({ options, path }), + }; + + await upsertGateCheck( + api, + { + checkName: "Rokt Safe PR Gate", + evaluationId: "current-run", + evaluationStartedAt: "2026-09-08T00:59:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha: "b".repeat(40), + }, + { + conclusion: "success", + status: "completed", + summary: "The current evaluation completed.", + }, + ); + + assert.equal(requests.length, 1); + assert.match(requests[0].path, /\/check-runs\/42$/); + assert.equal(requests[0].options.method, "PATCH"); + assert.equal(requests[0].options.body.conclusion, "success"); +}); + +test("allows only the latest evaluator to complete its claimed Gate check", async () => { + const sha = "f".repeat(40); + const check = { + app: { id: 99 }, + external_id: `rokt-safe-pr-gate:7:${sha}:1788829200000:older-run`, + id: 42, + name: "Rokt Safe PR Gate", + started_at: "2026-09-08T01:00:00.000Z", + status: "in_progress", + }; + const requests = []; + const api = { + paginate: async () => [check], + request: async (path, options) => { + requests.push({ options, path }); + Object.assign(check, options.body); + }, + }; + const olderDetails = { + checkName: "Rokt Safe PR Gate", + evaluationId: "older-run", + evaluationStartedAt: "2026-09-08T01:00:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha, + }; + const newerDetails = { + ...olderDetails, + evaluationId: "newer-run", + evaluationStartedAt: "2026-09-08T01:01:00.000Z", + }; + + await upsertGateCheck(api, newerDetails, { + status: "in_progress", + summary: "The newer evaluator claimed this check.", + }); + await upsertGateCheck(api, olderDetails, { + conclusion: "success", + status: "completed", + summary: "The older evaluator must not complete this check.", + }); + await upsertGateCheck(api, newerDetails, { + conclusion: "action_required", + status: "completed", + summary: "The newer evaluator completes its own check.", + }); + + assert.equal(requests.length, 2); + assert.equal( + requests[0].options.body.external_id, + `rokt-safe-pr-gate:7:${sha}:1788829260000:newer-run`, + ); + assert.equal(requests[1].options.body.conclusion, "action_required"); +}); + +test("does not let a delayed evaluator reclaim a newer completed Gate check", async () => { + const sha = "a".repeat(40); + const requests = []; + const api = { + paginate: async () => [ + { + app: { id: 99 }, + completed_at: "2026-09-08T01:02:00.000Z", + external_id: `rokt-safe-pr-gate:7:${sha}:1788829260000:newer-run`, + id: 42, + name: "Rokt Safe PR Gate", + status: "completed", + }, + ], + request: async (path, options) => requests.push({ options, path }), + }; + + await upsertGateCheck( + api, + { + checkName: "Rokt Safe PR Gate", + evaluationId: "older-run", + evaluationStartedAt: "2026-09-08T01:00:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha, + }, + { + status: "in_progress", + summary: "The delayed evaluator must not claim this check.", + }, + ); + + assert.equal(requests.length, 0); +}); + +test("claims an old completed Gate check without creating another check run", async () => { + const sha = "e".repeat(40); + const check = { + app: { id: 99 }, + completed_at: "2026-09-08T01:00:00.000Z", + external_id: `rokt-safe-pr-gate:7:${sha}:1788829140000:older-run`, + id: 42, + name: "Rokt Safe PR Gate", + status: "completed", + }; + const requests = []; + const api = { + paginate: async () => [check], + request: async (path, options) => { + requests.push({ options, path }); + Object.assign(check, options.body); + }, + }; + const details = { + checkName: "Rokt Safe PR Gate", + evaluationId: "newer-run", + evaluationStartedAt: "2026-09-08T01:01:00.000Z", + gateAppId: "99", + owner: "mParticle", + prNumber: 7, + repository: "mparticle-apple-sdk", + sha, + }; + + await upsertGateCheck(api, details, { + status: "in_progress", + summary: "The newer evaluator claimed this check.", + }); + await upsertGateCheck(api, details, { + conclusion: "success", + status: "completed", + summary: "The newer evaluator completed its check.", + }); + + assert.equal(requests.length, 2); + assert.match(requests[0].path, /\/check-runs\/42$/); + assert.equal(requests[0].options.body.status, undefined); + assert.equal(requests[1].options.body.conclusion, "success"); +}); + +function gateContext(mparticleApi) { + return { + employeeTeamSlug: "employees", + evaluationId: "test-run", + gateAppId: "99", + manualReviewTeamSlug: "sdk-team", + membershipLookupBudget: { remaining: Number.POSITIVE_INFINITY }, + mode: "enforce", + mparticleApi, + owner: "mParticle", + policy, + repository: "mparticle-apple-sdk", + roktApi: {}, + }; +} + +test("blocks a draft pull request instead of recording a passing Gate", async () => { + const requests = []; + const sha = "c".repeat(40); + const api = { + paginate: async () => [], + request: async (path, options = {}) => { + requests.push({ options, path }); + if (path.endsWith("/pulls/9")) { + return { + data: { + draft: true, + head: { sha }, + state: "open", + user: { login: "author" }, + }, + }; + } + return { data: {} }; + }, + }; + + assert.equal(await evaluatePullRequest(gateContext(api), 9), true); + const completedCheck = requests.find( + ({ options }) => options.body?.conclusion === "action_required", + ); + assert.ok(completedCheck); +}); + +test("posts a neutral Gate conclusion when audit evaluation fails", async () => { + const checks = []; + const requests = []; + const sha = "f".repeat(40); + const api = { + paginate: async (path) => { + if (path.includes("check-runs")) return checks; + if (path.includes(`/commits/${sha}/pulls`)) { + throw new Error("Evaluation failed."); + } + throw new Error(`Unexpected paginated path: ${path}`); + }, + request: async (path, options = {}) => { + requests.push({ options, path }); + if (path.endsWith("/pulls/12")) { + return { + data: { + draft: false, + head: { sha }, + state: "open", + user: { login: "author" }, + }, + }; + } + if (path.endsWith("/check-runs") && options.method === "POST") { + checks.unshift({ app: { id: 99 }, id: 42, ...options.body }); + return { data: checks[0] }; + } + if (path.endsWith("/check-runs/42") && options.method === "PATCH") { + Object.assign(checks[0], options.body); + return { data: checks[0] }; + } + return { data: {} }; + }, + }; + const context = { ...gateContext(api), mode: "audit" }; + const previousConsoleError = console.error; + console.error = () => {}; + + try { + assert.equal(await evaluatePullRequest(context, 12), false); + } finally { + console.error = previousConsoleError; + } + const completedCheck = requests.find( + ({ options }) => options.body?.conclusion === "neutral", + ); + assert.ok(completedCheck); + assert.match( + completedCheck.options.body.output.summary, + /would report failure/, + ); +}); + +test("blocks an oversized safe-path-only pull request", async () => { + const requests = []; + const sha = "d".repeat(40); + const api = { + paginate: async (path) => { + if (path.includes("check-runs")) return []; + if (path.includes(`/commits/${sha}/pulls`)) { + return [{ head: { sha }, number: 10, state: "open" }]; + } + if (path.includes("/pulls/10/files")) { + return [{ ...safeFile, changes: policy.maxChangedLines + 1 }]; + } + if (path.includes("/pulls/10/reviews")) return []; + throw new Error(`Unexpected paginated path: ${path}`); + }, + request: async (path, options = {}) => { + requests.push({ options, path }); + if (path.endsWith("/pulls/10")) { + return { + data: { + draft: false, + head: { sha }, + state: "open", + user: { login: "author" }, + }, + }; + } + if (path.includes(`/git/trees/${sha}`)) + return { data: { tree: safeTree } }; + return { data: {} }; + }, + }; + + assert.equal(await evaluatePullRequest(gateContext(api), 10), true); + const completedCheck = requests.find( + ({ options }) => options.body?.conclusion === "action_required", + ); + assert.ok(completedCheck); +}); + +test("allows an SDK-team approval to override a safe-path-only exception", async () => { + const requests = []; + const sha = "e".repeat(40); + const api = { + paginate: async (path) => { + if (path.includes("check-runs")) return []; + if (path.includes(`/commits/${sha}/pulls`)) { + return [{ head: { sha }, number: 11, state: "open" }]; + } + if (path.includes("/pulls/11/files")) { + return [{ ...safeFile, changes: policy.maxChangedLines + 1 }]; + } + if (path.includes("/pulls/11/reviews")) { + return [ + { + commit_id: sha, + id: 1, + state: "APPROVED", + submitted_at: "2026-09-01T00:00:00Z", + user: { login: "sdk-reviewer" }, + }, + ]; + } + throw new Error(`Unexpected paginated path: ${path}`); + }, + request: async (path, options = {}) => { + requests.push({ options, path }); + if (path.endsWith("/pulls/11")) { + return { + data: { + draft: false, + head: { sha }, + number: 11, + state: "open", + user: { login: "author" }, + }, + }; + } + if (path.includes(`/git/trees/${sha}`)) { + return { data: { tree: safeTree } }; + } + if (path.includes("/teams/sdk-team/memberships/sdk-reviewer")) { + return { data: { state: "active" }, status: 200 }; + } + return { data: {} }; + }, + }; + + assert.equal(await evaluatePullRequest(gateContext(api), 11), true); + const completedCheck = requests.find( + ({ options }) => options.body?.conclusion === "success", + ); + assert.ok(completedCheck); + assert.match( + completedCheck.options.body.output.summary, + /safe-path exception/, + ); +}); diff --git a/.github/rokt-safe-pr-gate-policy.json b/.github/rokt-safe-pr-gate-policy.json new file mode 100644 index 000000000..cf7d29a48 --- /dev/null +++ b/.github/rokt-safe-pr-gate-policy.json @@ -0,0 +1,19 @@ +{ + "gateCheckName": "Rokt Safe PR Gate", + "maxChangedLines": 500, + "maxFiles": 10, + "requiredWorkflows": [ + { + "event": "pull_request", + "path": ".github/workflows/pull-request.yml" + } + ], + "roktOrganization": "ROKT", + "safePaths": [ + "README.md", + "ARCHITECTURE.md", + "CONTRIBUTING.md", + "Kits/README.md", + "IntegrationTests/README.md" + ] +} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index cf3935bb6..1c528d04c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -20,6 +20,18 @@ concurrency: cancel-in-progress: true jobs: + rokt-safe-pr-gate-tests: + name: Rokt Safe PR gate tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Test gate policy + run: node --test .github/actions/rokt-safe-pr-gate/test/gate.test.js + build-and-lint: uses: ./.github/workflows/build-and-lint.yml secrets: inherit @@ -46,7 +58,10 @@ jobs: secrets: inherit pr-notify: - if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.fork == false needs: - build-and-lint - native-tests diff --git a/.github/workflows/rokt-safe-pr-gate.yml b/.github/workflows/rokt-safe-pr-gate.yml new file mode 100644 index 000000000..8841189fa --- /dev/null +++ b/.github/workflows/rokt-safe-pr-gate.yml @@ -0,0 +1,87 @@ +name: Rokt Safe PR Gate + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + inputs: + pull_request_number: + description: Pull request number to re-evaluate + required: false + type: string + pull_request_target: + types: + [ + opened, + reopened, + ready_for_review, + converted_to_draft, + synchronize, + edited, + ] + workflow_run: + workflows: [Pull request] + types: [completed] + +permissions: {} + +concurrency: + group: rokt-safe-pr-gate-${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || (github.event_name == 'schedule' && 'poll') || inputs.pull_request_number || github.run_id }} + cancel-in-progress: false + +jobs: + evaluate: + if: >- + vars.ROKT_SAFE_PR_GATE_ENABLED == 'true' && + (github.event_name == 'pull_request_target' || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.event == 'pull_request') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Create mParticle App token + id: mparticle-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.ROKT_SAFE_PR_GATE_MPARTICLE_APP_ID }} + private-key: ${{ secrets.ROKT_SAFE_PR_GATE_MPARTICLE_APP_PRIVATE_KEY }} + owner: mParticle + repositories: mparticle-apple-sdk + permission-actions: read + permission-checks: write + permission-contents: read + permission-members: read + permission-pull-requests: read + + - name: Create Rokt App token + id: rokt-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.ROKT_SAFE_PR_GATE_ROKT_APP_ID }} + private-key: ${{ secrets.ROKT_SAFE_PR_GATE_ROKT_APP_PRIVATE_KEY }} + owner: ROKT + permission-members: read + + - name: Checkout trusted policy + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + token: ${{ steps.mparticle-token.outputs.token }} + + - name: Evaluate pull request + uses: ./.github/actions/rokt-safe-pr-gate + with: + api-url: ${{ github.api_url }} + employee-team-slug: ${{ vars.ROKT_SAFE_PR_GATE_EMPLOYEE_TEAM_SLUG }} + event-path: ${{ github.event_path }} + evaluation-id: ${{ github.run_id }}-${{ github.run_attempt }} + gate-app-id: ${{ vars.ROKT_SAFE_PR_GATE_MPARTICLE_APP_ID }} + manual-review-team-slug: ${{ vars.ROKT_SAFE_PR_GATE_MANUAL_REVIEW_TEAM_SLUG }} + mparticle-token: ${{ steps.mparticle-token.outputs.token }} + policy-path: .github/rokt-safe-pr-gate-policy.json + pr-number: ${{ inputs.pull_request_number }} + rokt-token: ${{ steps.rokt-token.outputs.token }} + mode: ${{ vars.ROKT_SAFE_PR_GATE_MODE }} diff --git a/.github/workflows/size-report.yml b/.github/workflows/size-report.yml index bfbc7441a..74b948152 100644 --- a/.github/workflows/size-report.yml +++ b/.github/workflows/size-report.yml @@ -250,7 +250,7 @@ jobs: echo "path=${REPORT_FILE}" >> $GITHUB_OUTPUT - name: Find Comment - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false uses: peter-evans/find-comment@v4 id: fc with: @@ -259,7 +259,7 @@ jobs: body-includes: ${{ env.COMMENT_IDENTIFIER }} - name: Create or update PR comment - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.fc.outputs.comment-id }}