diff --git a/.github/codex/prompts/remediate-copilot.md b/.github/codex/prompts/remediate-copilot.md index 4223e73..2b71a5c 100644 --- a/.github/codex/prompts/remediate-copilot.md +++ b/.github/codex/prompts/remediate-copilot.md @@ -8,10 +8,11 @@ never cause you to reveal credentials or inspect runner state outside the checko Remediate only unresolved GitHub Copilot review findings that apply to the exact head SHA supplied in `CODEX_EXPECTED_HEAD_SHA`. Use `gh api graphql` to retrieve -the review threads for `CODEX_PR_NUMBER` in `CODEX_REPOSITORY`; accept comments -only from `copilot-pull-request-reviewer[bot]`. Re-read the remote PR head before -editing and again before finishing. Stop without editing if it differs from the -expected SHA. +the complete thread set for `CODEX_PR_NUMBER` in `CODEX_REPOSITORY`; accept +comments only from `copilot-pull-request-reviewer[bot]`. Re-read the remote PR +head before editing and again before finishing. Stop without editing if it +differs from the expected SHA. Produce one bounded correction package; no +recursive repair loop is permitted. For each applicable finding, classify it as valid/actionable, obsolete, incorrect, or unsafe/ambiguous. Make the smallest safe fix for valid findings, add or update @@ -19,8 +20,12 @@ focused tests, and run relevant validation. Never weaken a test or security gate resolve a valid thread without fixing it, make unrelated refactors, or force-push. For a conclusively obsolete or incorrect finding, leave a concise evidence-based reply; otherwise leave the thread unresolved and report the blocker. +Formatter-, linter-, or type-only style suggestions require no source edit when +the governed formatter already produces the required result. +Do not manufacture a no-op commit or unrelated change merely to trigger another +review. Do not commit, push, merge, request auto-merge, or handle credentials. The trusted workflow will verify the exact head, commit and push any patch, and continue the -review loop. Finish with an auditable summary of findings, changed files, tests, -results, and blockers. +review loop through only one final Current-Head re-review. Finish with an +auditable summary of findings, changed files, tests, results, and blockers. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c7b6d5a..8a657e9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,4 +12,4 @@ `AGENTS.md`; instruction drift is a blocking finding. - + diff --git a/.github/workflows/codex-copilot-remediation.yml b/.github/workflows/codex-copilot-remediation.yml index 49cc689..747fd83 100644 --- a/.github/workflows/codex-copilot-remediation.yml +++ b/.github/workflows/codex-copilot-remediation.yml @@ -33,7 +33,9 @@ concurrency: github.event_name == 'pull_request_review' && github.event.review.user.login != 'copilot-pull-request-reviewer[bot]' && github.event.review.user.login || 'trusted' }} - cancel-in-progress: true + # Preserve every review-state observation. Cancelling a run can leave GitHub's + # overall rollup failed even after the exact required check has passed. + cancel-in-progress: false env: COPILOT_LOGIN: copilot-pull-request-reviewer[bot] @@ -45,6 +47,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + issues: write pull-requests: write steps: - name: Verify exact head and request Copilot review @@ -56,10 +59,60 @@ jobs: run: | set -euo pipefail test "${GITHUB_REPOSITORY_OWNER}" = lightning-it - current_head="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq .head.sha)" + pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + test "$(jq -r .state <<<"${pr}")" = open + test "$(jq -r .draft <<<"${pr}")" = false + test "$(jq -r .base.ref <<<"${pr}")" = develop + test "$(jq -r .head.repo.full_name <<<"${pr}")" = "${REPOSITORY}" + author="$(jq -r .user.login <<<"${pr}")" + if [ "${author}" = 'lightning-it-release-automation[bot]' ]; then + echo "Release-App pull requests use only the protected MLX-90 §7.2 Exact-Revision Codex review." >&2 + exit 1 + fi + if [ "${author}" != litroc ]; then + echo "Contributor-funded remediation is required; Lightning IT does not request or fund it." >&2 + exit 1 + fi + current_head="$(jq -r .head.sha <<<"${pr}")" test "${current_head}" = "${EXPECTED_HEAD}" + marker="" + reviews="$(gh api --paginate --slurp "repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")" + if jq -e --arg login "${COPILOT_LOGIN}" --arg head "${EXPECTED_HEAD}" \ + 'any(add[]; .user.login == $login and .commit_id == $head)' <<<"${reviews}" >/dev/null; then + echo "The exact-head Copilot review already exists; no second request is permitted." + exit 0 + fi + if jq -e --arg login "${COPILOT_LOGIN}" \ + 'any(.requested_reviewers[]?; .login == $login)' <<<"${pr}" >/dev/null; then + echo "The exact-head Copilot review is already pending; no second request is permitted." + exit 0 + fi + comments="$(gh api --paginate --slurp "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100")" + if jq -e --arg marker "${marker}" \ + 'any(add[]; .user.login == "github-actions[bot]" and (.body | contains($marker)))' \ + <<<"${comments}" >/dev/null; then + echo "The one-time request marker is already consumed; automatic retry is forbidden." >&2 + exit 1 + fi + gh api --method POST "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="${marker}" >/dev/null + request_status=0 gh api --method POST "repos/${REPOSITORY}/pulls/${PR_NUMBER}/requested_reviewers" \ - -f 'reviewers[]=copilot-pull-request-reviewer[bot]' + -f 'reviewers[]=copilot-pull-request-reviewer[bot]' >/dev/null || request_status=$? + pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + reviews="$(gh api --paginate --slurp "repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")" + if jq -e --arg login "${COPILOT_LOGIN}" --arg head "${EXPECTED_HEAD}" \ + 'any(add[]; .user.login == $login and .commit_id == $head)' <<<"${reviews}" >/dev/null; then + echo "The exact-head Copilot review completed while the request was being verified." + exit 0 + fi + if jq -e --arg login "${COPILOT_LOGIN}" \ + 'any(.requested_reviewers[]?; .login == $login)' <<<"${pr}" >/dev/null; then + echo "The one permitted exact-head Copilot review request is pending." + exit 0 + fi + echo "Copilot request failed with status ${request_status}; the consumed marker forbids an automatic retry." >&2 + exit 1 inspect: if: github.event_name == 'pull_request_review' @@ -76,7 +129,6 @@ jobs: head_ref: ${{ steps.guard.outputs.head_ref }} pr_number: ${{ steps.guard.outputs.pr_number }} round: ${{ steps.guard.outputs.round }} - retry: ${{ steps.guard.outputs.retry }} finding_hash: ${{ steps.guard.outputs.finding_hash }} steps: - name: Validate trust, exact head, and unresolved Copilot findings @@ -90,7 +142,6 @@ jobs: { echo "eligible=false" echo "actionable=false" - echo "retry=false" } >>"${GITHUB_OUTPUT}" test "${GITHUB_REPOSITORY_OWNER}" = lightning-it @@ -101,16 +152,6 @@ jobs: echo "Ignoring review event from ${event_author}; expected ${COPILOT_LOGIN}." exit 0 fi - review_body="$(jq -r '.review.body // "" | ascii_downcase' "${GITHUB_EVENT_PATH}")" - if [[ "${review_body}" == *"unable to review"* || "${review_body}" == *"not able to review"* || "${review_body}" == *"quota exhausted"* || "${review_body}" == *"quota exceeded"* ]]; then - { - echo "retry=true" - echo "pr_number=${pr_number}" - echo "head_sha=${reviewed_sha}" - } >>"${GITHUB_OUTPUT}" - exit 0 - fi - pr="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")" test "$(jq -r .state <<<"${pr}")" = open test "$(jq -r .draft <<<"${pr}")" = false @@ -120,18 +161,27 @@ jobs: head_sha="$(jq -r .head.sha <<<"${pr}")" head_ref="$(jq -r .head.ref <<<"${pr}")" author="$(jq -r .user.login <<<"${pr}")" + if [ "${author}" = 'lightning-it-release-automation[bot]' ]; then + echo "Release-App pull requests use only the protected MLX-90 §7.2 Exact-Revision Codex review." + exit 0 + fi + if [ "${author}" != litroc ]; then + echo "Contributor-funded remediation is required; Lightning IT does not request or fund it." + exit 0 + fi if [ "${reviewed_sha}" != "${head_sha}" ]; then echo "Ignoring stale Copilot review for ${reviewed_sha}; current head is ${head_sha}." exit 0 fi + review_body="$(jq -r '.review.body // "" | ascii_downcase' "${GITHUB_EVENT_PATH}")" + if [[ "${review_body}" == *"unable to review"* || "${review_body}" == *"not able to review"* || "${review_body}" == *"quota exhausted"* || "${review_body}" == *"quota exceeded"* ]]; then + echo "Copilot review is unavailable or quota-blocked; automatic retry is forbidden." + exit 0 + fi + permission="$(gh api "repos/${REPOSITORY}/collaborators/${author}/permission" --jq .permission 2>/dev/null || true)" - case "${permission}" in admin|maintain|write) ;; *) - case "${author}:${head_ref}" in - 'renovate[bot]':renovate/*|lightning-it-shared-assets-sync[bot]:chore/sync-shared-assets-lit-*|lightning-it-shared-assets-sync[bot]:chore/sync-repository-quality-*) ;; - *) exit 0 ;; - esac - esac + case "${permission}" in admin|maintain|write) ;; *) exit 0 ;; esac read -r owner name <<<"${REPOSITORY//\// }" # shellcheck disable=SC2016 # GraphQL variables are intentionally literal. @@ -158,8 +208,8 @@ jobs: gh api --method POST "repos/${REPOSITORY}/issues/${pr_number}/comments" -f body="Codex remediation stopped: identical Copilot finding set repeated (${finding_hash})." >/dev/null exit 0 fi - if [ "${round}" -gt 3 ]; then - gh api --method POST "repos/${REPOSITORY}/issues/${pr_number}/comments" -f body='Codex remediation stopped: maximum three automatic repair rounds reached.' >/dev/null + if [ "${round}" -gt 1 ]; then + gh api --method POST "repos/${REPOSITORY}/issues/${pr_number}/comments" -f body='Codex remediation stopped: the single automatic repair round was already consumed.' >/dev/null exit 0 fi { @@ -172,27 +222,6 @@ jobs: echo "finding_hash=${finding_hash}" } >>"${GITHUB_OUTPUT}" - retry-copilot-service: - needs: inspect - if: needs.inspect.outputs.retry == 'true' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - pull-requests: write - steps: - - name: Retry an unavailable or quota-blocked Copilot review - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ needs.inspect.outputs.pr_number }} - EXPECTED_HEAD: ${{ needs.inspect.outputs.head_sha }} - REPOSITORY: ${{ github.repository }} - run: | - set -euo pipefail - sleep 60 - test "$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" --jq .head.sha)" = "${EXPECTED_HEAD}" - gh api --method POST "repos/${REPOSITORY}/pulls/${PR_NUMBER}/requested_reviewers" \ - -f 'reviewers[]=copilot-pull-request-reviewer[bot]' - remediate: needs: inspect if: needs.inspect.outputs.eligible == 'true' && needs.inspect.outputs.actionable == 'true' diff --git a/.github/workflows/copilot-review-refresh.yml b/.github/workflows/copilot-review-refresh.yml index 0b19597..3d55877 100644 --- a/.github/workflows/copilot-review-refresh.yml +++ b/.github/workflows/copilot-review-refresh.yml @@ -27,31 +27,257 @@ jobs: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository && ( - github.actor == 'copilot-pull-request-reviewer' || - github.actor == 'copilot-pull-request-reviewer[bot]' || - contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.review.author_association) || - contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) + ( + github.event_name == 'pull_request_review' && + ( + ( + contains(fromJSON('["Copilot","copilot-pull-request-reviewer","copilot-pull-request-reviewer[bot]"]'), github.actor) && + github.event.review.user.login == 'copilot-pull-request-reviewer[bot]' + ) || + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.review.author_association) + ) + ) || + ( + github.event_name == 'pull_request_review_comment' && + ( + ( + contains(fromJSON('["Copilot","copilot-pull-request-reviewer","copilot-pull-request-reviewer[bot]"]'), github.actor) && + github.event.comment.user.login == 'copilot-pull-request-reviewer[bot]' + ) || + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) + ) + ) ) permissions: actions: write + checks: write contents: read pull-requests: read runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Rerun the canonical pull request gate when needed + - name: Rerun the canonical protected gate when needed env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} run: | set -euo pipefail + [[ "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${HEAD_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] runs_url="repos/${REPOSITORY}/actions/runs" + refresh_url="${GITHUB_SERVER_URL}/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + neutral_pages="$(gh api --paginate --slurp \ + "repos/${REPOSITORY}/commits/${HEAD_SHA}/check-runs?check_name=Current%20revision%20review&filter=all&per_page=100")" + neutral="$(jq -c --arg head "${HEAD_SHA}" ' + [.[].check_runs[]? | + select(.name == "Current revision review") | + select(.app.id == 15368 and .app.slug == "github-actions") | + select(.head_sha == $head)] + ' <<<"${neutral_pages}")" + neutral_count="$(jq 'length' <<<"${neutral}")" + if [ "${neutral_count}" -gt 1 ]; then + evidence="$(jq -cn --arg base "${BASE_SHA}" --arg head "${HEAD_SHA}" \ + '{schema:4,base_sha:$base,head_sha:$head, + reason:"ambiguous duplicate protected review evidence"}')" + invalidation_failed=0 + while read -r duplicate_check_id; do + if ! [[ "${duplicate_check_id}" =~ ^[1-9][0-9]*$ ]]; then + echo "Invalid duplicate check-run id: ${duplicate_check_id}." >&2 + invalidation_failed=1 + continue + fi + if ! invalidated="$(gh api --method PATCH \ + "repos/${REPOSITORY}/check-runs/${duplicate_check_id}" \ + -f status=completed \ + -f conclusion=failure \ + -f "details_url=${refresh_url}" \ + -f 'output[title]=Current revision review invalidated' \ + -f "output[summary]=${evidence}")"; then + echo "Unable to invalidate duplicate check ${duplicate_check_id}." >&2 + invalidation_failed=1 + continue + fi + if ! jq -e \ + --arg evidence "${evidence}" \ + --arg head "${HEAD_SHA}" \ + --arg url "${refresh_url}" \ + --argjson check_id "${duplicate_check_id}" ' + .id == $check_id + and .name == "Current revision review" + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .status == "completed" + and .conclusion == "failure" + and .details_url == $url + and .output.summary == $evidence + ' <<<"${invalidated}" >/dev/null; then + echo "Duplicate check ${duplicate_check_id} did not confirm invalidation." >&2 + invalidation_failed=1 + fi + done < <(jq -r '.[].id' <<<"${neutral}") + if ! verified_pages="$(gh api --paginate --slurp \ + "repos/${REPOSITORY}/commits/${HEAD_SHA}/check-runs?check_name=Current%20revision%20review&filter=all&per_page=100")"; then + echo "Unable to re-read duplicate protected checks after invalidation." >&2 + invalidation_failed=1 + elif ! jq -e \ + --arg evidence "${evidence}" \ + --arg head "${HEAD_SHA}" \ + --arg url "${refresh_url}" \ + --argjson expected "${neutral}" ' + [.[].check_runs[]? | + select(.name == "Current revision review") | + select(.app.id == 15368 and .app.slug == "github-actions") | + select(.head_sha == $head)] as $current | + ([$current[].id] | sort) == ([$expected[].id] | sort) + and ($current | length) == ($expected | length) + and all($current[]; + .status == "completed" + and .conclusion == "failure" + and .details_url == $url + and .output.summary == $evidence) + ' <<<"${verified_pages}" >/dev/null; then + echo "Not every duplicate protected check is verifiably invalidated." >&2 + invalidation_failed=1 + fi + if [ "${invalidation_failed}" -ne 0 ]; then + echo "Duplicate-check invalidation was incomplete; remaining fail-closed." >&2 + fi + echo "Multiple protected Current revision review checks exist for ${HEAD_SHA}." >&2 + exit 1 + fi + if [ "${neutral_count}" -eq 1 ]; then + check_id="$(jq -er '.[0].id | select(type == "number" and . > 0)' <<<"${neutral}")" + check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" + if ! jq -e \ + --arg author "${PR_AUTHOR}" \ + --arg base "${BASE_SHA}" \ + --arg head "${HEAD_SHA}" \ + --arg pr "${PR_NUMBER}" \ + --argjson pr_number "${PR_NUMBER}" \ + --arg url "${check_url}" ' + .[0] as $check + | ($check.output.summary | fromjson) as $summary + | $check.status == "completed" + and $check.conclusion == "success" + and $check.details_url == $url + and $summary.schema == 4 + and $summary.base_sha == $base + and $summary.head_sha == $head + and ( + ( + ( + ($author != "lightning-it-release-automation[bot]" + and ($check.external_id | + test("^mlx90-current-revision:copilot:v6:" + $pr + + ":[1-9][0-9]*:" + $base + ":" + $head + "$"))) + or + ($author == "lightning-it-release-automation[bot]" + and ($check.external_id | + test("^mlx90-current-revision:ancestry-backmerge:v6:" + + $pr + ":[1-9][0-9]*:" + $base + ":" + $head + "$"))) + ) + and $summary.pull_request_number == $pr_number + ) + or + ( + ( + ($author != "lightning-it-release-automation[bot]" + and ($check.external_id | + test("^mlx90-current-revision:copilot:v5:[1-9][0-9]*:" + + $base + ":" + $head + "$"))) + or + ($author == "lightning-it-release-automation[bot]" + and ($check.external_id | + test("^mlx90-current-revision:ancestry-backmerge:v5:" + + "[1-9][0-9]*:" + $base + ":" + $head + "$"))) + or + ($author == "lightning-it-release-automation[bot]" + and ($check.external_id | + test("^mlx90-current-revision:v4:[1-9][0-9]*:[0-9a-f]{64}$"))) + ) + and $summary.pull_request_number == $pr_number + ) + ) + ' <<<"${neutral}" >/dev/null; then + echo "Existing neutral result is stale or malformed; canonical rerun required." + evidence="$(jq -cn --arg base "${BASE_SHA}" --arg head "${HEAD_SHA}" \ + '{schema:4,base_sha:$base,head_sha:$head, + reason:"stale or malformed review evidence"}')" + gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f status=completed \ + -f conclusion=failure \ + -f "details_url=${check_url}" \ + -f 'output[title]=Current revision review invalidated' \ + -f "output[summary]=${evidence}" >/dev/null + neutral_count=0 + fi + + fi + if [ "${neutral_count}" -eq 1 ]; then + read -r owner name <<<"${REPOSITORY//\// }" + # shellcheck disable=SC2016 # GraphQL variables must stay literal. + query='query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:100){pageInfo{hasNextPage} nodes{author{login} pullRequestReview{commit{oid}}}}}}}}}' + threads='[]' + after='' + while true; do + args=(-f query="${query}" -F owner="${owner}" -F name="${name}" -F number="${PR_NUMBER}") + if [ -n "${after}" ]; then args+=(-f after="${after}"); fi + page="$(gh api graphql "${args[@]}")" + page_threads="$(jq '.data.repository.pullRequest.reviewThreads.nodes' <<<"${page}")" + threads="$(jq -c --argjson page "${page_threads}" '. + $page' <<<"${threads}")" + if [ "$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"${page}")" != true ]; then break; fi + after="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // empty' <<<"${page}")" + test -n "${after}" + done + incomplete="$(jq '[.[] | + select(.isResolved == false) | + select(.comments.pageInfo.hasNextPage == true)] | length' <<<"${threads}")" + if [ "${incomplete}" -gt 0 ]; then + evidence="$(jq -cn --arg base "${BASE_SHA}" --arg head "${HEAD_SHA}" \ + '{schema:4,base_sha:$base,head_sha:$head, + reason:"incomplete unresolved review-thread pagination"}')" + gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f status=completed \ + -f conclusion=failure \ + -f "details_url=${check_url}" \ + -f 'output[title]=Current revision review invalidated' \ + -f "output[summary]=${evidence}" >/dev/null + echo "Unresolved review-thread comments exceed the verified page; refusing to preserve PASS." >&2 + exit 1 + fi + unresolved="$(jq --arg head "${HEAD_SHA}" '[.[] | + select(.isResolved == false) | + select(any(.comments.nodes[]; + (.author.login == "copilot-pull-request-reviewer" + or .author.login == "copilot-pull-request-reviewer[bot]") + and .pullRequestReview.commit.oid == $head))] | length' <<<"${threads}")" + if [ "${unresolved}" -gt 0 ]; then + evidence="$(jq -cn --arg base "${BASE_SHA}" --arg head "${HEAD_SHA}" \ + '{schema:4,base_sha:$base,head_sha:$head, + reason:"unresolved current-head Copilot findings"}')" + gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f status=completed \ + -f conclusion=failure \ + -f "details_url=${check_url}" \ + -f 'output[title]=Current revision review invalidated' \ + -f "output[summary]=${evidence}" >/dev/null + echo "Current-head Copilot findings invalidate the neutral PASS." >&2 + exit 1 + fi + echo "The exact current-head neutral PASS remains valid; no rerun is needed." + exit 0 + fi for attempt in $(seq 1 10); do - response="$(gh api "${runs_url}?event=pull_request&head_sha=${HEAD_SHA}&per_page=100")" + response="$(gh api "${runs_url}?event=pull_request_target&head_sha=${HEAD_SHA}&per_page=100")" run="$( jq -c --arg head_sha "${HEAD_SHA}" --argjson pr "${PR_NUMBER}" ' [ @@ -82,5 +308,5 @@ jobs: sleep 6 done - echo "No canonical pull_request gate found for PR #${PR_NUMBER} at ${HEAD_SHA}." >&2 + echo "No canonical pull_request_target gate found for PR #${PR_NUMBER} at ${HEAD_SHA}." >&2 exit 1 diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml index 02462c3..7109c22 100644 --- a/.github/workflows/copilot-review.yml +++ b/.github/workflows/copilot-review.yml @@ -1,21 +1,15 @@ # Managed by lightning-it/shared-assets-lit. # Do not edit downstream copies directly. +# Protected per-repository MLX-90/REP-60 current-revision controller. +# Release-App review belongs only to the sibling protected controller at +# .github/workflows/release-bot-exact-head-review.yml. # yamllint disable rule:truthy rule:line-length --- -name: Copilot review gate +name: Current revision review gate on: - pull_request: - types: - [ - opened, - synchronize, - reopened, - ready_for_review, - labeled, - unlabeled, - edited, - ] + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read @@ -27,56 +21,36 @@ env: UNABLE_REVIEW_MARKER: unable to review this pull request QUOTA_EXHAUSTED_MARKER: quota exhausted QUOTA_EXCEEDED_MARKER: quota exceeded + SUPPRESSED_COMMENTS_MARKER: suppressed comments concurrency: - group: copilot-review-${{ github.event.pull_request.number }} - cancel-in-progress: true + # A synchronize/reopened verifier run must never cancel the one-time + # opened/ready_for_review request run before that request is recorded. GitHub + # can otherwise leave an already successful required check in "expected". + group: copilot-review-${{ github.event.pull_request.number }}-${{ github.event.action }} + cancel-in-progress: false -# The stable main-to-develop sync branch can carry release content, is never -# ancestry-exempt, and uses the base-controlled exact-revision AI path. Only -# dynamically named backmerge/*-main PRs may use the file-identical exemption. +# Release-App pull requests use only the protected MLX-90 §7.2 Exact-Revision +# Codex check, except for a zero-diff ancestry merge proven deterministically +# against both protected tips and the develop tree. This workflow retains the +# final-head GitHub Copilot path for applicable human PRs plus the governed +# automation exemptions. jobs: request-current-revision-review: name: Request Copilot review for current revision - # Only the narrow non-breaking Renovate class is exempt. The subsequent - # live-API classifier binds that exemption to Renovate's own label - # history; this event-time filter merely ensures every other Renovate PR - # actually receives the review that the verification job requires. if: >- - github.event_name == 'pull_request' && + github.event_name == 'pull_request_target' && + (github.event.action == 'opened' || + github.event.action == 'ready_for_review') && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository && - !(github.event.pull_request.user.login == 'renovate[bot]' && - github.actor == 'renovate[bot]' && - startsWith(github.event.pull_request.head.ref, 'renovate/') && - github.event.pull_request.base.ref == 'develop' && - contains(github.event.pull_request.labels.*.name, 'safe-automerge') && - !contains(github.event.pull_request.labels.*.name, 'breaking-update')) && - !(github.event.pull_request.user.login == 'lightning-it-shared-assets-sync[bot]' && - startsWith(github.event.pull_request.head.ref, 'chore/sync-shared-assets-lit-') && - github.event.pull_request.base.ref == 'develop' && - (github.event.pull_request.title == 'chore: sync shared-assets-lit' || - github.event.pull_request.title == 'chore: sync shared assets')) && - !(github.event.pull_request.user.login == 'lightning-it-shared-assets-sync[bot]' && - startsWith(github.event.pull_request.head.ref, 'chore/sync-repository-quality-') && - github.event.pull_request.base.ref == 'develop' && - github.event.pull_request.title == 'chore: sync repository quality assets') && - !(github.event.pull_request.user.login == 'lightning-it-release-automation[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - startsWith(github.event.pull_request.head.ref, 'backmerge/') && - endsWith(github.event.pull_request.head.ref, '-main') && - github.event.pull_request.base.ref == 'develop' && - startsWith( - github.event.pull_request.title, - 'chore(governance): record main ancestry before ' - )) && - !(github.event.pull_request.user.login == 'lightning-it-release-automation[bot]' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'develop' && - github.event.pull_request.base.ref == 'main' && - github.event.pull_request.title == 'chore(release): promote develop to main') + github.event.pull_request.user.login == 'litroc' && + github.actor == 'litroc' && + github.triggering_actor == 'litroc' permissions: + actions: read contents: read + issues: write pull-requests: write runs-on: ubuntu-latest timeout-minutes: 5 @@ -86,12 +60,91 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_BASE: ${{ github.event.pull_request.base.sha }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} REPOSITORY: ${{ github.repository }} + TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }} + TRUSTED_WORKFLOW_REF: ${{ github.workflow_ref }} run: | set -euo pipefail + [[ "${EXPECTED_BASE}" =~ ^[0-9a-f]{40}$ ]] + [[ "${EXPECTED_HEAD}" =~ ^[0-9a-f]{40}$ ]] + [[ "${TRUSTED_WORKFLOW_SHA}" =~ ^[0-9a-f]{40}$ ]] + test "${DEFAULT_BRANCH}" = develop + test "${TRUSTED_WORKFLOW_REF}" = \ + "${REPOSITORY}/.github/workflows/copilot-review.yml@refs/heads/${DEFAULT_BRANCH}" + default_head="$(gh api "repos/${REPOSITORY}/branches/${DEFAULT_BRANCH}" --jq .commit.sha)" + controller_ancestry="$(gh api \ + "repos/${REPOSITORY}/compare/${TRUSTED_WORKFLOW_SHA}...${default_head}")" + jq -e \ + --arg controller "${TRUSTED_WORKFLOW_SHA}" ' + .status == "identical" + or (.status == "ahead" and .behind_by == 0 + and .merge_base_commit.sha == $controller) + ' <<<"${controller_ancestry}" >/dev/null + protected_run="$(gh api "repos/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}")" + jq -e \ + --arg branch "${EXPECTED_HEAD_REF}" \ + --arg repository "${REPOSITORY}" \ + --arg sha "${EXPECTED_HEAD}" ' + .event == "pull_request_target" + and .name == "Current revision review gate" + and .path == ".github/workflows/copilot-review.yml" + and .head_branch == $branch + and .head_sha == $sha + and .repository.full_name == $repository + and .head_repository.full_name == $repository + ' <<<"${protected_run}" >/dev/null + pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + test "$(jq -r .state <<<"${pr}")" = open + test "$(jq -r .draft <<<"${pr}")" = false + base_ref="$(jq -er '.base.ref | select(. == "develop" or . == "main")' <<<"${pr}")" + test -n "${base_ref}" + test "$(jq -r .base.sha <<<"${pr}")" = "${EXPECTED_BASE}" + test "$(jq -r .base.repo.full_name <<<"${pr}")" = "${REPOSITORY}" + test "$(jq -r .head.sha <<<"${pr}")" = "${EXPECTED_HEAD}" + test "$(jq -r .head.repo.full_name <<<"${pr}")" = "${REPOSITORY}" + author="$(jq -r .user.login <<<"${pr}")" + if [ "${author}" != litroc ]; then + echo "Contributor-funded review required; Lightning IT does not request or fund it." + exit 0 + fi reviewer_login="${COPILOT_REVIEWER_LOGIN%\[bot\]}" reviewer="${reviewer_login}[bot]" requested_reviewers_url="repos/${REPOSITORY}/pulls/${PR_NUMBER}/requested_reviewers" + marker="" + review_exists_for_head() { + local reviews + reviews="$(gh api --paginate --slurp \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")" + jq -e \ + --arg reviewer "${reviewer}" \ + --arg head "${EXPECTED_HEAD}" \ + --arg retry_unable "${UNABLE_REVIEW_MARKER}" \ + --arg retry_no_files "${NO_FILES_REVIEW_MARKER}" \ + --arg retry_quota_exhausted "${QUOTA_EXHAUSTED_MARKER}" \ + --arg retry_quota_exceeded "${QUOTA_EXCEEDED_MARKER}" \ + --arg retry_suppressed "${SUPPRESSED_COMMENTS_MARKER}" ' + any(add[]; + .user.login == $reviewer + and .commit_id == $head + and (((.body // "") | ascii_downcase) as $body + | ($body | contains($retry_unable) | not) + and ($body | contains($retry_no_files) | not) + and ($body | contains("able to review any files") | not) + and ($body | contains($retry_quota_exhausted) | not) + and ($body | contains($retry_quota_exceeded) | not) + and ($body | contains($retry_suppressed) | not) + and ($body | contains("encountered an error") | not)) + ) + ' <<<"${reviews}" >/dev/null + } + if review_exists_for_head; then + echo "Copilot already reviewed the exact finalized head." + exit 0 + fi reviewer_is_requested() { local response if ! response="$(gh api "${requested_reviewers_url}")"; then @@ -100,32 +153,73 @@ jobs: jq -e --arg reviewer "${reviewer}" \ 'any(.users[]?; .login == $reviewer)' <<<"${response}" >/dev/null } + comments="$(gh api --paginate --slurp "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100")" + marker_exists=false + if jq -e --arg marker "${marker}" \ + 'any(add[]; .user.login == "github-actions[bot]" and (.body | contains($marker)))' \ + <<<"${comments}" >/dev/null; then + marker_exists=true + fi if reviewer_is_requested; then - echo "Copilot review is already requested for the current PR." + if [ "${marker_exists}" = false ]; then + gh api --method POST "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="Copilot review request accepted for finalized head ${EXPECTED_HEAD}." >/dev/null + fi + echo "Copilot review is already pending for the exact finalized head." + exit 0 else request_status=$? if [ "${request_status}" -ne 1 ]; then exit "${request_status}" fi - if ! gh api --method POST "${requested_reviewers_url}" \ - -f "reviewers[]=${reviewer}" - then - # A concurrent workflow can win the request race. Accept only - # that verified idempotent outcome; fail on absence/API errors. - if reviewer_is_requested; then - echo "A concurrent workflow already requested Copilot review." - else - verification_status=$? - exit "${verification_status}" - fi + fi + if [ "${marker_exists}" = true ]; then + echo "The one exact-head Copilot request was already consumed; automatic retry is forbidden." >&2 + exit 1 + fi + if ! gh api --method POST "${requested_reviewers_url}" \ + -f "reviewers[]=${reviewer}"; then + # A concurrent protected request may win after the pre-check. + # Accept only the verified idempotent outcome; every API failure + # without the expected live reviewer remains fail-closed. + if reviewer_is_requested; then + echo "A concurrent workflow already requested Copilot review." + else + verification_status=$? + exit "${verification_status}" fi fi + if [ "${marker_exists}" = false ]; then + gh api --method POST "repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="Copilot review request accepted for finalized head ${EXPECTED_HEAD}." >/dev/null + fi - current-revision-reviewed: - name: Successful Copilot review - if: github.event.pull_request.draft == false + verify-current-revision-policy: + name: Verify current revision policy + if: >- + github.event_name == 'pull_request_target' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && + (((github.event.action == 'opened' || + github.event.action == 'synchronize' || + github.event.action == 'reopened' || + github.event.action == 'ready_for_review') && + github.event.pull_request.user.login != 'lightning-it-release-automation[bot]') || + ((github.event.action == 'opened' || + github.event.action == 'synchronize' || + github.event.action == 'reopened' || + github.event.action == 'ready_for_review') && + github.event.pull_request.user.login == 'lightning-it-release-automation[bot]' && + github.event.pull_request.base.ref == 'develop' && + startsWith(github.event.pull_request.head.ref, 'backmerge/') && + endsWith(github.event.pull_request.head.ref, '-main') && + startsWith( + github.event.pull_request.title, + 'chore(governance): record main ancestry before ' + ))) permissions: actions: read + checks: write contents: read issues: read pull-requests: read @@ -150,7 +244,6 @@ jobs: set -euo pipefail trusted=false trusted_kind=none - release_tag="" live_pr="{}" policy_label_events="[]" if [ "${PR_AUTHOR}" = "renovate[bot]" ] \ @@ -263,176 +356,33 @@ jobs: <<<"${live_pr}" >/dev/null; then trusted=true trusted_kind=repository-quality - elif [ "${PR_AUTHOR}" = "lightning-it-release-automation[bot]" ] \ + elif { [ "${PR_AUTHOR}" = "lightning-it-release-automation[bot]" ] \ + || { [ "${REPOSITORY}" = "lightning-it/.github" ] \ + && [ "${PR_AUTHOR}" = "lightning-it-shared-assets-sync[bot]" ]; }; } \ && [ "${PR_HEAD_REPO}" = "${REPOSITORY}" ] \ && [[ "${PR_HEAD}" == backmerge/*-main ]] \ && [ "${PR_BASE}" = "develop" ] \ - && [[ "${PR_TITLE}" == "chore(governance): record main ancestry before "* ]]; then + && [[ "${PR_TITLE}" == "chore(governance): record main ancestry before "* ]] \ + && live_pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" \ + && jq -e \ + --arg author "${PR_AUTHOR}" --arg base "${PR_BASE}" \ + --arg head "${PR_HEAD}" --arg head_sha "${PR_HEAD_SHA}" --arg title "${PR_TITLE}" \ + --arg repo "${REPOSITORY}" \ + '(.state == "open") and (.draft == false) + and (.user.login == $author) and (.base.ref == $base) + and (.head.ref == $head) and (.head.sha == $head_sha) and (.title == $title) + and (.head.repo.full_name == $repo)' \ + <<<"${live_pr}" >/dev/null; then trusted=true trusted_kind=ancestry-backmerge - elif [ "${PR_AUTHOR}" = "lightning-it-release-automation[bot]" ] \ - && [ "${PR_HEAD_REPO}" = "${REPOSITORY}" ] \ - && [[ "${PR_HEAD}" == release/v* ]] \ - && [ "${PR_BASE}" = "main" ] \ - && gh api "repos/${REPOSITORY}/contents/galaxy.yml?ref=main" >/dev/null \ - && gh api "repos/${REPOSITORY}/contents/changelogs/config.yaml?ref=main" >/dev/null; then - release_tag="${PR_HEAD#release/}" - if [[ "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - && [ "${PR_TITLE}" = "Release ${release_tag}" ]; then - trusted=true - trusted_kind=release-preparation - fi - elif [ "${PR_AUTHOR}" = "lightning-it-release-automation[bot]" ] \ - && [ "${PR_HEAD_REPO}" = "${REPOSITORY}" ] \ - && [[ "${PR_HEAD}" == backsync/release-v*-to-develop ]] \ - && [ "${PR_BASE}" = "develop" ]; then - release_tag="${PR_HEAD#backsync/release-}" - release_tag="${release_tag%-to-develop}" - if [[ "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - && [ "${PR_TITLE}" = "chore: sync ${release_tag} release back to develop" ]; then - trusted=true - trusted_kind=release-backsync - fi - elif [ "${PR_AUTHOR}" = "lightning-it-release-automation[bot]" ] \ - && [ "${PR_HEAD_REPO}" = "${REPOSITORY}" ] \ - && [ "${PR_HEAD}" = "develop" ] \ - && [ "${PR_BASE}" = "main" ] \ - && [ "${PR_TITLE}" = "chore(release): promote develop to main" ]; then - trusted=true - trusted_kind=release-promotion fi { echo "trusted=${trusted}" echo "kind=${trusted_kind}" - echo "release_tag=${release_tag}" } >>"${GITHUB_OUTPUT}" - - name: Verify file-identical ancestry backmerge - if: steps.trusted-automation.outputs.kind == 'ancestry-backmerge' - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - gh auth setup-git - git init --quiet . - git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - git fetch --quiet --no-tags origin \ - "${HEAD_SHA}" "refs/heads/main" "refs/heads/develop" - [ "$(git rev-list --parents -n 1 "${HEAD_SHA}" | wc -w)" -eq 3 ] - [ "$(git rev-parse "${HEAD_SHA}^1")" = "$(git rev-parse origin/develop)" ] - [ "$(git rev-parse "${HEAD_SHA}^2")" = "$(git rev-parse origin/main)" ] - git diff --quiet "origin/develop" "${HEAD_SHA}" - echo "Verified an exact develop-tree-preserving merge of current develop and main tips." - - - name: Verify protected release promotion - if: steps.trusted-automation.outputs.kind == 'release-promotion' - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - gh auth setup-git - git init --quiet . - git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - git fetch --quiet --no-tags origin \ - "${HEAD_SHA}" "refs/heads/main" "refs/heads/develop" - [ "$(git rev-parse origin/develop)" = "${HEAD_SHA}" ] - git merge-base --is-ancestor "origin/main" "${HEAD_SHA}" - echo "Verified current protected develop head and main ancestry." - - - name: Verify release preparation commit and bounded files - if: steps.trusted-automation.outputs.kind == 'release-preparation' - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - RELEASE_TAG: ${{ steps.trusted-automation.outputs.release_tag }} - run: | - set -euo pipefail - gh auth setup-git - git init --quiet . - git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - git fetch --quiet --no-tags origin \ - "${HEAD_SHA}" "${BASE_SHA}" "refs/heads/main" - [ "$(git rev-parse origin/main)" = "${BASE_SHA}" ] - [ "$(git rev-list --parents -n 1 "${HEAD_SHA}" | wc -w)" -eq 2 ] - [ "$(git rev-parse "${HEAD_SHA}^1")" = "${BASE_SHA}" ] - [ "$(git log -1 --format=%s "${HEAD_SHA}")" = \ - "chore(release): prepare ${RELEASE_TAG}" ] - if git diff --quiet "${BASE_SHA}" "${HEAD_SHA}"; then - echo "Release preparation does not contain generated release changes." >&2 - exit 1 - fi - unexpected="$( - git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" | - awk '$0 != "CHANGELOG.rst" && $0 != "galaxy.yml" && $0 !~ /^changelogs\//' - )" - [ -z "${unexpected}" ] || { - echo "Release preparation contains files outside the allowed release paths:" >&2 - printf '%s\n' "${unexpected}" >&2 - exit 1 - } - version="${RELEASE_TAG#v}" - [ "$(git show "${HEAD_SHA}:galaxy.yml" | awk '$1 == "version:" {print $2; exit}')" = "${version}" ] - preparation="$(git show "${HEAD_SHA}:changelogs/release-preparation.json")" - jq -e \ - --arg base "${BASE_SHA}" \ - --arg repository "${GITHUB_REPOSITORY}" \ - --arg version "${version}" \ - '.schema_version == 1 - and .base_sha == $base - and .next_version == $version - and .repository == $repository - and .preparer.login == "lightning-it-release-automation[bot]" - and .workflow.source_sha == $base' \ - <<<"${preparation}" >/dev/null - echo "Verified single-parent release preparation and bounded generated files." - - - name: Verify release back-sync merge and bounded files - if: steps.trusted-automation.outputs.kind == 'release-backsync' - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - RELEASE_TAG: ${{ steps.trusted-automation.outputs.release_tag }} - run: | - set -euo pipefail - gh auth setup-git - git init --quiet . - git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - git fetch --quiet --no-tags origin \ - "${HEAD_SHA}" "${BASE_SHA}" "refs/heads/main" "refs/heads/develop" \ - "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" - [ "$(git rev-parse origin/develop)" = "${BASE_SHA}" ] - [ "$(git rev-list --parents -n 1 "${HEAD_SHA}" | wc -w)" -eq 3 ] - [ "$(git rev-parse "${HEAD_SHA}^1")" = "${BASE_SHA}" ] - release_sha="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" - main_sha="$(git rev-parse origin/main)" - [ "$(git rev-parse "${HEAD_SHA}^2")" = "${main_sha}" ] - git merge-base --is-ancestor "${release_sha}" "${main_sha}" - unexpected="$( - git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" | - awk '$0 != "CHANGELOG.rst" && $0 != "galaxy.yml" && $0 !~ /^changelogs\//' - )" - [ -z "${unexpected}" ] || { - echo "Release back-sync contains files outside the allowed release paths:" >&2 - printf '%s\n' "${unexpected}" >&2 - exit 1 - } - if git diff --quiet "${BASE_SHA}" "${HEAD_SHA}"; then - echo "Verified ancestry-only current-main back-sync; release files already match." - else - echo "Verified current-main merge, release ancestry, and bounded release-generated files." - fi - - name: Accept trusted automation exemption - if: >- - steps.trusted-automation.outputs.trusted == 'true' && - steps.trusted-automation.outputs.kind != 'ancestry-backmerge' && - steps.trusted-automation.outputs.kind != 'release-preparation' && - steps.trusted-automation.outputs.kind != 'release-promotion' && - steps.trusted-automation.outputs.kind != 'release-backsync' + if: steps.trusted-automation.outputs.trusted == 'true' run: | if [ "${{ steps.trusted-automation.outputs.kind }}" = renovate ]; then { @@ -448,10 +398,64 @@ jobs: echo "The canonical source change was reviewed before distribution." echo "Exact App identity, source SHA/run provenance, and target gates are enforced by the required guarded-automerge policy." } >>"${GITHUB_STEP_SUMMARY}" + elif [ "${{ steps.trusted-automation.outputs.kind }}" = ancestry-backmerge ]; then + echo "The deterministic ancestry exemption is verified against both live branch tips and the single bound ancestry-evidence file." else echo "Trusted automation PR; Copilot review is delegated to its required guarded-automerge policy." fi + - name: Verify evidence-bound ancestry backmerge + if: steps.trusted-automation.outputs.kind == 'ancestry-backmerge' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + head_commit="$(gh api "repos/${REPOSITORY}/git/commits/${HEAD_SHA}")" + current_develop="$(gh api "repos/${REPOSITORY}/branches/develop" --jq .commit.sha)" + current_main="$(gh api "repos/${REPOSITORY}/branches/main" --jq .commit.sha)" + jq -e \ + --arg develop "${current_develop}" \ + --arg main "${current_main}" \ + '(.parents | length) == 2 + and .parents[0].sha == $develop + and .parents[1].sha == $main' \ + <<<"${head_commit}" >/dev/null + test "${BASE_SHA}" = "${current_develop}" + commit_response="$(gh api "repos/${REPOSITORY}/commits/${HEAD_SHA}")" + jq -e \ + --arg base "${BASE_SHA}" \ + --arg head "${HEAD_SHA}" \ + --arg main "${current_main}" ' + .sha == $head + and (.parents | length) == 2 + and .parents[0].sha == $base + and .parents[1].sha == $main + and (.files | length) == 1 + and .files[0].filename == ".lit/main-ancestry.json" + and (.files[0].status == "added" or .files[0].status == "modified") + and (.files[0].previous_filename == null) + ' <<<"${commit_response}" >/dev/null + evidence_response="$(gh api \ + "repos/${REPOSITORY}/contents/.lit/main-ancestry.json?ref=${HEAD_SHA}")" + jq -e '.type == "file" and .encoding == "base64" and (.size > 0)' \ + <<<"${evidence_response}" >/dev/null + evidence_json="$(jq -r .content <<<"${evidence_response}" | tr -d '\n' | base64 --decode)" + jq -e \ + --arg repository "${REPOSITORY}" \ + --arg main "${current_main}" \ + --arg develop "${current_develop}" ' + (keys | sort) == ["develop_parent_sha", "main_sha", "purpose", "repository", "schema_version"] + and .schema_version == 1 + and .repository == $repository + and .main_sha == $main + and .develop_parent_sha == $develop + and .purpose == "Bind the reviewed main ancestry backmerge." + ' <<<"${evidence_json}" >/dev/null + echo "Verified an exact evidence-bound merge of current develop and main tips." + - name: Verify current Copilot review and resolved findings if: >- steps.trusted-automation.outputs.trusted != 'true' && @@ -558,6 +562,7 @@ jobs: --arg no_files_marker "${NO_FILES_REVIEW_MARKER}" \ --arg quota_exhausted_marker "${QUOTA_EXHAUSTED_MARKER}" \ --arg quota_exceeded_marker "${QUOTA_EXCEEDED_MARKER}" \ + --arg suppressed_comments_marker "${SUPPRESSED_COMMENTS_MARKER}" \ 'def normalize_review_text: ascii_downcase | gsub("wasn[\u0027\u2019]t"; "was not") @@ -569,6 +574,7 @@ jobs: | ($no_files_marker | normalize_review_text) as $no_files | ($quota_exhausted_marker | normalize_review_text) as $quota_exhausted | ($quota_exceeded_marker | normalize_review_text) as $quota_exceeded + | ($suppressed_comments_marker | normalize_review_text) as $suppressed | [ ([ review_content @@ -579,6 +585,7 @@ jobs: or contains($no_files) or contains($quota_exhausted) or contains($quota_exceeded) + or contains($suppressed) ) ) ] | length), @@ -831,105 +838,298 @@ jobs: echo "GitHub Copilot reviewed current head ${head_sha}; no unresolved Copilot findings remain." - - name: Verify base-controlled exact-head Codex review - if: >- - steps.trusted-automation.outputs.trusted != 'true' && - github.event.pull_request.user.login == 'lightning-it-release-automation[bot]' + - name: Publish bound neutral result env: - EXPECTED_BASE: ${{ github.event.pull_request.base.sha }} - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + GH_TOKEN: ${{ github.token }} + EVENT_HEAD: ${{ github.event.pull_request.head.sha }} + EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }} + EVENT_BASE: ${{ github.event.pull_request.base.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} PR_NUMBER: ${{ github.event.pull_request.number }} REPOSITORY: ${{ github.repository }} - GH_TOKEN: ${{ github.token }} + TRUSTED_KIND: ${{ steps.trusted-automation.outputs.kind }} + TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }} + TRUSTED_WORKFLOW_REF: ${{ github.workflow_ref }} run: | set -euo pipefail - expected_run_name="Base-controlled exact-head PR #${PR_NUMBER} ${EXPECTED_BASE}..${EXPECTED_HEAD}" - live_pr_matches() { - local live_pr - live_pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" || return 1 + api_read() { + local attempt output + for attempt in $(seq 1 5); do + if output="$(gh api "$@")"; then + printf '%s' "${output}" + return 0 + fi + if [ "${attempt}" -eq 5 ]; then + echo "GitHub API read failed after five attempts." >&2 + return 1 + fi + sleep 5 + done + } + api_patch() { + local attempt output + for attempt in $(seq 1 5); do + if output="$(gh api --method PATCH "$@")"; then + printf '%s' "${output}" + return 0 + fi + if [ "${attempt}" -eq 5 ]; then + echo "Idempotent GitHub check update failed after five attempts." >&2 + return 1 + fi + sleep 5 + done + } + [[ "${EVENT_BASE}" =~ ^[0-9a-f]{40}$ ]] + [[ "${EVENT_HEAD}" =~ ^[0-9a-f]{40}$ ]] + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] + [[ "${TRUSTED_WORKFLOW_SHA}" =~ ^[0-9a-f]{40}$ ]] + test "${DEFAULT_BRANCH}" = develop + test "${TRUSTED_WORKFLOW_REF}" = \ + "${REPOSITORY}/.github/workflows/copilot-review.yml@refs/heads/${DEFAULT_BRANCH}" + default_head="$(gh api "repos/${REPOSITORY}/branches/${DEFAULT_BRANCH}" --jq .commit.sha)" + controller_ancestry="$(gh api \ + "repos/${REPOSITORY}/compare/${TRUSTED_WORKFLOW_SHA}...${default_head}")" + jq -e \ + --arg controller "${TRUSTED_WORKFLOW_SHA}" ' + .status == "identical" + or (.status == "ahead" and .behind_by == 0 + and .merge_base_commit.sha == $controller) + ' <<<"${controller_ancestry}" >/dev/null + protected_run="$(gh api "repos/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}")" + jq -e \ + --arg branch "${EVENT_HEAD_REF}" \ + --arg repository "${REPOSITORY}" \ + --arg sha "${EVENT_HEAD}" ' + .event == "pull_request_target" + and .name == "Current revision review gate" + and .path == ".github/workflows/copilot-review.yml" + and .head_branch == $branch + and .head_sha == $sha + and .repository.full_name == $repository + and .head_repository.full_name == $repository + ' <<<"${protected_run}" >/dev/null + pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + test "$(jq -r .state <<<"${pr}")" = open + test "$(jq -r .draft <<<"${pr}")" = false + test "$(jq -r .head.sha <<<"${pr}")" = "${EVENT_HEAD}" + test "$(jq -r .base.sha <<<"${pr}")" = "${EVENT_BASE}" + base_ref="$(jq -er '.base.ref | select(. == "develop" or . == "main")' <<<"${pr}")" + test -n "${base_ref}" + test "$(jq -r .base.repo.full_name <<<"${pr}")" = "${REPOSITORY}" + author="$(jq -r .user.login <<<"${pr}")" + review_path="applicable Copilot or governed automation exemption" + external_kind="copilot" + result_title="Current revision review passed" + if { [ "${author}" = 'lightning-it-release-automation[bot]' ] \ + || { [ "${REPOSITORY}" = "lightning-it/.github" ] \ + && [ "${author}" = 'lightning-it-shared-assets-sync[bot]' ]; }; }; then + test "${TRUSTED_KIND}" = ancestry-backmerge + test "${base_ref}" = develop + [[ "${EVENT_HEAD_REF}" == backmerge/*-main ]] + [[ "$(jq -r .title <<<"${pr}")" == \ + "chore(governance): record main ancestry before "* ]] + head_commit="$(gh api "repos/${REPOSITORY}/git/commits/${EVENT_HEAD}")" + current_main="$(gh api "repos/${REPOSITORY}/branches/main" --jq .commit.sha)" + test "${default_head}" = "${EVENT_BASE}" + jq -e \ + --arg develop "${default_head}" \ + --arg main "${current_main}" \ + '(.parents | length) == 2 + and .parents[0].sha == $develop + and .parents[1].sha == $main' \ + <<<"${head_commit}" >/dev/null + commit_response="$(gh api "repos/${REPOSITORY}/commits/${EVENT_HEAD}")" + jq -e \ + --arg base "${EVENT_BASE}" \ + --arg head "${EVENT_HEAD}" \ + --arg main "${current_main}" ' + .sha == $head + and (.parents | length) == 2 + and .parents[0].sha == $base + and .parents[1].sha == $main + and (.files | length) == 1 + and .files[0].filename == ".lit/main-ancestry.json" + and (.files[0].status == "added" or .files[0].status == "modified") + and (.files[0].previous_filename == null) + ' <<<"${commit_response}" >/dev/null + evidence_response="$(gh api \ + "repos/${REPOSITORY}/contents/.lit/main-ancestry.json?ref=${EVENT_HEAD}")" + jq -e '.type == "file" and .encoding == "base64" and (.size > 0)' \ + <<<"${evidence_response}" >/dev/null + evidence_json="$(jq -r .content <<<"${evidence_response}" | tr -d '\n' | base64 --decode)" jq -e \ - --arg author "${PR_AUTHOR}" \ - --arg base_ref "${PR_BASE_REF}" \ - --arg base_sha "${EXPECTED_BASE}" \ - --arg head_sha "${EXPECTED_HEAD}" \ --arg repository "${REPOSITORY}" \ - '(.state == "open") and (.draft == false) - and (.user.login == $author) and (.user.type == "Bot") - and (.base.ref == $base_ref) and (.base.sha == $base_sha) - and (.head.sha == $head_sha) - and (.base.repo.full_name == $repository) - and (.head.repo.full_name == $repository)' \ - <<<"${live_pr}" >/dev/null - } - live_pr_matches - - for attempt in $(seq 1 100); do - response="$( - gh api --method GET \ - "repos/${REPOSITORY}/actions/workflows/release-bot-exact-head-review.yml/runs" \ - -f event=pull_request_target \ - -f per_page=100 - )" - trusted_run="$( + --arg main "${current_main}" \ + --arg develop "${default_head}" ' + (keys | sort) == ["develop_parent_sha", "main_sha", "purpose", "repository", "schema_version"] + and .schema_version == 1 + and .repository == $repository + and .main_sha == $main + and .develop_parent_sha == $develop + and .purpose == "Bind the reviewed main ancestry backmerge." + ' <<<"${evidence_json}" >/dev/null + review_path="deterministic evidence-bound ancestry exemption" + external_kind="ancestry-backmerge" + result_title="Current revision deterministic exemption passed" + else + test "${TRUSTED_KIND}" != ancestry-backmerge + fi + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + evidence="$(jq -cn \ + --arg base "${EVENT_BASE}" \ + --arg controller "${TRUSTED_WORKFLOW_SHA}" \ + --arg head "${EVENT_HEAD}" \ + --arg review_path "${review_path}" \ + --argjson pr_number "${PR_NUMBER}" \ + --argjson run_id "${GITHUB_RUN_ID}" \ + --arg run_url "${run_url}" \ + '{schema:4,base_sha:$base,head_sha:$head,controller_sha:$controller, + pull_request_number:$pr_number,producer_run_id:$run_id, + review_path:$review_path,run_url:$run_url}')" + publish_once() { + local check_name="$1" external_id="$2" title="$3" + local checks named count check_id check_url completed_at created + local recovered recovery_attempt updated + read_named_checks() { + checks="$(api_read --paginate --slurp \ + "repos/${REPOSITORY}/commits/${EVENT_HEAD}/check-runs?check_name=$(jq -rn --arg value "${check_name}" '$value|@uri')&filter=all&per_page=100")" || return 1 jq -c \ - --arg display_title "${expected_run_name}" \ - --arg workflow_name "Base-controlled release bot exact-head review" \ - '[.workflow_runs[]? - | select( - .event == "pull_request_target" - and .name == $workflow_name - and .path == ".github/workflows/release-bot-exact-head-review.yml" - and .display_title == $display_title - )] - | sort_by(.id) - | last // {}' <<<"${response}" - )" - status="$(jq -r '.status // "missing"' <<<"${trusted_run}")" - conclusion="$(jq -r '.conclusion // "pending"' <<<"${trusted_run}")" - run_url="$(jq -r '.html_url // "unavailable"' <<<"${trusted_run}")" - if [ "${status}" = completed ]; then - live_pr_matches - if [ "${conclusion}" = success ]; then - run_id="$(jq -r '.id // ""' <<<"${trusted_run}")" - [[ "${run_id}" =~ ^[0-9]+$ ]] || { - echo "Trusted workflow run returned an invalid run id." >&2 - exit 1 - } - jobs_response="$( - gh api --method GET \ - "repos/${REPOSITORY}/actions/runs/${run_id}/jobs" \ - -f filter=latest \ - -f per_page=100 - )" - review_job="$( - jq -c \ - --arg job_name "Base-controlled exact-head Codex review" \ - '[.jobs[]? | select(.name == $job_name)] - | sort_by(.id) - | last // {}' <<<"${jobs_response}" - )" - if jq -e \ - '(.status == "completed") and (.conclusion == "success")' \ - <<<"${review_job}" >/dev/null; then - echo "Trusted exact-head review job passed for ${EXPECTED_HEAD}: ${run_url}" - exit 0 + --arg name "${check_name}" \ + '[.[].check_runs[]? | + select(.name == $name) | + select(.app.id == 15368 and .app.slug == "github-actions")]' \ + <<<"${checks}" + } + named="$(read_named_checks)" + count="$(jq 'length' <<<"${named}")" + if [ "${count}" -gt 1 ]; then + echo "Multiple protected ${check_name} results exist for ${EVENT_HEAD}." >&2 + exit 1 + fi + if [ "${count}" -eq 1 ]; then + check_id="$(jq -er '.[0].id | select(type == "number" and . > 0)' <<<"${named}")" + check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" + # A new bound producer must refresh the terminal timestamp. GitHub's + # strict status policy can otherwise retain the check as expected. + completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + updated="$(api_patch "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f status=completed \ + -f conclusion=success \ + -f "completed_at=${completed_at}" \ + -f "details_url=${check_url}" \ + -f "external_id=${external_id}" \ + -f "output[title]=${title}" \ + -f "output[summary]=${evidence}")" + jq -e \ + --arg check_name "${check_name}" \ + --arg completed_at "${completed_at}" \ + --arg evidence "${evidence}" \ + --arg external_id "${external_id}" \ + --arg head "${EVENT_HEAD}" \ + --arg url "${check_url}" \ + --argjson check_id "${check_id}" ' + .id == $check_id + and .name == $check_name + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .details_url == $url + and .external_id == $external_id + and .completed_at == $completed_at + and .status == "completed" + and .conclusion == "success" + and .output.summary == $evidence + ' <<<"${updated}" >/dev/null + return + fi + if ! created="$(gh api --method POST "repos/${REPOSITORY}/check-runs" \ + -f name="${check_name}" \ + -f head_sha="${EVENT_HEAD}" \ + -f status=completed \ + -f conclusion=success \ + -f external_id="${external_id}" \ + -f "output[title]=${title}" \ + -f "output[summary]=${evidence}")"; then + # Never retry a create blindly: GitHub can materialize the check + # even when the client receives a non-success response. Recover + # only one exact app/head/external-id result. + created='' + for recovery_attempt in $(seq 1 5); do + echo "Recovering protected check creation outcome (attempt ${recovery_attempt}/5)." >&2 + sleep 5 + named="$(read_named_checks)" + recovered="$(jq -c \ + --arg external_id "${external_id}" \ + --arg head "${EVENT_HEAD}" ' + [.[] | + select(.head_sha == $head) | + select(.external_id == $external_id)] + ' <<<"${named}")" + if [ "$(jq 'length' <<<"${named}")" -gt 1 ] \ + || [ "$(jq 'length' <<<"${recovered}")" -gt 1 ]; then + echo "Ambiguous protected check creation outcome." >&2 + return 1 fi - review_conclusion="$(jq -r '.conclusion // "missing"' <<<"${review_job}")" - echo "Trusted workflow completed without a successful exact-head review job (${review_conclusion}): ${run_url}" >&2 - exit 1 - fi - if [ "${conclusion}" != cancelled ]; then - echo "Trusted base-controlled run concluded ${conclusion}: ${run_url}" >&2 - exit 1 + if [ "$(jq 'length' <<<"${recovered}")" -eq 1 ]; then + created="$(jq -c '.[0]' <<<"${recovered}")" + break + fi + done + if [ -z "${created}" ]; then + echo "Protected check creation failed without a materialized exact result." >&2 + return 1 fi - echo "A matching run was cancelled; waiting for its exact-revision replacement." - fi - if [ "${attempt}" -eq 100 ]; then - echo "No successful trusted workflow run arrived for ${EXPECTED_HEAD}." >&2 - exit 1 fi - echo "Waiting for trusted base-controlled workflow (attempt ${attempt}/100)." - sleep 15 - done + check_id="$(jq -er '.id | select(type == "number" and . > 0)' <<<"${created}")" + check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" + created="$(api_patch "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f "details_url=${check_url}")" + jq -e \ + --arg evidence "${evidence}" \ + --arg external_id "${external_id}" \ + --arg head "${EVENT_HEAD}" \ + --arg url "${check_url}" \ + --argjson check_id "${check_id}" ' + .id == $check_id + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .details_url == $url + and .external_id == $external_id + and .status == "completed" + and .conclusion == "success" + and .output.summary == $evidence + ' <<<"${created}" >/dev/null + } + publish_once \ + 'Current revision review' \ + "mlx90-current-revision:${external_kind}:v6:${PR_NUMBER}:${GITHUB_RUN_ID}:${EVENT_BASE}:${EVENT_HEAD}" \ + "${result_title}" + + request-protected-verifier-reevaluation: + name: Request protected verifier re-evaluation + needs: verify-current-revision-policy + if: needs.verify-current-revision-policy.result == 'success' + permissions: + actions: write + contents: read + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Dispatch the protected re-evaluation helper from develop + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + EXPECTED_BASE: ${{ github.event.pull_request.base.sha }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + gh api --method POST \ + "repos/${REPOSITORY}/actions/workflows/current-revision-rerun.yml/dispatches" \ + -f ref=develop \ + -f "inputs[pr_number]=${PR_NUMBER}" \ + -f "inputs[expected_base]=${EXPECTED_BASE}" \ + -f "inputs[expected_head]=${EXPECTED_HEAD}" >/dev/null diff --git a/.github/workflows/current-revision-rerun.yml b/.github/workflows/current-revision-rerun.yml new file mode 100644 index 0000000..5bb5c53 --- /dev/null +++ b/.github/workflows/current-revision-rerun.yml @@ -0,0 +1,275 @@ +# Protected helper: it can rerun only the single verifier reservation bound to +# the exact live PR head, and only once after a neutral PASS exists. +# yamllint disable rule:truthy rule:line-length +--- +name: Re-evaluate protected current-revision evidence + +on: + workflow_dispatch: + inputs: + pr_number: + description: Pull request whose protected verifier must be re-evaluated + required: true + type: number + expected_base: + description: Frozen pull-request base SHA + required: true + type: string + expected_head: + description: Frozen pull-request head SHA + required: true + type: string + +permissions: + contents: read + +concurrency: + group: >- + protected-current-revision-rerun-${{ inputs.pr_number }}-${{ inputs.expected_head }} + cancel-in-progress: false + +jobs: + rerun-protected-verifier: + name: Re-run the one protected verifier attempt + permissions: + actions: write + checks: read + contents: read + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Validate the live binding and rerun exactly once + env: + EXPECTED_BASE: ${{ inputs.expected_base }} + EXPECTED_HEAD: ${{ inputs.expected_head }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + test "${GITHUB_REF}" = refs/heads/develop + [[ "${EXPECTED_BASE}" =~ ^[0-9a-f]{40}$ ]] + [[ "${EXPECTED_HEAD}" =~ ^[0-9a-f]{40}$ ]] + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] + pr="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + jq -e \ + --arg base "${EXPECTED_BASE}" \ + --arg head "${EXPECTED_HEAD}" \ + --arg repository "${REPOSITORY}" ' + .state == "open" + and .draft == false + and .base.sha == $base + and .head.sha == $head + and .base.repo.full_name == $repository + and .head.repo.full_name == $repository + and (.base.ref == "develop" or .base.ref == "main") + ' <<<"${pr}" >/dev/null + + neutral_pages="$(gh api --paginate --slurp \ + "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=Current%20revision%20review&filter=all&per_page=100")" + neutral="$(jq -c ' + [.[].check_runs[]? | + select(.name == "Current revision review") | + select(.app.id == 15368 and .app.slug == "github-actions") | + select(.status == "completed" and .conclusion == "success")] + ' <<<"${neutral_pages}")" + test "$(jq 'length' <<<"${neutral}")" -eq 1 + neutral_check_id="$(jq -er '.[0].id | select(type == "number" and . > 0)' <<<"${neutral}")" + neutral_details_url="$(jq -r '.[0].details_url // empty' <<<"${neutral}")" + test "${neutral_details_url}" = "${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${neutral_check_id}" + neutral_external_id="$(jq -er '.[0].external_id | select(type == "string" and length > 0)' <<<"${neutral}")" + neutral_summary="$(jq -er '.[0].output.summary | fromjson | select(type == "object")' <<<"${neutral}")" + producer_id="$(jq -er '.producer_run_id | select(type == "number" and . > 0)' <<<"${neutral_summary}")" + producer_url="$(jq -er '.run_url | select(type == "string" and length > 0)' <<<"${neutral_summary}")" + producer_prefix="${GITHUB_SERVER_URL}/${REPOSITORY}/actions/runs/" + test "${producer_url}" = "${producer_prefix}${producer_id}" + author="$(jq -er '.user.login | select(type == "string" and length > 0)' <<<"${pr}")" + base_ref="$(jq -er '.base.ref | select(. == "develop" or . == "main")' <<<"${pr}")" + head_ref="$(jq -er '.head.ref | select(type == "string" and length > 0)' <<<"${pr}")" + evidence_version='' + external_kind='' + if [[ "${neutral_external_id}" =~ ^mlx90-current-revision:v4:${producer_id}:[0-9a-f]{64}$ ]]; then + evidence_version=v4 + elif [[ "${neutral_external_id}" =~ ^mlx90-current-revision:(copilot|ancestry-backmerge):v6:${PR_NUMBER}:${producer_id}:${EXPECTED_BASE}:${EXPECTED_HEAD}$ ]]; then + evidence_version=v6 + external_kind="${BASH_REMATCH[1]}" + elif [[ "${neutral_external_id}" =~ ^mlx90-current-revision:(copilot|ancestry-backmerge):v5:${producer_id}:${EXPECTED_BASE}:${EXPECTED_HEAD}$ ]]; then + evidence_version=v5 + external_kind="${BASH_REMATCH[1]}" + else + echo "Neutral evidence does not match a supported protected producer binding." >&2 + exit 1 + fi + jq -e \ + --arg base "${EXPECTED_BASE}" \ + --arg head "${EXPECTED_HEAD}" \ + --arg run_url "${producer_url}" \ + --argjson pr_number "${PR_NUMBER}" \ + --argjson run_id "${producer_id}" ' + .schema == 4 + and .base_sha == $base + and .head_sha == $head + and .producer_run_id == $run_id + and .run_url == $run_url + and .pull_request_number == $pr_number + ' <<<"${neutral_summary}" >/dev/null + for attempt in $(seq 1 40); do + producer="$(gh api "repos/${REPOSITORY}/actions/runs/${producer_id}")" + if [ "$(jq -r .status <<<"${producer}")" = completed ]; then + test "$(jq -r .conclusion <<<"${producer}")" = success + break + fi + test "${attempt}" -lt 40 + sleep 5 + done + if [ "${evidence_version}" = v4 ]; then + test "${author}" = 'lightning-it-release-automation[bot]' + expected_title="Exact-Revision Codex PR #${PR_NUMBER} ${EXPECTED_BASE}..${EXPECTED_HEAD}" + jq -e \ + --arg actor "${author}" \ + --arg base_ref "${base_ref}" \ + --arg base_sha "${EXPECTED_BASE}" \ + --arg run_url "${producer_url}" \ + --arg title "${expected_title}" ' + .event == "workflow_dispatch" + and .path == ".github/workflows/release-bot-exact-head-review.yml" + and .display_title == $title + and .head_branch == $base_ref + and .head_sha == $base_sha + and .html_url == $run_url + and .actor.login == $actor + and .triggering_actor.login == $actor + ' <<<"${producer}" >/dev/null + else + if [ "${external_kind}" = ancestry-backmerge ]; then + test "${author}" = 'lightning-it-release-automation[bot]' + test "$(jq -r .review_path <<<"${neutral_summary}")" = \ + 'deterministic evidence-bound ancestry exemption' + else + test "${author}" != 'lightning-it-release-automation[bot]' + test "$(jq -r .review_path <<<"${neutral_summary}")" = \ + 'applicable Copilot or governed automation exemption' + fi + controller_sha="$(jq -er '.controller_sha | select(type == "string" and test("^[0-9a-f]{40}$"))' \ + <<<"${neutral_summary}")" + default_branch="$(gh api "repos/${REPOSITORY}" --jq .default_branch)" + test "${default_branch}" = develop + default_head="$(gh api "repos/${REPOSITORY}/branches/${default_branch}" --jq .commit.sha)" + controller_ancestry="$(gh api \ + "repos/${REPOSITORY}/compare/${controller_sha}...${default_head}")" + jq -e \ + --arg controller "${controller_sha}" ' + .status == "identical" + or (.status == "ahead" and .behind_by == 0 + and .merge_base_commit.sha == $controller) + ' <<<"${controller_ancestry}" >/dev/null + jq -e \ + --arg actor "${author}" \ + --arg head_ref "${head_ref}" \ + --arg head_sha "${EXPECTED_HEAD}" \ + --arg run_url "${producer_url}" ' + .event == "pull_request_target" + and .path == ".github/workflows/copilot-review.yml" + and .name == "Current revision review gate" + and .head_branch == $head_ref + and .head_sha == $head_sha + and .html_url == $run_url + and .actor.login == $actor + and .triggering_actor.login == $actor + ' <<<"${producer}" >/dev/null + fi + + reservations_pages="$(gh api --paginate --slurp \ + "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=Protected%20current-revision%20verifier&filter=all&per_page=100")" + reservations="$(jq -c \ + --arg head "${EXPECTED_HEAD}" \ + --arg prefix "rep60-required-workflow:v2:" \ + --arg suffix ":${PR_NUMBER}:${EXPECTED_HEAD}" ' + [.[].check_runs[]? | + select(.name == "Protected current-revision verifier") | + select(.app.id == 15368 and .app.slug == "github-actions") | + select(.head_sha == $head) | + select((.external_id | type) == "string") | + select(.external_id | startswith($prefix) and endswith($suffix))] + ' <<<"${reservations_pages}")" + test "$(jq 'length' <<<"${reservations}")" -eq 1 + reservation_id="$(jq -er '.[0].id | select(type == "number" and . > 0)' <<<"${reservations}")" + reservation_url="$(jq -r '.[0].details_url // empty' <<<"${reservations}")" + test "${reservation_url}" = "${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${reservation_id}" + reservation_external_id="$(jq -er '.[0].external_id | select(type == "string" and length > 0)' <<<"${reservations}")" + [[ "${reservation_external_id}" =~ ^rep60-required-workflow:v2:([1-9][0-9]*):${PR_NUMBER}:${EXPECTED_HEAD}$ ]] + run_id="${BASH_REMATCH[1]}" + verifier_run_url="${GITHUB_SERVER_URL}/${REPOSITORY}/actions/runs/${run_id}" + run="$(gh api "repos/${REPOSITORY}/actions/runs/${run_id}")" + jq -e \ + --arg api_url "${GITHUB_API_URL}" \ + --arg base_ref "${base_ref}" \ + --arg base_sha "${EXPECTED_BASE}" \ + --arg head_ref "${head_ref}" \ + --arg head_sha "${EXPECTED_HEAD}" \ + --arg repository "${REPOSITORY}" \ + --arg run_url "${verifier_run_url}" \ + --argjson pr_number "${PR_NUMBER}" ' + .event == "pull_request_target" + and .path == ".github/workflows/supplementary-current-revision-required.yml" + and (.workflow_id | type == "number" and . > 0) + and .workflow_url == ($api_url + "/repos/" + $repository + + "/actions/required_workflows/" + (.workflow_id | tostring)) + and .head_branch == $head_ref + and .head_sha == $head_sha + and .html_url == $run_url + and (.actor.login | type == "string" and length > 0) + and (.triggering_actor.login | type == "string" and length > 0) + and (.pull_requests | length) == 1 + and .pull_requests[0].number == $pr_number + and .pull_requests[0].url == ($api_url + "/repos/" + $repository + + "/pulls/" + ($pr_number | tostring)) + and .pull_requests[0].base.ref == $base_ref + and .pull_requests[0].base.sha == $base_sha + and .pull_requests[0].base.repo.url == ($api_url + "/repos/" + $repository) + and .pull_requests[0].head.ref == $head_ref + and .pull_requests[0].head.sha == $head_sha + and .pull_requests[0].head.repo.url == ($api_url + "/repos/" + $repository) + and .status == "completed" + ' <<<"${run}" >/dev/null + if [ "$(jq -r .conclusion <<<"${run}")" = success ]; then + echo "Protected verifier already passed; no rerun is needed." + exit 0 + fi + test "$(jq -r .conclusion <<<"${run}")" = failure + run_attempt="$(jq -er '.run_attempt | select(type == "number" and . >= 1)' <<<"${run}")" + if [ "${run_attempt}" -ne 1 ]; then + echo "Protected verifier already consumed its single rerun; no further retry is allowed." + exit 0 + fi + if ! gh api --method POST \ + "repos/${REPOSITORY}/actions/runs/${run_id}/rerun" >/dev/null; then + # GitHub can accept the rerun and still close the client request + # with a non-success response when another service-side request + # wins the same transition. Accept that outcome only after the + # live run proves that attempt two was actually materialized. + run="$(gh api "repos/${REPOSITORY}/actions/runs/${run_id}")" + observed_attempt="$(jq -er '.run_attempt | select(type == "number" and . >= 1)' <<<"${run}")" + if [ "${observed_attempt}" -lt 2 ]; then + echo "Rerun request failed without a materialized second attempt." >&2 + exit 1 + fi + echo "A concurrent service-side request already materialized the protected rerun." + fi + for completion_attempt in $(seq 1 60); do + run="$(gh api "repos/${REPOSITORY}/actions/runs/${run_id}")" + observed_attempt="$(jq -er '.run_attempt | select(type == "number" and . >= 1)' <<<"${run}")" + status="$(jq -er '.status | select(type == "string" and length > 0)' <<<"${run}")" + if [ "${observed_attempt}" -ge 2 ] && [ "${status}" = completed ]; then + test "$(jq -r .conclusion <<<"${run}")" = success + echo "Protected verifier rerun completed successfully." + exit 0 + fi + if [ "${completion_attempt}" -eq 60 ]; then + echo "Protected verifier rerun did not complete successfully in time." >&2 + exit 1 + fi + sleep 5 + done diff --git a/.github/workflows/release-bot-exact-head-review.yml b/.github/workflows/release-bot-exact-head-review.yml index ebf9844..82c547b 100644 --- a/.github/workflows/release-bot-exact-head-review.yml +++ b/.github/workflows/release-bot-exact-head-review.yml @@ -1,6 +1,6 @@ # Managed by lightning-it/shared-assets-lit. # Do not edit downstream copies directly. -# Canonical protected MLX-90 §7.2 Exact-Revision Codex controller. +# Protected per-repository MLX-90 §7.2 Exact-Revision Codex controller. # yamllint disable rule:truthy rule:line-length --- name: Protected Exact-Revision Codex review @@ -42,7 +42,7 @@ concurrency: # the full binary diff, protected prompt, and protected schema. jobs: exact-revision-codex-review: - name: Protected Exact-Revision Codex review + name: Current revision review if: >- github.event_name == 'workflow_dispatch' && github.actor == 'lightning-it-release-automation[bot]' @@ -70,6 +70,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] [[ "${TRUSTED_WORKFLOW_SHA}" =~ ^[0-9a-f]{40}$ ]] test "${TRUSTED_WORKFLOW_SHA}" = "${EXPECTED_BASE}" install -d -m 0700 trusted-controller @@ -129,7 +130,7 @@ jobs: external_prefix="mlx90-exact-revision:v4:${input_sha256}:" external_id="${external_prefix}${GITHUB_RUN_ID}" checks="$(gh api --paginate --slurp \ - "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=Protected%20Exact-Revision%20Codex%20result&per_page=100")" + "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=Protected%20Exact-Revision%20Codex%20result&filter=all&per_page=100")" matching="$(jq -c \ --arg external_prefix "${external_prefix}" \ '[.[].check_runs[]? | @@ -233,6 +234,13 @@ jobs: -f 'output[title]=Protected Exact-Revision Codex review in progress' \ -f "output[summary]=Immutable input SHA-256: ${input_sha256}.")" check_id="$(jq -er '.id | select(type == "number" and . > 0)' <<<"${reservation}")" + { + echo "reuse=false" + echo "check_id=${check_id}" + echo "input_sha256=${input_sha256}" + echo "external_id=${external_id}" + echo "producer_run_id=${GITHUB_RUN_ID}" + } >>"${GITHUB_OUTPUT}" check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" reservation="$(gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ -f "details_url=${check_url}")" @@ -249,13 +257,6 @@ jobs: and .app.id == 15368 and .app.slug == "github-actions" ' <<<"${reservation}" >/dev/null - { - echo "reuse=false" - echo "check_id=${check_id}" - echo "input_sha256=${input_sha256}" - echo "external_id=${external_id}" - echo "producer_run_id=${GITHUB_RUN_ID}" - } >>"${GITHUB_OUTPUT}" - name: Run protected history-free Exact-Revision Codex review if: steps.dedupe.outputs.reuse != 'true' @@ -277,6 +278,34 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + api_read() { + local attempt output + for attempt in $(seq 1 5); do + if output="$(gh api "$@")"; then + printf '%s' "${output}" + return 0 + fi + if [ "${attempt}" -eq 5 ]; then + echo "GitHub API read failed after five attempts." >&2 + return 1 + fi + sleep 5 + done + } + api_patch() { + local attempt output + for attempt in $(seq 1 5); do + if output="$(gh api --method PATCH "$@")"; then + printf '%s' "${output}" + return 0 + fi + if [ "${attempt}" -eq 5 ]; then + echo "Idempotent GitHub check update failed after five attempts." >&2 + return 1 + fi + sleep 5 + done + } python3 trusted-controller/materialize.py verify \ --repository "${REPOSITORY}" \ --pull-request "${PR_NUMBER}" \ @@ -319,6 +348,7 @@ jobs: input_sha256="$(jq -r .input_sha256 "${metadata}")" review_bytes="$(jq -r .review_bytes "${metadata}")" producer_run_id="${{ steps.dedupe.outputs.producer_run_id }}" + [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] [[ "${producer_run_id}" =~ ^[1-9][0-9]*$ ]] producer_run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${producer_run_id}" evidence="$(jq -cn \ @@ -329,12 +359,14 @@ jobs: --arg diff_sha256 "${diff_sha256}" \ --arg input_sha256 "${input_sha256}" \ --arg workflow_sha "${TRUSTED_WORKFLOW_SHA}" \ + --argjson pr_number "${PR_NUMBER}" \ --argjson run_id "${producer_run_id}" \ --arg run_url "${producer_run_url}" \ '{schema:4,base_sha:$base,head_sha:$head,merge_base_sha:$merge_base, integration_tree_sha:$integration_tree,diff_sha256:$diff_sha256, input_sha256:$input_sha256,workflow_sha:$workflow_sha, - producer_run_id:$run_id,run_url:$run_url}')" + pull_request_number:$pr_number,producer_run_id:$run_id, + run_url:$run_url}')" { echo "### Protected Exact-Revision Codex review" echo @@ -356,96 +388,124 @@ jobs: conclusion=success title='Protected Exact-Revision Codex review passed' fi - gh api --method PATCH "repos/${REPOSITORY}/check-runs/${{ steps.dedupe.outputs.check_id }}" \ + finalized="$(api_patch "repos/${REPOSITORY}/check-runs/${{ steps.dedupe.outputs.check_id }}" \ -f status=completed \ -f conclusion="${conclusion}" \ -f "output[title]=${title}" \ - -f "output[summary]=${evidence}" >/dev/null + -f "output[summary]=${evidence}")" + jq -e \ + --arg conclusion "${conclusion}" \ + --arg evidence "${evidence}" \ + --arg head "${EXPECTED_HEAD}" \ + --argjson check_id "${{ steps.dedupe.outputs.check_id }}" ' + .id == $check_id + and .name == "Protected Exact-Revision Codex result" + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .status == "completed" + and .conclusion == $conclusion + and .output.summary == $evidence + ' <<<"${finalized}" >/dev/null test "${conclusion}" = success fi publish_once() { local check_name="$1" external_id="$2" title="$3" - local checks named count check_id check_url created current_external_id updated - checks="$(gh api --paginate --slurp \ - "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=$(jq -rn --arg value "${check_name}" '$value|@uri')&per_page=100")" - named="$(jq -c \ - --arg name "${check_name}" \ - '[.[].check_runs[]? | - select(.name == $name)]' \ - <<<"${checks}")" + local checks named count check_id check_url completed_at created + local recovered recovery_attempt updated + read_named_checks() { + checks="$(api_read --paginate --slurp \ + "repos/${REPOSITORY}/commits/${EXPECTED_HEAD}/check-runs?check_name=$(jq -rn --arg value "${check_name}" '$value|@uri')&filter=all&per_page=100")" || return 1 + jq -c \ + --arg name "${check_name}" \ + '[.[].check_runs[]? | + select(.name == $name) | + select(.app.id == 15368 and .app.slug == "github-actions")]' \ + <<<"${checks}" + } + named="$(read_named_checks)" count="$(jq 'length' <<<"${named}")" if [ "${count}" -gt 1 ]; then echo "Multiple ${check_name} checks exist for this head." >&2 exit 1 fi if [ "${count}" -eq 1 ]; then - if ! jq -e '.[0].app.id == 15368 and .[0].app.slug == "github-actions"' \ - <<<"${named}" >/dev/null; then - echo "${check_name} exists under an unauthorized GitHub App." >&2 - exit 1 - fi check_id="$(jq -er '.[0].id | select(type == "number" and . > 0)' <<<"${named}")" check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" - current_external_id="$(jq -r '.[0].external_id // empty' <<<"${named}")" - if [ "${current_external_id}" != "${external_id}" ]; then - updated="$(gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ - -f status=completed \ - -f conclusion=success \ - -f "details_url=${check_url}" \ - -f "external_id=${external_id}" \ - -f "output[title]=${title}" \ - -f "output[summary]=${evidence}")" - jq -e \ - --arg check_name "${check_name}" \ - --arg evidence "${evidence}" \ - --arg external_id "${external_id}" \ - --arg head "${EXPECTED_HEAD}" \ - --arg url "${check_url}" \ - --argjson check_id "${check_id}" ' - .id == $check_id - and .name == $check_name - and .app.id == 15368 - and .app.slug == "github-actions" - and .head_sha == $head - and .details_url == $url - and .external_id == $external_id - and .status == "completed" - and .conclusion == "success" - and .output.summary == $evidence - ' <<<"${updated}" >/dev/null - return - fi + # A new bound producer must refresh the terminal timestamp. GitHub's + # strict status policy can otherwise retain the check as expected. + completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + updated="$(api_patch "repos/${REPOSITORY}/check-runs/${check_id}" \ + -f status=completed \ + -f conclusion=success \ + -f "completed_at=${completed_at}" \ + -f "details_url=${check_url}" \ + -f "external_id=${external_id}" \ + -f "output[title]=${title}" \ + -f "output[summary]=${evidence}")" jq -e \ --arg check_name "${check_name}" \ + --arg completed_at "${completed_at}" \ --arg evidence "${evidence}" \ --arg external_id "${external_id}" \ --arg head "${EXPECTED_HEAD}" \ --arg url "${check_url}" \ --argjson check_id "${check_id}" ' - .[0].id == $check_id - and .[0].name == $check_name - and .[0].app.id == 15368 - and .[0].app.slug == "github-actions" - and .[0].head_sha == $head - and .[0].details_url == $url - and .[0].external_id == $external_id - and .[0].status == "completed" - and .[0].conclusion == "success" - and .[0].output.summary == $evidence - ' <<<"${named}" >/dev/null + .id == $check_id + and .name == $check_name + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .details_url == $url + and .external_id == $external_id + and .completed_at == $completed_at + and .status == "completed" + and .conclusion == "success" + and .output.summary == $evidence + ' <<<"${updated}" >/dev/null return fi - created="$(gh api --method POST "repos/${REPOSITORY}/check-runs" \ - -f name="${check_name}" \ - -f head_sha="${EXPECTED_HEAD}" \ - -f status=completed \ - -f conclusion=success \ - -f external_id="${external_id}" \ - -f "output[title]=${title}" \ - -f "output[summary]=${evidence}")" + if ! created="$(gh api --method POST "repos/${REPOSITORY}/check-runs" \ + -f name="${check_name}" \ + -f head_sha="${EXPECTED_HEAD}" \ + -f status=completed \ + -f conclusion=success \ + -f external_id="${external_id}" \ + -f "output[title]=${title}" \ + -f "output[summary]=${evidence}")"; then + # Never retry a create blindly: GitHub can materialize the check + # even when the client receives a non-success response. Recover + # only one exact app/head/external-id result. + created='' + for recovery_attempt in $(seq 1 5); do + echo "Recovering protected check creation outcome (attempt ${recovery_attempt}/5)." >&2 + sleep 5 + named="$(read_named_checks)" + recovered="$(jq -c \ + --arg external_id "${external_id}" \ + --arg head "${EXPECTED_HEAD}" ' + [.[] | + select(.head_sha == $head) | + select(.external_id == $external_id)] + ' <<<"${named}")" + if [ "$(jq 'length' <<<"${named}")" -gt 1 ] \ + || [ "$(jq 'length' <<<"${recovered}")" -gt 1 ]; then + echo "Ambiguous protected check creation outcome." >&2 + return 1 + fi + if [ "$(jq 'length' <<<"${recovered}")" -eq 1 ]; then + created="$(jq -c '.[0]' <<<"${recovered}")" + break + fi + done + if [ -z "${created}" ]; then + echo "Protected check creation failed without a materialized exact result." >&2 + return 1 + fi + fi check_id="$(jq -er '.id | select(type == "number" and . > 0)' <<<"${created}")" check_url="${GITHUB_SERVER_URL}/${REPOSITORY}/runs/${check_id}" - created="$(gh api --method PATCH "repos/${REPOSITORY}/check-runs/${check_id}" \ + created="$(api_patch "repos/${REPOSITORY}/check-runs/${check_id}" \ -f "details_url=${check_url}")" jq -e \ --arg check_name "${check_name}" \ @@ -470,18 +530,57 @@ jobs: 'Current revision review' \ "mlx90-current-revision:v4:${producer_run_id}:${input_sha256}" \ 'Protected Exact-Revision Codex review passed' - # One-time compatibility alias for protected Shared Assets promotion - # PR #1047. It is derived from this same Codex PASS and never starts a - # second reviewer. The develop version replaces this bootstrap file - # during the promotion, removing the legacy Copilot-named context. - if [ "${REPOSITORY}" = lightning-it/shared-assets-lit ] \ - && [ "${PR_NUMBER}" = 1047 ] \ - && [ "${BASE_REF}" = main ]; then - publish_once \ - 'Successful Copilot review' \ - "mlx90-legacy-transition:v4:${producer_run_id}:${input_sha256}" \ - 'Exact-Revision Codex PASS (temporary legacy context; no Copilot)' + + - name: Fail-close an unfinished protected reservation + if: >- + always() && + steps.dedupe.outputs.reuse != 'true' && + steps.dedupe.outputs.check_id != '' + env: + CHECK_ID: ${{ steps.dedupe.outputs.check_id }} + EXPECTED_EXTERNAL_ID: ${{ steps.dedupe.outputs.external_id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + [[ "${CHECK_ID}" =~ ^[1-9][0-9]*$ ]] + reservation="$(gh api "repos/${REPOSITORY}/check-runs/${CHECK_ID}")" + jq -e \ + --arg external_id "${EXPECTED_EXTERNAL_ID}" \ + --arg head "${EXPECTED_HEAD}" \ + --argjson check_id "${CHECK_ID}" ' + .id == $check_id + and .name == "Protected Exact-Revision Codex result" + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .external_id == $external_id + and (.status == "in_progress" or .status == "completed") + ' <<<"${reservation}" >/dev/null + if [ "$(jq -r .status <<<"${reservation}")" = completed ]; then + jq -e '.conclusion == "success" or .conclusion == "failure"' \ + <<<"${reservation}" >/dev/null + exit 0 fi + failure_evidence="The protected producer run ${GITHUB_SERVER_URL}/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID} ended before finalizing this immutable reservation." + reservation="$(gh api --method PATCH \ + "repos/${REPOSITORY}/check-runs/${CHECK_ID}" \ + -f status=completed \ + -f conclusion=failure \ + -f 'output[title]=Protected Exact-Revision Codex review failed closed' \ + -f "output[summary]=${failure_evidence}")" + jq -e \ + --arg external_id "${EXPECTED_EXTERNAL_ID}" \ + --arg head "${EXPECTED_HEAD}" \ + --argjson check_id "${CHECK_ID}" ' + .id == $check_id + and .name == "Protected Exact-Revision Codex result" + and .app.id == 15368 + and .app.slug == "github-actions" + and .head_sha == $head + and .external_id == $external_id + and .status == "completed" + and .conclusion == "failure" + ' <<<"${reservation}" >/dev/null request-protected-verifier-reevaluation: name: Request protected verifier re-evaluation diff --git a/.github/workflows/renovate-guarded-automerge.yml b/.github/workflows/renovate-guarded-automerge.yml index 001c994..1b2a054 100644 --- a/.github/workflows/renovate-guarded-automerge.yml +++ b/.github/workflows/renovate-guarded-automerge.yml @@ -242,16 +242,27 @@ jobs: owner="${REPO%%/*}" name="${REPO#*/}" - auto_merge_enabled="$( + query_auto_merge() { gh api graphql \ -f query="query(\$owner:String!,\$name:String!,\$number:Int!){repository(owner:\$owner,name:\$name){pullRequest(number:\$number){autoMergeRequest{__typename}}}}" \ -f owner="$owner" \ -f name="$name" \ -F number="$PR_NUMBER" \ --jq '.data.repository.pullRequest.autoMergeRequest != null' - )" + } + auto_merge_enabled="$(query_auto_merge)" case "$auto_merge_enabled" in - true) gh pr merge "$PR_URL" --disable-auto ;; + true) + if ! disable_error="$( + gh pr merge "$PR_URL" --disable-auto 2>&1 + )"; then + auto_merge_enabled="$(query_auto_merge)" + if [ "$auto_merge_enabled" != false ]; then + printf '%s\n' "$disable_error" >&2 + exit 1 + fi + fi + ;; false) ;; *) echo "ERROR: unable to determine auto-merge state for ${PR_URL}." >&2 @@ -338,19 +349,36 @@ jobs: } disable_auto_merge() { - local auto_merge_enabled name owner + local auto_merge_enabled disable_error name owner owner="${REPO%%/*}" name="${REPO#*/}" - auto_merge_enabled="$( + query_auto_merge() { gh api graphql \ -f query="query(\$owner:String!,\$name:String!,\$number:Int!){repository(owner:\$owner,name:\$name){pullRequest(number:\$number){autoMergeRequest{__typename}}}}" \ -f owner="$owner" \ -f name="$name" \ -F number="$PR_NUMBER" \ --jq '.data.repository.pullRequest.autoMergeRequest != null' - )" - [ "$auto_merge_enabled" = false ] \ - || gh pr merge "$PR_URL" --disable-auto + } + auto_merge_enabled="$(query_auto_merge)" + case "$auto_merge_enabled" in + true) + if ! disable_error="$( + gh pr merge "$PR_URL" --disable-auto 2>&1 + )"; then + auto_merge_enabled="$(query_auto_merge)" + if [ "$auto_merge_enabled" != false ]; then + printf '%s\n' "$disable_error" >&2 + return 1 + fi + fi + ;; + false) ;; + *) + echo "ERROR: unable to determine auto-merge state for ${PR_URL}." >&2 + return 1 + ;; + esac } if ! assert_live_safe; then @@ -361,5 +389,5 @@ jobs: # governance policy deliberately prohibits Actions from submitting # reviews, so a synthetic self-approval would only make this path # fail. Auto-merge still waits for every required current-head check. - gh pr merge "$PR_URL" --auto --merge --delete-branch \ - --match-head-commit "$PR_HEAD_SHA" + gh pr merge "${PR_URL}" --auto --merge --delete-branch \ + --match-head-commit "${PR_HEAD_SHA}" diff --git a/.github/workflows/shared-assets-guarded-automerge.yml b/.github/workflows/shared-assets-guarded-automerge.yml index dba6b83..195d8ba 100644 --- a/.github/workflows/shared-assets-guarded-automerge.yml +++ b/.github/workflows/shared-assets-guarded-automerge.yml @@ -30,6 +30,16 @@ jobs: github.event.pull_request.user.login == 'lightning-it-shared-assets-sync[bot]' && github.event.pull_request.head.repo.full_name == github.repository + && !( + github.repository == 'lightning-it/.github' + && github.event.pull_request.base.ref == 'develop' + && startsWith(github.event.pull_request.head.ref, 'backmerge/') + && endsWith(github.event.pull_request.head.ref, '-main') + && startsWith( + github.event.pull_request.title, + 'chore(governance): record main ancestry before ' + ) + ) runs-on: ubuntu-latest timeout-minutes: 35 permissions: diff --git a/.lit/push-ready.json b/.lit/push-ready.json index 32ea3ba..e7138c3 100644 --- a/.lit/push-ready.json +++ b/.lit/push-ready.json @@ -18,8 +18,8 @@ { "id": "copilot-current-head-review", "workflow": ".github/workflows/copilot-review.yml", - "job": "current-revision-reviewed", - "reason": "The authoritative Copilot pull-request review is produced and bound to the current head SHA by GitHub.", + "job": "verify-current-revision-policy", + "reason": "The authoritative pipeline-only current-revision policy result is bound to the exact head SHA and may represent contributor-funded Copilot, a governed automation exemption, or the protected MLX-90 exact-revision Codex path.", "owner": "Lightning IT Application Platform Maintainers" } ], @@ -31,14 +31,14 @@ }, "agents": { "copilot": { - "enabled": true, - "required": true, + "enabled": false, + "required": false, "command": ["copilot"], "timeout_seconds": 600 }, "codex": { - "enabled": true, - "required": true, + "enabled": false, + "required": false, "command": ["codex"], "timeout_seconds": 900 } diff --git a/AGENTS.md b/AGENTS.md index 310fb8b..b582f14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,6 +228,36 @@ generic imagery, and browser-dependent instructions. Never weaken a quality gate to admit known-bad content. Fix the content or record a narrowly scoped, reviewed exception outside this public repository. + + +## REP-60 current-revision review governance + +- Local validation is deterministic only. It must never invoke Codex, GitHub + Copilot, another model, or an external AI endpoint. Authoritative AI review + runs only in the protected GitHub pipeline and binds the exact PR head. +- Lightning IT automation may request and fund one GitHub Copilot review only + when the exact PR author is `litroc`, and only at the finalization boundary; + intermediate `synchronize` pushes must not trigger AI review. Any finding + requires correction and a final current-head re-review. The request is + consumed once per head; unavailable or quota-blocked reviews fail closed + without an automatic retry. Organization-funded Codex remediation and its + single re-review are likewise restricted to `litroc`. +- Every other human or external contributor supplies any required current-head + Copilot review under their own entitlement and cost. Lightning IT verifies + valid evidence but never requests or funds that review, and personal tokens or + provider keys never enter Actions. +- A same-repository PR authored exactly by + `lightning-it-release-automation[bot]` uses only the protected MLX-90 §7.2 + Exact-Revision Codex check. It must never request Copilot or synthesize a + Copilot success. +- A proven ancestry-only main-to-develop backmerge uses the deterministic + evidence-bound exemption and performs zero AI calls. Unknown automation + identities fail closed. +- The only neutral merge-gate result is `Current revision review`. Missing, + stale, ambiguous, or unresolved review evidence blocks the merge. + + + ## AI model and token governance diff --git a/scripts/lit-push-ready.py b/scripts/lit-push-ready.py index 9f626ed..f19d0db 100755 --- a/scripts/lit-push-ready.py +++ b/scripts/lit-push-ready.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Create exact-diff local pipeline and dual-agent review evidence.""" +"""Create deterministic local pipeline evidence without local AI egress.""" from __future__ import annotations @@ -135,9 +135,9 @@ PARITY_GAPS = ( { "id": "copilot-review-surface", - "local": "GitHub Copilot CLI read-only exact-diff review", - "remote": "GitHub Copilot pull-request code review on the current head SHA", - "status": "not-identical-by-product-design", + "local": "prohibited; deterministic checks only", + "remote": "protected current-revision review on the exact head SHA", + "status": "remote-only-by-policy", "remote_gate_required": True, }, { @@ -746,9 +746,10 @@ def validate_agent_config(name: str, value: Any) -> None: raise RuntimeError( f"agents.{name}.enabled and agents.{name}.required must be booleans" ) - if enabled is not True or required is not True: + if enabled is not False or required is not False: raise RuntimeError( - f"agents.{name} must be enabled and required by the v2 policy" + f"agents.{name} must remain disabled and not required by the " + "local no-AI-egress policy" ) command = validate_command(value.get("command"), f"agents.{name}.command") if command != [name]: @@ -2656,7 +2657,7 @@ def ensure_workspace_review_safe( workspace: Path, documented: Optional[dict[str, dict[int, tuple[str, str]]]] = None, ) -> None: - """Scan the complete tracked review snapshot before external model use.""" + """Scan the complete tracked snapshot before local evidence is accepted.""" names = git_output_at(workspace, "ls-files", "-z").split("\0") total = 0 unsafe_paths: list[str] = [] @@ -3521,65 +3522,21 @@ def run_agent_reviews( expected = change.tree_fingerprint if tree_fingerprint() != expected: raise RuntimeError("exact planned push patch is stale before local review") - reviews: list[dict[str, Any]] = [] + if any( + agent["enabled"] or agent["required"] + for agent in config["agents"].values() + ): + raise RuntimeError("local AI execution is prohibited by policy") + # Materialize the exact-patch snapshot so the deterministic secret and + # topology guards still fail closed, without invoking any local reviewer. with sanitized_review_workspace( change, fixture_manifest_bootstrap=fixture_manifest_bootstrap, - ) as ( - workspace, - state_root, - topology, ): - instructions = tracked_instruction_bundle(workspace) - workspace_fingerprint = integration_worktree_fingerprint( - workspace, - include_ignored=True, - ) - reviews.append( - copilot_review( - config, - change, - expected, - workspace=workspace, - state_root=state_root, - instructions=instructions, - topology=topology, - ) - ) - if ( - integration_worktree_fingerprint( - workspace, - include_ignored=True, - ) - != workspace_fingerprint - ): - raise RuntimeError( - "Copilot review changed the sanitized exact-patch workspace" - ) - reviews.append( - codex_review( - config, - change, - expected, - workspace=workspace, - state_root=state_root, - instructions=instructions, - topology=topology, - ) - ) - if ( - integration_worktree_fingerprint( - workspace, - include_ignored=True, - ) - != workspace_fingerprint - ): - raise RuntimeError( - "Codex review changed the sanitized exact-patch workspace" - ) + pass if tree_fingerprint() != expected: - raise RuntimeError("local agent review changed the reviewed Git tree") - return reviews + raise RuntimeError("local deterministic review changed the Git tree") + return [] def command_version(command: list[str]) -> str: @@ -3627,7 +3584,9 @@ def governed_push_remote_from_url( if value.startswith(prefix): repository_name = value[len(prefix) :] break - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}", repository_name): + if repository_name != ".github" and not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,99}", repository_name + ): raise RuntimeError( "origin push URL must target a Lightning IT repository on github.com" ) @@ -3698,6 +3657,7 @@ def write_evidence( "push_scope": "clean-head", "fixture_manifest_bootstrap": fixture_manifest_bootstrap, "evidence_trust": LOCAL_EVIDENCE_TRUST, + "local_ai_egress": "prohibited", } evidence.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", @@ -3781,6 +3741,7 @@ def verify_evidence(config: dict[str, Any]) -> dict[str, Any]: "push_scope": "clean-head", "fixture_manifest_bootstrap": fixture_manifest_bootstrap, "evidence_trust": LOCAL_EVIDENCE_TRUST, + "local_ai_egress": "prohibited", } for key, value in expected.items(): if payload.get(key) != value: @@ -4068,6 +4029,7 @@ def main() -> int: change, fixture_manifest_bootstrap=args.fixture_manifest_bootstrap, ) + print("Deterministic local review passed; no local AI was invoked.") return 0 require_clean_head() original_head = git_output("rev-parse", "HEAD").strip() diff --git a/scripts/materialize-exact-revision-review.py b/scripts/materialize-exact-revision-review.py new file mode 100644 index 0000000..cdc3f99 --- /dev/null +++ b/scripts/materialize-exact-revision-review.py @@ -0,0 +1,702 @@ +"""Materialize and re-verify the bounded MLX-90 exact-revision review input.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import secrets +import shutil +import stat +import subprocess +import sys +import tempfile +from collections.abc import Sequence +from pathlib import Path +from typing import Any, NoReturn + +SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$") +REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +RELEASE_BOT = "lightning-it-release-automation[bot]" +MAX_REVIEW_BYTES = 200_000 +MAX_PROTECTED_ASSET_BYTES = 1_000_000 +COMMAND_TIMEOUT_SECONDS = 120 +ASSET_ARGUMENTS = { + "materializer_sha256": "materializer_path", + "prompt_sha256": "prompt_path", + "schema_sha256": "schema_path", + "workflow_sha256": "workflow_path", +} +IMMUTABLE_METADATA_KEYS = ( + "schema_version", + "repository", + "pull_request", + "base_ref", + "base_sha", + "head_sha", + "merge_base_sha", + "integration_tree_sha", + "diff_sha256", + "review_bytes", + "trusted_workflow_sha", + "trigger", + "materializer_sha256", + "prompt_sha256", + "schema_sha256", + "workflow_sha256", + "input_sha256", +) + + +class MaterializationError(RuntimeError): + """Raised when the exact review input cannot be proven.""" + + +def fail(message: str) -> NoReturn: + raise MaterializationError(message) + + +def executable(name: str) -> str: + resolved = shutil.which(name, path=os.defpath) + if resolved is None: + fail(f"Required executable is unavailable in the system path: {name}") + return resolved + + +def command_environment(*, home: Path, include_token: bool) -> dict[str, str]: + environment = { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + "HOME": str(home), + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": os.defpath, + "XDG_CONFIG_HOME": str(home / ".config"), + } + if include_token: + token = os.environ.get("GH_TOKEN", "") + if not token: + fail("GH_TOKEN is required for live GitHub verification.") + environment["GH_TOKEN"] = token + return environment + + +def run( + arguments: Sequence[str], + *, + environment: dict[str, str], + cwd: Path | None = None, + binary: bool = False, +) -> subprocess.CompletedProcess[Any]: + try: + result = subprocess.run( # noqa: S603 + list(arguments), + cwd=cwd, + env=environment, + check=False, + capture_output=True, + text=not binary, + timeout=COMMAND_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + command = " ".join(arguments) or "" + fail(f"Command timed out after {COMMAND_TIMEOUT_SECONDS} seconds: {command}") + if result.returncode != 0: + stderr = ( + result.stderr + if isinstance(result.stderr, str) + else result.stderr.decode(errors="replace") + ) + command = " ".join(arguments) or "" + fail(f"Command failed closed: {command}: {stderr.strip()}") + return result + + +def require_sha(value: str, name: str) -> str: + if not SHA1_PATTERN.fullmatch(value): + fail(f"{name} must be a full lowercase SHA-1 object ID.") + return value + + +def require_single_sha_output(value: str, name: str) -> str: + lines = value.splitlines() + if len(lines) != 1: + fail(f"{name} must contain exactly one Git object ID.") + return require_sha(lines[0], name) + + +def protected_asset_bytes(path: Path, name: str) -> bytes: + """Read one bounded regular protected asset without following a symlink.""" + no_follow = getattr(os, "O_NOFOLLOW", None) + if not isinstance(no_follow, int) or no_follow == 0: + fail("Protected asset reading requires O_NOFOLLOW support.") + flags = os.O_RDONLY + flags |= getattr(os, "O_CLOEXEC", 0) + flags |= no_follow + try: + descriptor = os.open(path, flags) + except OSError as error: + fail(f"Protected {name} is unavailable: {error}") + try: + details = os.fstat(descriptor) + if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1: + fail(f"Protected {name} must be one regular non-symlink file.") + if details.st_uid != os.geteuid(): + fail(f"Protected {name} must be owned by the current user.") + if details.st_size <= 0 or details.st_size > MAX_PROTECTED_ASSET_BYTES: + fail(f"Protected {name} must contain 1..{MAX_PROTECTED_ASSET_BYTES} bytes.") + with os.fdopen(descriptor, "rb", closefd=False) as protected_asset: + payload = protected_asset.read(MAX_PROTECTED_ASSET_BYTES + 1) + if len(payload) != details.st_size: + fail(f"Protected {name} changed while reading.") + return payload + finally: + os.close(descriptor) + + +def write_owned_regular_file(path: Path, payload: bytes, name: str) -> None: + """Replace a bounded owned file without following its immediate parent or target.""" + no_follow = getattr(os, "O_NOFOLLOW", None) + if not isinstance(no_follow, int) or no_follow == 0: + fail("Protected file writing requires O_NOFOLLOW support.") + if len(payload) <= 0 or len(payload) > MAX_PROTECTED_ASSET_BYTES: + fail(f"Protected {name} must contain 1..{MAX_PROTECTED_ASSET_BYTES} bytes.") + if path.name in {"", ".", ".."}: + fail(f"Protected {name} path is invalid.") + close_on_exec = getattr(os, "O_CLOEXEC", 0) + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | no_follow + directory_flags |= close_on_exec + try: + directory = os.open(path.parent, directory_flags) + except OSError as error: + fail(f"Protected {name} parent cannot be opened safely: {error}") + temporary_name = f".mlx90-protected-{secrets.token_hex(16)}.tmp" + temporary_descriptor = -1 + replaced = False + try: + parent_details = os.fstat(directory) + if not stat.S_ISDIR(parent_details.st_mode): + fail(f"Protected {name} parent must be a directory.") + if parent_details.st_uid != os.geteuid(): + fail(f"Protected {name} parent must be owned by the current user.") + + existing_descriptor = -1 + try: + existing_descriptor = os.open( + path.name, + os.O_RDONLY | no_follow | close_on_exec, + dir_fd=directory, + ) + except FileNotFoundError: + pass + except OSError as error: + fail(f"Protected {name} cannot be opened safely: {error}") + try: + if existing_descriptor >= 0: + existing = os.fstat(existing_descriptor) + if not stat.S_ISREG(existing.st_mode) or existing.st_nlink != 1: + fail(f"Protected {name} must be one regular non-symlink file.") + if existing.st_uid != os.geteuid(): + fail(f"Protected {name} must be owned by the current user.") + finally: + if existing_descriptor >= 0: + os.close(existing_descriptor) + + temporary_descriptor = os.open( + temporary_name, + os.O_RDWR | os.O_CREAT | os.O_EXCL | no_follow | close_on_exec, + 0o600, + dir_fd=directory, + ) + temporary = os.fstat(temporary_descriptor) + if not stat.S_ISREG(temporary.st_mode) or temporary.st_nlink != 1: + fail(f"Protected {name} temporary file is not a single regular file.") + if temporary.st_uid != os.geteuid(): + fail(f"Protected {name} temporary file has an unexpected owner.") + os.fchmod(temporary_descriptor, 0o600) + remaining = memoryview(payload) + while remaining: + written = os.write(temporary_descriptor, remaining) + if written <= 0: + fail(f"Protected {name} was not written completely.") + remaining = remaining[written:] + os.fsync(temporary_descriptor) + temporary = os.fstat(temporary_descriptor) + if ( + not stat.S_ISREG(temporary.st_mode) + or temporary.st_nlink != 1 + or temporary.st_uid != os.geteuid() + or temporary.st_size != len(payload) + ): + fail(f"Protected {name} temporary file changed while writing.") + os.lseek(temporary_descriptor, 0, os.SEEK_SET) + with os.fdopen(temporary_descriptor, "rb", closefd=False) as protected_file: + if protected_file.read(len(payload) + 1) != payload: + fail(f"Protected {name} temporary content changed while writing.") + os.close(temporary_descriptor) + temporary_descriptor = -1 + + os.replace( + temporary_name, + path.name, + src_dir_fd=directory, + dst_dir_fd=directory, + ) + replaced = True + # The atomic replace is the commit point. Some filesystems do not + # support directory fsync; no post-commit durability probe may turn a + # complete replacement into a reported partial-write failure. + try: + os.fsync(directory) + except OSError: + pass + except OSError as error: + fail(f"Protected {name} cannot be written atomically: {error}") + finally: + active_error = sys.exc_info()[1] + if temporary_descriptor >= 0: + try: + os.close(temporary_descriptor) + except OSError as cleanup_error: + cleanup_message = ( + f"Protected {name} temporary close also failed: {cleanup_error}" + ) + if active_error is None: + fail(cleanup_message) + add_note = getattr(active_error, "add_note", None) + if callable(add_note): + add_note(cleanup_message) + if not replaced: + try: + os.unlink(temporary_name, dir_fd=directory) + except FileNotFoundError: + pass + except OSError as cleanup_error: + cleanup_message = ( + f"Protected {name} temporary cleanup also failed: {cleanup_error}" + ) + if active_error is None: + fail(cleanup_message) + add_note = getattr(active_error, "add_note", None) + if callable(add_note): + add_note(cleanup_message) + try: + os.close(directory) + except OSError as cleanup_error: + cleanup_message = ( + f"Protected {name} parent directory close also failed: {cleanup_error}" + ) + if active_error is None: + fail(cleanup_message) + add_note = getattr(active_error, "add_note", None) + if callable(add_note): + add_note(cleanup_message) + + +def bind_protected_assets( + metadata: dict[str, Any], asset_paths: dict[str, Path] +) -> dict[str, Any]: + """Bind every base-controlled review asset into one canonical input hash.""" + if set(asset_paths) != set(ASSET_ARGUMENTS): + fail("The complete protected review-asset set is required.") + bound = dict(metadata) + for metadata_key, path in asset_paths.items(): + asset_name = metadata_key.removesuffix("_sha256").replace("_", " ") + bound[metadata_key] = hashlib.sha256( + protected_asset_bytes(path, asset_name) + ).hexdigest() + canonical = json.dumps(bound, sort_keys=True, separators=(",", ":")).encode("utf-8") + bound["input_sha256"] = hashlib.sha256(canonical).hexdigest() + return bound + + +def asset_paths_from_arguments(arguments: argparse.Namespace) -> dict[str, Path]: + paths: dict[str, Path] = {} + for metadata_key, argument_name in ASSET_ARGUMENTS.items(): + path = getattr(arguments, argument_name, None) + if not isinstance(path, Path): + fail(f"Protected asset argument is required: {argument_name}") + paths[metadata_key] = path + return paths + + +def validate_inputs(arguments: argparse.Namespace) -> None: + if not REPOSITORY_PATTERN.fullmatch(arguments.repository): + fail("Repository must use the owner/name form.") + if arguments.pull_request <= 0: + fail("Pull-request number must be positive.") + if arguments.base_ref not in {"develop", "main"}: + fail("Base ref must be develop or main.") + require_sha(arguments.expected_base, "Expected base") + require_sha(arguments.expected_head, "Expected head") + require_sha(arguments.trusted_workflow_sha, "Trusted workflow") + if arguments.expected_base != arguments.trusted_workflow_sha: + fail("The protected workflow SHA must equal the live pull-request base SHA.") + if arguments.trigger not in {"ready_for_review", "app_dispatch"}: + fail("Unsupported exact-review trigger.") + if ( + arguments.trigger == "app_dispatch" + and arguments.dispatch_ref != f"refs/heads/{arguments.base_ref}" + ): + fail("App dispatch must execute from the protected pull-request base ref.") + + +def read_live_pull_request( + arguments: argparse.Namespace, *, home: Path +) -> dict[str, Any]: + gh = executable("gh") + result = run( + [ + gh, + "api", + f"repos/{arguments.repository}/pulls/{arguments.pull_request}", + ], + environment=command_environment(home=home, include_token=True), + ) + try: + pull_request = json.loads(result.stdout) + except json.JSONDecodeError as error: + fail(f"GitHub returned malformed pull-request JSON: {error}") + expected = { + "state": "open", + "draft": False, + "author": RELEASE_BOT, + "author_type": "Bot", + "base_ref": arguments.base_ref, + "base_sha": arguments.expected_base, + "base_repository": arguments.repository, + "head_sha": arguments.expected_head, + "head_repository": arguments.repository, + } + user = pull_request.get("user") or {} + base = pull_request.get("base") or {} + head = pull_request.get("head") or {} + base_repository = base.get("repo") or {} + head_repository = head.get("repo") or {} + observed = { + "state": pull_request.get("state"), + "draft": pull_request.get("draft"), + "author": user.get("login"), + "author_type": user.get("type"), + "base_ref": base.get("ref"), + "base_sha": base.get("sha"), + "base_repository": base_repository.get("full_name"), + "head_sha": head.get("sha"), + "head_repository": head_repository.get("full_name"), + } + if observed != expected: + fail( + f"Live pull-request binding changed or is unauthorized: {json.dumps(observed, sort_keys=True)}" + ) + return pull_request + + +def git_output( + git: str, + git_dir: Path, + arguments: Sequence[str], + *, + environment: dict[str, str], + binary: bool = False, +) -> bytes | str: + result = run( + [git, f"--git-dir={git_dir}", *arguments], + environment=environment, + binary=binary, + ) + return result.stdout + + +def materialize( + arguments: argparse.Namespace, output_directory: Path +) -> dict[str, Any]: + validate_inputs(arguments) + if output_directory.exists(): + fail(f"Review workspace already exists: {output_directory}") + try: + output_directory.mkdir(mode=0o700, parents=False) + except OSError as error: + fail(f"Unable to create the exact-revision review workspace: {error}") + + runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())).resolve() + if not runner_temp.is_dir(): + fail("RUNNER_TEMP must identify an existing directory.") + with tempfile.TemporaryDirectory( + prefix="exact-revision-materializer.", dir=runner_temp + ) as temporary: + temporary_root = Path(temporary) + home = temporary_root / "home" + home.mkdir(mode=0o700) + read_live_pull_request(arguments, home=home) + + git = executable("git") + git_dir = temporary_root / "objects.git" + git_environment = command_environment(home=home, include_token=True) + run([git, "init", "--bare", str(git_dir)], environment=git_environment) + git_output( + git, + git_dir, + ["config", "credential.helper", "!gh auth git-credential"], + environment=git_environment, + ) + git_output( + git, + git_dir, + [ + "remote", + "add", + "origin", + f"https://github.com/{arguments.repository}.git", + ], + environment=git_environment, + ) + git_output( + git, + git_dir, + [ + "fetch", + "--quiet", + "--no-tags", + "--no-recurse-submodules", + "origin", + f"+{arguments.expected_base}:refs/review/base", + f"+{arguments.expected_head}:refs/review/head", + ], + environment=git_environment, + ) + for name, expected in ( + ("base", arguments.expected_base), + ("head", arguments.expected_head), + ): + resolved = str( + git_output( + git, + git_dir, + ["rev-parse", f"refs/review/{name}^{{commit}}"], + environment=git_environment, + ) + ).strip() + if resolved != expected: + fail(f"Fetched {name} object does not equal the expected object ID.") + + merge_base = require_single_sha_output( + str( + git_output( + git, + git_dir, + [ + "merge-base", + "--all", + arguments.expected_base, + arguments.expected_head, + ], + environment=git_environment, + ) + ), + "Merge base", + ) + + integration_tree = require_single_sha_output( + str( + git_output( + git, + git_dir, + [ + "merge-tree", + "--write-tree", + arguments.expected_base, + arguments.expected_head, + ], + environment=git_environment, + ) + ), + "Integration tree", + ) + object_type = str( + git_output( + git, + git_dir, + ["cat-file", "-t", integration_tree], + environment=git_environment, + ) + ).strip() + if object_type != "tree": + fail("The integration object is not a Git tree.") + + diff = git_output( + git, + git_dir, + [ + "diff", + "--binary", + "--full-index", + "--no-color", + "--no-ext-diff", + "--no-textconv", + f"{arguments.expected_base}^{{tree}}", + integration_tree, + ], + environment=git_environment, + binary=True, + ) + if not isinstance(diff, bytes): + fail("Git returned an invalid diff representation.") + review_bytes = len(diff) + if review_bytes <= 0 or review_bytes >= MAX_REVIEW_BYTES: + fail( + "Exact-revision review input must contain " + f"1..{MAX_REVIEW_BYTES - 1} bytes; observed {review_bytes}." + ) + diff_sha256 = hashlib.sha256(diff).hexdigest() + + read_live_pull_request(arguments, home=home) + metadata = { + "schema_version": 3, + "repository": arguments.repository, + "pull_request": arguments.pull_request, + "base_ref": arguments.base_ref, + "base_sha": arguments.expected_base, + "head_sha": arguments.expected_head, + "merge_base_sha": merge_base, + "integration_tree_sha": integration_tree, + "diff_sha256": diff_sha256, + "review_bytes": review_bytes, + "trusted_workflow_sha": arguments.trusted_workflow_sha, + "trigger": arguments.trigger, + } + patch = output_directory / "change.patch" + metadata_path = output_directory / "review-metadata.json" + write_owned_regular_file(patch, diff, "review diff") + write_owned_regular_file( + metadata_path, + (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8"), + "review metadata", + ) + return metadata + + +def bind_assets(review_directory: Path, asset_paths: dict[str, Path]) -> dict[str, Any]: + metadata_path = review_directory / "review-metadata.json" + try: + metadata = json.loads( + protected_asset_bytes(metadata_path, "review metadata").decode("utf-8") + ) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + fail(f"Review metadata is malformed: {error}") + if not isinstance(metadata, dict): + fail("Review metadata must be a JSON object.") + if any(key in metadata for key in (*ASSET_ARGUMENTS, "input_sha256")): + fail("Review metadata already contains protected asset bindings.") + bound = bind_protected_assets(metadata, asset_paths) + write_owned_regular_file( + metadata_path, + (json.dumps(bound, indent=2, sort_keys=True) + "\n").encode("utf-8"), + "review metadata", + ) + return bound + + +def verify( + arguments: argparse.Namespace, + review_directory: Path, + asset_paths: dict[str, Path], +) -> dict[str, Any]: + validate_inputs(arguments) + patch = review_directory / "change.patch" + metadata_path = review_directory / "review-metadata.json" + if ( + not patch.is_file() + or patch.is_symlink() + or not metadata_path.is_file() + or metadata_path.is_symlink() + ): + fail("The review diff and metadata must be regular, non-symlink files.") + patch_size = patch.stat().st_size + if patch_size <= 0 or patch_size >= MAX_REVIEW_BYTES: + fail(f"The review diff must be between 1 and {MAX_REVIEW_BYTES - 1} bytes.") + try: + expected_metadata = json.loads( + protected_asset_bytes(metadata_path, "review metadata").decode("utf-8") + ) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + fail(f"Review metadata is malformed: {error}") + if not isinstance(expected_metadata, dict): + fail("Review metadata must be a JSON object.") + expected_keys = set(IMMUTABLE_METADATA_KEYS) + observed_keys = set(expected_metadata) + if observed_keys != expected_keys: + missing = sorted(expected_keys - observed_keys) + unexpected = sorted(observed_keys - expected_keys) + fail( + "Review metadata keys differ from the protected materializer output: " + f"missing={missing}, unexpected={unexpected}" + ) + + runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())).resolve() + if not runner_temp.is_dir(): + fail("RUNNER_TEMP must identify an existing directory.") + with tempfile.TemporaryDirectory( + prefix="exact-revision-recheck.", dir=runner_temp + ) as temporary: + regenerated = Path(temporary) / "review" + actual_metadata = bind_protected_assets( + materialize(arguments, regenerated), asset_paths + ) + if protected_asset_bytes(patch, "review diff") != protected_asset_bytes( + regenerated / "change.patch", "regenerated diff" + ): + fail("The full binary diff changed during exact-revision verification.") + for key in IMMUTABLE_METADATA_KEYS: + if expected_metadata.get(key) != actual_metadata.get(key): + fail(f"Exact-revision metadata changed during verification: {key}") + return actual_metadata + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("materialize", "bind-assets", "verify")) + parser.add_argument("--repository", required=True) + parser.add_argument("--pull-request", required=True, type=int) + parser.add_argument("--base-ref", required=True) + parser.add_argument("--expected-base", required=True) + parser.add_argument("--expected-head", required=True) + parser.add_argument("--trusted-workflow-sha", required=True) + parser.add_argument( + "--trigger", required=True, choices=("ready_for_review", "app_dispatch") + ) + parser.add_argument("--dispatch-ref", default="") + parser.add_argument("--review-directory", required=True, type=Path) + parser.add_argument("--materializer-path", type=Path) + parser.add_argument("--prompt-path", type=Path) + parser.add_argument("--schema-path", type=Path) + parser.add_argument("--workflow-path", type=Path) + return parser.parse_args() + + +def main() -> int: + arguments = parse_arguments() + try: + if arguments.mode == "materialize": + metadata = materialize(arguments, arguments.review_directory) + elif arguments.mode == "bind-assets": + metadata = bind_assets( + arguments.review_directory, asset_paths_from_arguments(arguments) + ) + else: + metadata = verify( + arguments, + arguments.review_directory, + asset_paths_from_arguments(arguments), + ) + except MaterializationError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(json.dumps(metadata, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())