Bladeren bron

fix: preserve weighted approval review semantics

Turtle 1 maand geleden
bovenliggende
commit
c4855581c3

+ 6 - 6
.github/review-ownership/README.md

@@ -2,7 +2,7 @@
 
 ## Summary
 
-The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both workflows execute policy from the trusted default branch.
+The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch.
 
 ## Table of Contents
 
@@ -31,13 +31,13 @@ The policy test measures non-test tracked lines under matched directories and re
 
 ## Approval scoring
 
-The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status context. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails.
+The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails.
 
 Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count.
 
-Each reviewer contributes only the latest non-comment `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. Comment-only and dismissed review records do not replace an effective decision. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
+Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 
-The workflow runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft, and when a review is submitted, edited, or dismissed. It fetches every review and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
 
 <a id="review-exclusions"></a>
 
@@ -55,7 +55,7 @@ For a modified file with a supported source extension, the scanner compares the
 
 ## Security
 
-The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. Pull-request filenames and reviews are treated as API data and escaped in logs.
+The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs.
 
 Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run.
 
@@ -63,7 +63,7 @@ Ownership and approval policy changes take effect only after they merge into the
 
 ## Verification
 
-Run `pnpm run test:request-review` for routing behavior and `pnpm run test:approval-policy` for policy parsing, effective review decisions, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin both workflows' trusted checkout, permissions, events, and commands. The repository gate graph runs all three checks in CI.
+Run `pnpm run test:request-review` for routing behavior and `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
 
 <a id="dev-note"></a>
 

+ 95 - 16
.github/review-ownership/check-approval.mjs

@@ -13,6 +13,14 @@ const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
 const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
 const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
 
+class GitHubApiError extends Error {
+  constructor(message, status) {
+    super(message)
+    this.name = 'GitHubApiError'
+    this.status = status
+  }
+}
+
 /**
  * Parse the approval score policy.
  * @param {string} source Approval policy JSON.
@@ -39,14 +47,16 @@ export function parseApprovalPolicy(source) {
 }
 
 /**
- * Select each reviewer's latest non-comment review decision.
+ * Select each reviewer's current approval or change-request decision.
  * @param {unknown[]} reviews Pull-request review records in GitHub's chronological order.
  * @returns {Array<{login: string, state: 'APPROVED' | 'CHANGES_REQUESTED'}>} Effective review decisions.
  */
 export function effectiveReviewDecisions(reviews) {
   const decisions = new Map()
   for (const review of reviews) {
-    if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') {
+    if (!isRecord(review)) throw new Error('pull-request review is not an object')
+    if (review.user === null) continue
+    if (!isRecord(review.user) || typeof review.user.login !== 'string') {
       throw new Error('pull-request review has no reviewer login')
     }
     const login = validateLogin(review.user.login, 'pull-request reviewer')
@@ -54,8 +64,11 @@ export function effectiveReviewDecisions(reviews) {
       throw new Error(`pull-request review by @${login} has an invalid state`)
     }
     const state = review.state.toUpperCase()
-    if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') {
-      decisions.set(login.toLowerCase(), { login, state })
+    const key = login.toLowerCase()
+    if (state === 'DISMISSED') {
+      decisions.delete(key)
+    } else if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') {
+      decisions.set(key, { login, state })
     }
   }
   return [...decisions.values()]
@@ -84,7 +97,10 @@ export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetc
     })
     if (!response.ok) {
       const responseBody = await response.text()
-      throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
+      throw new GitHubApiError(
+        `GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`,
+        response.status,
+      )
     }
     if (response.status === 204) return undefined
     return response.json()
@@ -193,6 +209,32 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, write
   return result
 }
 
+/**
+ * Resolve the reviewed pull request from a completed review-event workflow run.
+ * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
+ * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
+ */
+export async function approvalEventFromWorkflowRun({ event, api }) {
+  const repository = repositoryFromEvent(event)
+  if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event'
+    || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') {
+    throw new Error('event has no successful weighted approval review workflow run')
+  }
+  const expectedHeadSha = validateHeadSha(event.workflow_run.head_sha, 'workflow run')
+  const pullNumber = parsePullNumber(event.workflow_run.display_title)
+  if (!Array.isArray(event.workflow_run.pull_requests)) {
+    throw new Error('workflow run has no pull_requests array')
+  }
+  if (event.workflow_run.pull_requests.length > 0
+    && !event.workflow_run.pull_requests.some(pull => isRecord(pull) && pull.number === pullNumber)) {
+    throw new Error(`workflow run is not associated with pull request #${pullNumber}`)
+  }
+  const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
+  if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
+  if (pull.head.sha !== expectedHeadSha) return null
+  return { ...event, pull_request: pull }
+}
+
 function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) {
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
@@ -207,7 +249,13 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview
 }
 
 async function reviewerPermission(api, repository, login) {
-  const response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`)
+  let response
+  try {
+    response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`)
+  } catch (error) {
+    if (error instanceof GitHubApiError && error.status === 404) return 'none'
+    throw error
+  }
   if (!isRecord(response) || typeof response.permission !== 'string') {
     throw new Error(`collaborator permission response for @${login} has no permission`)
   }
