shoc-pr-review-runner/.github/workflows/review-pr.yml
Adam Moussa 5fc633a3fa
Merge pull request #2 from Sea-Haven-Industries/fix/ci-status-honesty
fix: never report an unread CI signal as missing CI
2026-07-29 12:45:11 -04:00

323 lines
13 KiB
YAML

name: Review PR
# SHOC PR Review Runner — Phase 1 (spec §8, §26).
# Manually dispatched. Checks out exact PR heads read-only, runs clean
# build/test gates, generates a truthful evidence report, invokes the Fireworks
# review agent, validates its output, and publishes artifacts.
#
# This workflow NEVER writes to the product repositories or their PRs: the
# GITHUB_TOKEN carries contents:read only (listing any permission zeroes every
# unlisted scope), and product-repo access uses a GitHub App installation token
# downscoped at mint time to contents/pull-requests/metadata READ.
#
# SECURITY ARCHITECTURE — why this is two jobs:
# Running the product repos' build gates executes code authored in the PR under
# review (npm lifecycle scripts, eslint/vite/vitest configs, MSBuild targets).
# That code must never share a job with a secret, because step-level `env:` is
# not an isolation boundary: PR code can poison $GITHUB_ENV for later steps,
# read a later step's /proc environ, or overwrite the runner's own scripts.
# Therefore:
# job `gates` — executes untrusted PR code. Holds NO Fireworks key, and the
# App token is revoked before the first build command runs.
# job `review` — holds the Fireworks key. Executes NO product-repo code; it
# re-checks out this repo fresh (so tampered scripts from the
# gates job cannot follow) and consumes only text artifacts.
on:
workflow_dispatch:
inputs:
review_type:
description: Review type
required: true
type: choice
options: [frontend, backend, paired]
frontend_pr:
description: Frontend PR number (required for frontend/paired)
required: false
type: string
backend_pr:
description: Backend PR number (required for backend/paired)
required: false
type: string
ticket:
description: SH ticket identifier or URL
required: false
type: string
review_notes:
description: "Reviewer context. Sent to the Fireworks model and kept in run artifacts for 30 days — do not paste credentials."
required: false
type: string
run_mocked_e2e:
description: Run the mocked Playwright suite (frontend/paired)
required: true
type: boolean
default: true
model:
description: Fireworks review model
required: true
type: choice
default: deepseek-v4-pro
options: [deepseek-v4-pro, kimi-k2p6]
permissions:
contents: read
concurrency:
group: review-pr-${{ github.event.inputs.review_type }}-${{ github.event.inputs.frontend_pr }}-${{ github.event.inputs.backend_pr }}
cancel-in-progress: false
env:
FRONTEND_REPO: Sea-Haven-Industries/shoc-frontend-new
BACKEND_REPO: Sea-Haven-Industries/shoc-backend
COMPANION_BRANCH: dev
REVIEW_TYPE: ${{ github.event.inputs.review_type }}
TICKET: ${{ github.event.inputs.ticket }}
REVIEW_NOTES: ${{ github.event.inputs.review_notes }}
jobs:
gates:
name: Gates (${{ github.event.inputs.review_type }})
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout runner
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Configure workspace paths
# The product checkouts live OUTSIDE github.workspace so PR code is
# never a sibling of this repo's scripts. RUNNER_TEMP is only available
# as a shell variable, not in a workflow-level env block.
run: |
{
echo "WORKSPACE_DIR=$RUNNER_TEMP/workspace"
echo "ARTIFACTS_DIR=$RUNNER_TEMP/workspace/artifacts"
echo "STATE_DIR=$RUNNER_TEMP/runner-state-$(openssl rand -hex 8)"
} >> "$GITHUB_ENV"
- name: Resolve and validate inputs
id: inputs
env:
FRONTEND_PR: ${{ github.event.inputs.frontend_pr }}
BACKEND_PR: ${{ github.event.inputs.backend_pr }}
run: ./scripts/resolve-inputs.sh
- name: Mint read-only App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.SHOC_REVIEW_APP_ID }}
private-key: ${{ secrets.SHOC_REVIEW_APP_PRIVATE_KEY }}
owner: Sea-Haven-Industries
repositories: shoc-frontend-new,shoc-backend
# Downscope at mint time so the token stays read-only even if the App
# installation is later granted broader permissions.
permission-contents: read
permission-pull-requests: read
permission-checks: read
permission-metadata: read
- name: Resolve frontend PR head
if: steps.inputs.outputs.frontend_pr != ''
id: frontend-head
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.inputs.outputs.frontend_pr }}
run: ./scripts/resolve-pr-head.sh frontend "$FRONTEND_REPO" "$PR_NUMBER"
- name: Resolve backend PR head
if: steps.inputs.outputs.backend_pr != ''
id: backend-head
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.inputs.outputs.backend_pr }}
run: ./scripts/resolve-pr-head.sh backend "$BACKEND_REPO" "$PR_NUMBER"
- name: Checkout product repositories at exact heads
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
FRONTEND_SHA: ${{ steps.frontend-head.outputs.frontend_sha }}
BACKEND_SHA: ${{ steps.backend-head.outputs.backend_sha }}
run: ./scripts/checkout-repositories.sh
- name: Collect review context
# Runs before any PR-authored code executes, so the collected diff and
# file contents cannot be tampered with by the build.
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: ./scripts/collect-context.sh
- name: Revoke App token before running untrusted code
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
gh api -X DELETE /installation/token --silent || echo "token revoke returned non-zero (it also expires on its own)"
# Everything below this line may execute code authored in the PR.
# No secret is present in this job from here on.
- name: Set up .NET
if: inputs.review_type == 'backend' || inputs.review_type == 'paired'
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 8.0.x
- name: Backend gates
if: inputs.review_type == 'backend' || inputs.review_type == 'paired'
continue-on-error: true
run: ./scripts/run-backend-gates.sh
- name: Set up Node
if: inputs.review_type == 'frontend' || inputs.review_type == 'paired'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
- name: Frontend gates
if: inputs.review_type == 'frontend' || inputs.review_type == 'paired'
continue-on-error: true
env:
RUN_MOCKED_E2E: ${{ inputs.run_mocked_e2e }}
run: ./scripts/run-frontend-gates.sh
- name: Stop stray background processes
if: always()
run: |
# PR-authored scripts can background processes that would otherwise
# keep running and mutate files after the gates finish.
pkill -u "$(id -u)" -f 'node|dotnet|vite|playwright' 2>/dev/null || true
sleep 2
- name: Stage gate results for the review job
if: always()
run: |
cp "$STATE_DIR/gate-status.tsv" "$ARTIFACTS_DIR/gate-status.tsv" 2>/dev/null || true
ls -la "$ARTIFACTS_DIR"
- name: Upload gates context
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: gates-context-${{ github.run_id }}
path: ${{ runner.temp }}/workspace/artifacts/
retention-days: 1
if-no-files-found: warn
review:
name: Review
needs: gates
if: always() && needs.gates.result != 'cancelled'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout runner
# Fresh checkout: scripts tampered with in the gates job cannot follow.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Configure workspace paths
# The product checkouts live OUTSIDE github.workspace so PR code is
# never a sibling of this repo's scripts. RUNNER_TEMP is only available
# as a shell variable, not in a workflow-level env block.
run: |
{
echo "WORKSPACE_DIR=$RUNNER_TEMP/workspace"
echo "ARTIFACTS_DIR=$RUNNER_TEMP/workspace/artifacts"
echo "STATE_DIR=$RUNNER_TEMP/runner-state-$(openssl rand -hex 8)"
} >> "$GITHUB_ENV"
- name: Download gates context
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gates-context-${{ github.run_id }}
path: ${{ runner.temp }}/workspace/artifacts
- name: Point the gate table at the downloaded results
run: |
mkdir -p "$STATE_DIR"
cp "$ARTIFACTS_DIR/gate-status.tsv" "$STATE_DIR/gate-status.tsv" 2>/dev/null || true
- name: Generate evidence report
run: ./scripts/generate-evidence.sh
- name: Run review agent
id: agent
continue-on-error: true
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
MODEL: ${{ inputs.model }}
run: ./scripts/run-review-agent.sh
- name: Artifact redaction check
id: redact
# Runs even when earlier steps failed so nothing is uploaded unscanned.
if: always()
env:
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
run: ./scripts/redact-check.sh
- name: Job summary
if: always() && steps.redact.outcome == 'success'
run: |
{
echo "## SHOC PR Review — ${REVIEW_TYPE}"
echo ""
echo "### Gate status (machine-recorded, authoritative)"
echo ""
echo "| Gate | Status |"
echo "| --- | --- |"
if [ -f "$STATE_DIR/gate-status.tsv" ]; then
awk -F'\t' '{printf "| %s | %s |\n", $1, $2}' "$STATE_DIR/gate-status.tsv"
fi
echo ""
if [ -f "$ARTIFACTS_DIR/review.md" ] && [ "${{ steps.agent.outcome }}" = "success" ]; then
echo "### Review (model-generated, copy into GitHub manually)"
echo ""
echo "The block below is model output influenced by PR content. The"
echo "gate table above is the authoritative record of what ran."
echo ""
echo '```markdown'
cat "$ARTIFACTS_DIR/review.md"
echo '```'
else
echo "### No valid review produced"
echo ""
echo "Agent step outcome: ${{ steps.agent.outcome }} — see validation-errors.txt in the artifacts."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload review
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.redact.outcome == 'success'
with:
name: review-${{ github.run_id }}
path: |
${{ runner.temp }}/workspace/artifacts/review.md
${{ runner.temp }}/workspace/artifacts/review-evidence.md
${{ runner.temp }}/workspace/artifacts/gate-status.tsv
${{ runner.temp }}/workspace/artifacts/validation-errors.txt
${{ runner.temp }}/workspace/artifacts/logs/
retention-days: 30
if-no-files-found: warn
- name: Upload prompt and diff material
# Contains full private-repo source; kept briefly for debugging only.
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.redact.outcome == 'success'
with:
name: review-context-${{ github.run_id }}
path: |
${{ runner.temp }}/workspace/artifacts/agent-prompt*.txt
${{ runner.temp }}/workspace/artifacts/agent-raw-response*
${{ runner.temp }}/workspace/artifacts/prompt-*.txt
${{ runner.temp }}/workspace/artifacts/*.diff
retention-days: 2
if-no-files-found: warn
- name: Fail run if agent or validation failed
if: steps.agent.outcome != 'success'
run: |
echo "Review agent or output validation failed — see artifacts." >&2
exit 1