Skip to content

fix: preserve verdict state and skip cancelled preview gates #4166

fix: preserve verdict state and skip cancelled preview gates

fix: preserve verdict state and skip cancelled preview gates #4166

Workflow file for this run

name: PR Preview
on:
pull_request:
types:
- opened
- reopened
- synchronize
- closed
concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
detect-changes:
name: Detect preview changes
# Promotion pull requests (staging -> main) provision nothing: the staging
# environment already serves the exact commit under review, so a per-PR
# copy duplicates it. The required gate below still reports for them.
if: >-
github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.head.ref != 'staging'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
outputs:
deploy_backend: ${{ steps.compute.outputs.deploy_backend }}
run_migrations: ${{ steps.compute.outputs.run_migrations }}
deploy_frontend: ${{ steps.compute.outputs.deploy_frontend }}
any_change: ${{ steps.compute.outputs.any_change }}
preview_backend_live: ${{ steps.compute.outputs.preview_backend_live }}
preview_api_url: ${{ format('https://abundant-ai-preview--oddish-pr-{0}-api.modal.run', github.event.pull_request.number) }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
# find_last_deploys.py emits all the change signals (see its docstring):
# - pr_{backend,migrations,frontend}: does the whole PR touch each
# component (diffed vs the PR base branch) -- used on opened/reopened
# and as the no-base fallback.
# - On synchronize: backend_base/migrations_base (last successful deploy
# SHAs) + {backend,migrations,workflow}_changed (only this push's
# files, diffed vs that base / the previous push).
# We compute these via the GitHub compare API instead of dorny/paths-filter
# because that action ignores its `base` input on pull_request events (so
# it diffs the whole PR) and, under its default `some` quantifier, its
# negated excludes match almost anything -- both forced the DB seed.
- name: Detect preview component changes
id: last_deployed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OWNER_REPO: ${{ github.repository }}
EVENT_ACTION: ${{ github.event.action }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: python "$GITHUB_WORKSPACE/.github/scripts/preview/find_last_deploys.py"
- name: Compute deployment plan
id: compute
env:
EVENT_ACTION: ${{ github.event.action }}
BACKEND_BASE: ${{ steps.last_deployed.outputs.backend_base }}
MIGRATIONS_BASE: ${{ steps.last_deployed.outputs.migrations_base }}
BACKEND_CHANGED: ${{ steps.last_deployed.outputs.backend_changed }}
MIGRATIONS_CHANGED: ${{ steps.last_deployed.outputs.migrations_changed }}
PR_BACKEND_CHANGED: ${{ steps.last_deployed.outputs.pr_backend }}
PR_MIGRATIONS_CHANGED: ${{ steps.last_deployed.outputs.pr_migrations }}
PR_FRONTEND_CHANGED: ${{ steps.last_deployed.outputs.pr_frontend }}
WORKFLOW_CHANGED: ${{ steps.last_deployed.outputs.workflow_changed }}
run: bash "$GITHUB_WORKSPACE/.github/scripts/preview/compute_deployment_plan.sh"
prepare-preview-database:
name: Prepare preview database
needs:
- detect-changes
# Backend-scoped PRs keep their Supabase preview branch smoke-tested on
# later frontend-only pushes, so the required preview stays end-to-end.
if: |
always() &&
needs.detect-changes.result == 'success' &&
(
needs.detect-changes.outputs.preview_backend_live == 'true' ||
needs.detect-changes.outputs.deploy_backend == 'true' ||
needs.detect-changes.outputs.run_migrations == 'true'
)
runs-on: ubuntu-latest
# Covers the auto-heal worst case: incremental upgrade + retry, then a full
# snapshot rebuild (restore + seed + upgrade) back-to-back.
timeout-minutes: 20
permissions:
contents: read
packages: read
outputs:
branch_ref: ${{ steps.prepare.outputs.branch_ref }}
branch_id: ${{ steps.prepare.outputs.branch_id }}
branch_was_created: ${{ steps.prepare.outputs.branch_was_created }}
container:
image: ghcr.io/abundant-ai/oddish-ci-base:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
MODAL_ENVIRONMENT: preview
MODAL_APP_NAME: oddish-pr-${{ github.event.pull_request.number }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ vars.SUPABASE_PROJECT_REF }}
UV_PROJECT_ENVIRONMENT: /opt/venvs/backend
# Production DB URL, used strictly read-only as the source of the schema
# snapshot + Alembic pointers (bootstrap_preview_db.py) and the sampled
# seed data -- the preview's only data source. Deliberately NOT named
# ODDISH_DATABASE_URL: that var is rebound to the branch DB by
# wait_for_supabase_branch.sh in this job.
PREVIEW_SAMPLE_SOURCE_DB_URL: ${{ secrets.ODDISH_DATABASE_URL }}
defaults:
run:
working-directory: backend
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Sync backend dependencies
run: uv sync --frozen
- name: Prepare preview database
id: prepare
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
DEPLOY_BACKEND: ${{ needs.detect-changes.outputs.deploy_backend }}
RUN_MIGRATIONS: ${{ needs.detect-changes.outputs.run_migrations }}
run: "$GITHUB_WORKSPACE/.github/scripts/preview/prepare_preview_database.sh"
deploy-preview-backend:
name: Deploy preview backend
needs:
- detect-changes
- prepare-preview-database
if: |
always() &&
needs.prepare-preview-database.result == 'success' &&
(
needs.detect-changes.outputs.deploy_backend == 'true' ||
needs.prepare-preview-database.outputs.branch_was_created == 'true'
)
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
modal_api_url: ${{ steps.deploy.outputs.modal_api_url }}
container:
image: ghcr.io/abundant-ai/oddish-ci-base:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
MODAL_ENVIRONMENT: preview
MODAL_APP_NAME: oddish-pr-${{ github.event.pull_request.number }}
MODAL_SECRET_ENVIRONMENT: main
EXPECTED_MODAL_API_URL: ${{ needs.detect-changes.outputs.preview_api_url }}
UV_PROJECT_ENVIRONMENT: /opt/venvs/backend
ODDISH_MODAL_API_MIN_CONTAINERS: "0"
ODDISH_MODAL_API_BUFFER_CONTAINERS: "0"
ODDISH_MODAL_API_MAX_CONTAINERS: "2"
ODDISH_MODAL_WORKER_MIN_CONTAINERS: "0"
ODDISH_MODAL_WORKER_BUFFER_CONTAINERS: "0"
ODDISH_MODAL_WORKER_MAX_CONTAINERS: "2"
ODDISH_GATE_LLM_ON_BASELINES: "1"
defaults:
run:
working-directory: backend
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Sync backend dependencies
run: uv sync --frozen
- name: Deploy preview backend
id: deploy
run: "$GITHUB_WORKSPACE/.github/scripts/preview/deploy_preview_backend.sh"
update-vercel-preview:
name: Update Vercel preview
needs:
- detect-changes
- prepare-preview-database
- deploy-preview-backend
# Run for every open same-repository PR, not only frontend changes: the
# branch ruleset requires a working Preview deployment before merge.
if: |
always() &&
needs.detect-changes.result == 'success' &&
(
needs.prepare-preview-database.result == 'success' ||
needs.prepare-preview-database.result == 'skipped'
) &&
(
needs.deploy-preview-backend.result == 'success' ||
needs.deploy-preview-backend.result == 'skipped'
)
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
container:
image: ghcr.io/abundant-ai/oddish-ci-base:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_GIT_BRANCH: ${{ github.event.pull_request.head.ref }}
VERCEL_GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_TITLE: ${{ github.event.pull_request.title }}
# Point the preview frontend at the preview backend whenever one is live
# (deployed this run or a prior run), NOT only when it redeployed this push
# -- otherwise a frontend-only follow-up push blanks this, and
# update_vercel_preview.sh falls back to prod, so the preview frontend
# starts querying production. Blank (-> prod fallback) only when no preview
# backend was ever provisioned (frontend-only PRs).
MODAL_API_URL: ${{ needs.detect-changes.outputs.preview_backend_live == 'true' && needs.detect-changes.outputs.preview_api_url || '' }}
MODAL_APP_NAME: oddish-pr-${{ github.event.pull_request.number }}
STAGING_API_URL: ${{ vars.ODDISH_STAGING_API_URL }}
PROD_API_URL: ${{ vars.ODDISH_PROD_API_URL }}
SUPABASE_BRANCH_REF: ${{ needs.prepare-preview-database.outputs.branch_ref }}
PREVIEW_ALIAS_HOSTNAME: pr-${{ github.event.pull_request.number }}.oddish.app
outputs:
preview_url: ${{ steps.vercel.outputs.preview_url }}
preview_alias_url: ${{ steps.vercel.outputs.preview_alias_url }}
backend_api_url: ${{ steps.vercel.outputs.backend_api_url }}
backend_label: ${{ steps.vercel.outputs.backend_label }}
database_label: ${{ steps.vercel.outputs.database_label }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Update Vercel preview
id: vercel
run: "$GITHUB_WORKSPACE/.github/scripts/preview/update_vercel_preview.sh"
require-working-preview:
name: Require working preview
needs:
- detect-changes
- prepare-preview-database
- deploy-preview-backend
- update-vercel-preview
# `!cancelled()` rather than `always()`: the gate must still run when
# upstream jobs are SKIPPED (fork and promotion paths), but a run that
# cancel-in-progress superseded must not publish a failing check and a
# failing deployment status for a commit whose replacement run is still
# building.
if: "!cancelled() && github.event.action != 'closed'"
runs-on: ubuntu-latest
# No job-level `environment:` key here: GitHub attributes that deployment
# record to the person who triggered the run, so pull requests showed a
# human as the deployer. The steps below create the same Preview record
# through the API with the workflow token, so the deployer is
# github-actions. The record still satisfies the staging ruleset's
# required-deployments rule (same environment, same commit).
permissions:
deployments: write
env:
GH_TOKEN: ${{ github.token }}
# Same-repository condition included: a fork branch named `staging`
# must not short-circuit the gate to success.
PROMOTION_PR: ${{ github.event.pull_request.head.ref == 'staging' && github.event.pull_request.head.repo.full_name == github.repository }}
DEPLOY_URL: ${{ (github.event.pull_request.head.ref == 'staging' && github.event.pull_request.head.repo.full_name == github.repository) && 'https://staging.oddish.app' || needs.update-vercel-preview.outputs.preview_alias_url || needs.update-vercel-preview.outputs.preview_url }}
steps:
- name: Create the Preview deployment record
id: deployment
# Fork PRs get a read-only token; skip so the verify step below can
# fail with its clearer same-repository message.
if: github.event.pull_request.head.repo.full_name == github.repository
env:
# The head commit, not GITHUB_SHA: on pull_request events GITHUB_SHA
# is the temporary merge commit, and the required-deployments rule
# and the pull request page key on the head commit.
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
deployment_id=$(jq -n --arg ref "$HEAD_SHA" \
'{ref: $ref, environment: "Preview", auto_merge: false,
required_contexts: [], transient_environment: true,
description: "PR preview gate"}' \
| gh api "repos/$GITHUB_REPOSITORY/deployments" --input - --jq '.id')
gh api "repos/$GITHUB_REPOSITORY/deployments/$deployment_id/statuses" \
-f state=in_progress >/dev/null
echo "id=$deployment_id" >> "$GITHUB_OUTPUT"
- name: Verify preview deployment
id: verify
env:
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
DETECT_RESULT: ${{ needs.detect-changes.result }}
PREPARE_RESULT: ${{ needs.prepare-preview-database.result }}
BACKEND_RESULT: ${{ needs.deploy-preview-backend.result }}
VERCEL_RESULT: ${{ needs.update-vercel-preview.result }}
DEPLOY_BACKEND: ${{ needs.detect-changes.outputs.deploy_backend }}
RUN_MIGRATIONS: ${{ needs.detect-changes.outputs.run_migrations }}
PREVIEW_BACKEND_LIVE: ${{ needs.detect-changes.outputs.preview_backend_live }}
BRANCH_WAS_CREATED: ${{ needs.prepare-preview-database.outputs.branch_was_created }}
DB_BRANCH_ID: ${{ needs.prepare-preview-database.outputs.branch_id }}
DB_BRANCH_REF: ${{ needs.prepare-preview-database.outputs.branch_ref }}
PREVIEW_URL: ${{ needs.update-vercel-preview.outputs.preview_url }}
PREVIEW_ALIAS_URL: ${{ needs.update-vercel-preview.outputs.preview_alias_url }}
BACKEND_API_URL: ${{ needs.update-vercel-preview.outputs.backend_api_url }}
run: |
set -euo pipefail
{
echo "## Required preview gate"
echo
} >> "$GITHUB_STEP_SUMMARY"
fail() {
echo "::error::$1"
echo "- $1" >> "$GITHUB_STEP_SUMMARY"
exit 1
}
if [ "$PROMOTION_PR" = "true" ]; then
echo "- Promotion pull request: staging is the preview environment." >> "$GITHUB_STEP_SUMMARY"
echo "- https://staging.oddish.app runs this commit (see Staging Deploy)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
preview_url="${PREVIEW_ALIAS_URL:-${PREVIEW_URL:-}}"
backend_required=false
db_required=false
if [ "$PREVIEW_BACKEND_LIVE" = "true" ]; then
backend_required=true
db_required=true
fi
if [ "$DEPLOY_BACKEND" = "true" ] || [ "$RUN_MIGRATIONS" = "true" ]; then
backend_required=true
db_required=true
fi
if [ "$SAME_REPO" != "true" ]; then
fail "PR preview deployments require a same-repository branch with access to preview secrets."
fi
if [ "$DETECT_RESULT" != "success" ]; then
fail "Preview change detection did not succeed."
fi
if [ "$db_required" = "true" ]; then
if [ "$PREPARE_RESULT" != "success" ]; then
fail "Preview database preparation did not succeed."
fi
if [ -z "$DB_BRANCH_ID" ] || [ -z "$DB_BRANCH_REF" ]; then
fail "Preview database branch was not produced."
fi
elif [ "$PREPARE_RESULT" != "success" ] && [ "$PREPARE_RESULT" != "skipped" ]; then
fail "Preview database preparation did not succeed."
fi
if [ "$backend_required" = "true" ]; then
if { [ "$DEPLOY_BACKEND" = "true" ] || [ "$BRANCH_WAS_CREATED" = "true" ]; } &&
[ "$BACKEND_RESULT" != "success" ]; then
fail "Preview backend deployment did not succeed."
fi
if [ "$BACKEND_RESULT" != "success" ] && [ "$BACKEND_RESULT" != "skipped" ]; then
fail "Preview backend deployment did not succeed."
fi
if [ -z "$BACKEND_API_URL" ]; then
fail "Preview backend URL was not produced."
fi
fi
if [ "$VERCEL_RESULT" != "success" ]; then
fail "Vercel preview update did not succeed."
fi
if [ -z "$preview_url" ]; then
fail "Vercel preview URL was not produced."
fi
for attempt in $(seq 1 36); do
status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 10 "$preview_url" || true)"
case "$status" in
2*|3*|401|403)
echo "- Frontend preview is ready: $preview_url" >> "$GITHUB_STEP_SUMMARY"
break
;;
esac
if [ "$attempt" = "36" ]; then
fail "Frontend preview never became reachable at $preview_url."
fi
sleep 5
done
if [ "$backend_required" = "true" ]; then
readiness_url="${BACKEND_API_URL%/}/openapi.json"
# --max-time must outlast a backend cold start (~30s+); a shorter
# one abandons every attempt mid-boot so no retry count can pass.
for attempt in $(seq 1 12); do
body_file="$(mktemp)"
status="$(curl --silent --output "$body_file" --write-out '%{http_code}' --max-time 120 "$readiness_url" || true)"
if [ "$status" = "200" ] && [ -s "$body_file" ]; then
rm -f "$body_file"
echo "- Backend preview is ready: $readiness_url" >> "$GITHUB_STEP_SUMMARY"
break
fi
rm -f "$body_file"
if [ "$attempt" = "12" ]; then
fail "Preview backend never became ready at $readiness_url."
fi
sleep 5
done
fi
{
echo "- Backend required: \`$backend_required\`"
echo "- Database required: \`$db_required\`"
if [ "$db_required" = "true" ]; then
echo "- Database preview branch: \`$DB_BRANCH_REF\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Publish the deployment result
if: always() && steps.deployment.outputs.id != ''
env:
DEPLOYMENT_ID: ${{ steps.deployment.outputs.id }}
VERIFY_OUTCOME: ${{ steps.verify.outcome }}
run: |
set -euo pipefail
state=failure
if [ "$VERIFY_OUTCOME" = "success" ]; then state=success; fi
jq -n --arg state "$state" --arg url "${DEPLOY_URL:-}" \
'{state: $state}
+ (if $url != "" then {environment_url: $url} else {} end)' \
| gh api "repos/$GITHUB_REPOSITORY/deployments/$DEPLOYMENT_ID/statuses" \
--input - >/dev/null
echo "deployment $DEPLOYMENT_ID marked $state"
post-preview-links:
name: Post preview links
needs:
- detect-changes
- prepare-preview-database
- deploy-preview-backend
- update-vercel-preview
if: |
always() &&
needs.detect-changes.result == 'success' &&
needs.detect-changes.outputs.any_change == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PREVIEW_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
DEPLOY_BACKEND: ${{ needs.detect-changes.outputs.deploy_backend }}
RUN_MIGRATIONS: ${{ needs.detect-changes.outputs.run_migrations }}
DEPLOY_FRONTEND: ${{ needs.detect-changes.outputs.deploy_frontend }}
VERCEL_PREVIEW_URL: ${{ needs.update-vercel-preview.outputs.preview_alias_url || needs.update-vercel-preview.outputs.preview_url }}
VERCEL_DEPLOYMENT_URL: ${{ needs.update-vercel-preview.outputs.preview_url }}
MODAL_API_URL: ${{ needs.deploy-preview-backend.outputs.modal_api_url || needs.update-vercel-preview.outputs.backend_api_url }}
PREVIEW_BACKEND_LABEL: ${{ needs.update-vercel-preview.outputs.backend_label }}
PREVIEW_DATABASE_LABEL: ${{ needs.update-vercel-preview.outputs.database_label }}
SUPABASE_BRANCH_REF: ${{ needs.prepare-preview-database.outputs.branch_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Post preview links
run: python "$GITHUB_WORKSPACE/.github/scripts/preview/post_preview_links.py"
stop-preview:
name: Stop preview
if: >-
github.event.action == 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.head.ref != 'staging'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
container:
image: ghcr.io/abundant-ai/oddish-ci-base:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
MODAL_ENVIRONMENT: preview
MODAL_APP_NAME: oddish-pr-${{ github.event.pull_request.number }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_REF: ${{ vars.SUPABASE_PROJECT_REF }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_GIT_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PREVIEW_ALIAS_HOSTNAME: pr-${{ github.event.pull_request.number }}.oddish.app
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Stop preview
run: "$GITHUB_WORKSPACE/.github/scripts/preview/stop_preview.sh"