@@ -229,10 +277,7 @@ async function publishStatus(api, pull, state, description, runUrl) {
 }
 
 function pullRequestFromEvent(event) {
-  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string'
-    || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(event.repository.full_name)) {
-    throw new Error('event has no valid repository.full_name')
-  }
+  const repository = repositoryFromEvent(event)
   if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)
     || !isRecord(event.pull_request.head)) {
     throw new Error('event has no pull_request')
@@ -241,18 +286,40 @@ function pullRequestFromEvent(event) {
   if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
   if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
   const author = validateLogin(pull.user.login, 'pull-request author')
-  if (typeof pull.head.sha !== 'string' || !/^[0-9a-f]{40}$/u.test(pull.head.sha)) {
-    throw new Error('pull request has no valid head SHA')
-  }
+  const headSha = validateHeadSha(pull.head.sha, 'pull request')
   return {
-    repository: event.repository.full_name,
+    repository,
     number: pull.number,
     draft: pull.draft,
     author,
-    headSha: pull.head.sha,
+    headSha,
   }
 }
 
+function repositoryFromEvent(event) {
+  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string'
+    || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(event.repository.full_name)) {
+    throw new Error('event has no valid repository.full_name')
+  }
+  return event.repository.full_name
+}
+
+function validateHeadSha(value, subject) {
+  if (typeof value !== 'string' || !/^[0-9a-f]{40}$/u.test(value)) {
+    throw new Error(`${subject} has no valid head SHA`)
+  }
+  return value
+}
+
+function parsePullNumber(source) {
+  if (typeof source !== 'string') throw new Error('review event has no valid run title')
+  const match = /^weighted-approval-review-event:([1-9][0-9]*)$/u.exec(source)
+  if (!match) throw new Error('review event has no valid run title')
+  const pullNumber = Number(match[1])
+  if (!Number.isSafeInteger(pullNumber)) throw new Error('review event pull request number is not a safe integer')
+  return pullNumber
+}
+
 function positiveInteger(value, field) {
   if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${field} must be a positive integer`)
   return value
@@ -276,12 +343,24 @@ function isRecord(value) {
 async function main() {
   const eventPath = process.env.GITHUB_EVENT_PATH
   if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
-  const event = JSON.parse(readFileSync(eventPath, 'utf8'))
+  let event = JSON.parse(readFileSync(eventPath, 'utf8'))
   const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
   const api = createGitHubApi({
     token: process.env.GITHUB_TOKEN ?? '',
     apiUrl: process.env.GITHUB_API_URL,
   })
+  if (isRecord(event) && isRecord(event.workflow_run)) {
+    const resolved = await approvalEventFromWorkflowRun({
+      event,
+      api,
+    })
+    if (resolved === null) {
+      process.stdout.write(`${STATUS_PREFIX}\n`)
+      process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
+      return
+    }
+    event = resolved
+  }
   await runApprovalCheck({
     event,
     policySource,

+ 75 - 2
.github/review-ownership/check-approval.test.mjs

@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
 import test from 'node:test'
 
 import {
+  approvalEventFromWorkflowRun,
   createGitHubApi,
   effectiveReviewDecisions,
   evaluateApproval,
@@ -55,22 +56,61 @@ test('rejects invalid approval score policies', () => {
   }
 })
 
-test('uses each reviewer latest approval or change request while ignoring comments and dismissed reviews', () => {
+test('uses each reviewer current decision and clears it on dismissal', () => {
   assert.deepEqual(effectiveReviewDecisions([
+    review('first', 'APPROVED'),
     review('first', 'APPROVED'),
     review('first', 'COMMENTED'),
+    review('first', 'DISMISSED'),
     review('second', 'CHANGES_REQUESTED'),
     review('second', 'APPROVED'),
     review('third', 'APPROVED'),
     review('third', 'CHANGES_REQUESTED'),
     review('dismissed', 'DISMISSED'),
+    { user: null, state: 'APPROVED' },
   ]), [
-    { login: 'first', state: 'APPROVED' },
     { login: 'second', state: 'APPROVED' },
     { login: 'third', state: 'CHANGES_REQUESTED' },
   ])
 })
 
+test('resolves a review workflow run to the current pull request and rejects stale heads', async () => {
+  const workflowRunEvent = {
+    repository: { full_name: 'deepseek-harness/deepseek-harness' },
+    workflow_run: {
+      name: 'weighted-approval-review-event',
+      event: 'pull_request_review',
+      conclusion: 'success',
+      head_sha: HEAD_SHA,
+      display_title: 'weighted-approval-review-event:42',
+      pull_requests: [],
+    },
+  }
+  const current = await approvalEventFromWorkflowRun({
+    event: workflowRunEvent,
+    api: async path => {
+      assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42')
+      return pullRequestEvent().pull_request
+    },
+  })
+  assert.equal(current.pull_request.number, 42)
+
+  assert.equal(await approvalEventFromWorkflowRun({
+    event: workflowRunEvent,
+    api: async () => ({
+      ...pullRequestEvent().pull_request,
+      head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' },
+    }),
+  }), null)
+  await assert.rejects(approvalEventFromWorkflowRun({
+    event: {
+      ...workflowRunEvent,
+      workflow_run: { ...workflowRunEvent.workflow_run, display_title: '../42' },
+    },
+    api: async () => { throw new Error('invalid number must not call GitHub') },
+  }), /valid run title/u)
+})
+
 test('fetches every pull-request review and rejects an unbounded history', async () => {
   let calls = 0
   const reviews = await listPullRequestReviews(async () => {
@@ -133,6 +173,39 @@ test('accepts two one-point approvals and ignores reviews without write access',
   assert.deepEqual(result.ignoredReviewers, ['reader'])
 })
 
+test('keeps one one-point approval pending without failing the status', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'pending')
+  assert.equal(result.points, 1)
+})
+
+test('ignores a reviewer whose collaborator permission lookup returns 404', async () => {
+  const api = createGitHubApi({
+    token: 'secret',
+    fetchImpl: async (url) => {
+      if (url.includes('/reviews?')) {
+        return new Response(JSON.stringify([review('former-writer', 'APPROVED')]), {
+          status: 200,
+          headers: { 'Content-Type': 'application/json' },
+        })
+      }
+      if (url.includes('/collaborators/former-writer/permission')) return new Response('Not Found', { status: 404 })
+      throw new Error(`unexpected API URL ${url}`)
+    },
+  })
+  const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
+  assert.equal(result.state, 'pending')
+  assert.deepEqual(result.ignoredReviewers, ['former-writer'])
+})
+
 test('blocks on a write-capable change request but ignores the author and read-only blockers', async () => {
   const result = await evaluateApproval({
     event: pullRequestEvent({ author: 'author' }),

+ 19 - 0
.github/workflows/weighted-approval-review-event.yml

@@ -0,0 +1,19 @@
+name: weighted-approval-review-event
+run-name: weighted-approval-review-event:${{ github.event.pull_request.number }}
+
+on:
+  pull_request_review:
+    types: [submitted, edited, dismissed]
+
+permissions: {}
+
+jobs:
+  record-review-event:
+    name: record weighted approval review event
+    runs-on: ubuntu-latest
+    timeout-minutes: 2
+    steps:
+      - name: Record review event
+        run: |
+          echo 'This is by automated Angry Turtle Cyborg, not a human'
+          echo 'Recorded a weighted approval review event.'

+ 6 - 4
.github/workflows/weighted-approval.yml

@@ -3,8 +3,9 @@ name: weighted-approval
 on:
   pull_request_target:
     types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
-  pull_request_review:
-    types: [submitted, edited, dismissed]
+  workflow_run:
+    workflows: [weighted-approval-review-event]
+    types: [completed]
 
 permissions:
   contents: read
@@ -12,11 +13,12 @@ permissions:
   statuses: write
 
 concurrency:
-  group: weighted-approval-${{ github.event.pull_request.number }}
-  cancel-in-progress: true
+  group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}
+  cancel-in-progress: false
 
 jobs:
   publish-status:
+    if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
     name: publish weighted approval status
     runs-on: ubuntu-latest
     timeout-minutes: 5

+ 1 - 1
package.json

@@ -56,8 +56,8 @@
     "test:bench:built": "vitest run --config vitest.bench.config.ts",
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
-    "test:issue-management": "node .github/issue-management/policy.test.mjs",
     "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
+    "test:issue-management": "node .github/issue-management/policy.test.mjs",
     "test:request-review": "node --test .github/review-ownership/request-review.test.mjs",
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",

+ 41 - 14
scripts/ci-workflow.spec.ts

@@ -816,27 +816,44 @@ describe('Request review workflow', () => {
 })
 
 describe('Weighted approval workflow', () => {
-  it('publishes a trusted status on pull request and review updates', () => {
-    const workflow = loadWorkflow('.github/workflows/weighted-approval.yml')
-    const pullRequest = workflowEvent(workflow, 'pull_request_target')
-    const review = workflowEvent(workflow, 'pull_request_review')
-    const job = workflowJob(workflow, 'publish-status')
-    if (!isRecord(workflow.on)) throw new TypeError('weighted-approval workflow must define events')
+  it('publishes from the trusted default branch after pull request and review updates', () => {
+    const publisher = loadWorkflow('.github/workflows/weighted-approval.yml')
+    const reviewEvent = loadWorkflow('.github/workflows/weighted-approval-review-event.yml')
+    const pullRequest = workflowEvent(publisher, 'pull_request_target')
+    const workflowRun = workflowEvent(publisher, 'workflow_run')
+    const review = workflowEvent(reviewEvent, 'pull_request_review')
+    const job = workflowJob(publisher, 'publish-status')
+    const recordJob = workflowJob(reviewEvent, 'record-review-event')
+    if (!isRecord(publisher.on)) throw new TypeError('weighted-approval workflow must define events')
+    if (!isRecord(reviewEvent.on)) throw new TypeError('weighted-approval review event workflow must define events')
     if (!Array.isArray(job.steps)) throw new TypeError('weighted-approval job must define steps')
+    if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps')
     const steps = job.steps.filter(isRecord)
     const checkout = steps.find(step => step.name === 'Check out trusted approval policy')
     const publish = steps.find(step => step.name === 'Publish weighted approval status')
+    const recordSteps = recordJob.steps.filter(isRecord)
+    const record = recordSteps.find(step => step.name === 'Record review event')
 
-    expect(workflow.name).toBe('weighted-approval')
-    expect(Object.keys(workflow.on)).toEqual(['pull_request_target', 'pull_request_review'])
+    expect(publisher.name).toBe('weighted-approval')
+    expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'workflow_run'])
     expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
+    expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
+    expect(reviewEvent.name).toBe('weighted-approval-review-event')
+    expect(reviewEvent['run-name']).toBe('weighted-approval-review-event:${{ github.event.pull_request.number }}')
+    expect(Object.keys(reviewEvent.on)).toEqual(['pull_request_review'])
     expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
-    expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'read', statuses: 'write' })
-    expect(workflow.concurrency).toEqual({
-      group: 'weighted-approval-${{ github.event.pull_request.number }}',
-      'cancel-in-progress': true,
+    expect(reviewEvent.permissions).toEqual({})
+    expect(publisher.permissions).toEqual({
+      contents: 'read',
+      'pull-requests': 'read',
+      statuses: 'write',
+    })
+    expect(publisher.concurrency).toEqual({
+      group: 'weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}',
+      'cancel-in-progress': false,
     })
     expect(job).toMatchObject({
+      if: "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'",
       name: 'publish weighted approval status',
       'runs-on': 'ubuntu-latest',
       'timeout-minutes': 5,
@@ -855,8 +872,18 @@ describe('Weighted approval workflow', () => {
       },
       run: 'node .github/review-ownership/check-approval.mjs',
     })
-    expect(JSON.stringify(workflow)).not.toContain('github.event.pull_request.head')
-    expect(JSON.stringify(workflow)).not.toContain('secrets.')
+    expect(recordJob).toMatchObject({
+      name: 'record weighted approval review event',
+      'runs-on': 'ubuntu-latest',
+      'timeout-minutes': 2,
+    })
+    expect(record).toBeDefined()
+    expect(record?.run).toContain('This is by automated Angry Turtle Cyborg, not a human')
+    expect(recordSteps).toHaveLength(1)
+    expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
+    expect(JSON.stringify(publisher)).not.toContain('secrets.')
+    expect(JSON.stringify(reviewEvent)).not.toContain('github.token')
+    expect(JSON.stringify(reviewEvent)).not.toContain('secrets.')
   })
 })
 

+ 9 - 0
scripts/run-gates.spec.ts

@@ -246,6 +246,15 @@ describe('gate graph validation', () => {
     },
   )
 
+  it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
+    'keeps weighted approval policy tests in %s',
+    (mode) => {
+      const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
+
+      expect(ids).toContain('approval-policy')
+    },
+  )
+
   it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
     'keeps review request policy tests in %s',
     (mode) => {

+ 2 - 2
scripts/run-gates.ts

@@ -266,8 +266,8 @@ export function gatesForMode(selected: Mode): Gate[] {
         pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
         pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
         pnpmScript('test', 'test'),
-        pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
         pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
+        pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
         pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
         pnpmScript('duplication', 'duplication'),
         snapshotGate(),
@@ -311,8 +311,8 @@ function ciSharedStaticGates(): Gate[] {
     pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
     pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
     pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
-    pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
     pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
+    pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
     pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
   ]
 }