Public API hardening: fix force-delete, project-scope hosts, route tests + OpenAPI drift check #5804
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Preview | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - closed | |
| # MCP-only PRs have their own preview pipeline (pr-mcp-preview.yml) that | |
| # deploys a dedicated per-PR worker. Skip the full Railway inspector | |
| # preview when a PR's diff is confined to MCP-only concerns: the `mcp/` | |
| # package itself, its PR-preview workflow, and its staging-deploy | |
| # workflow. GitHub still fires this workflow for any PR whose diff | |
| # touches at least one non-ignored path, so mixed PRs still get the | |
| # inspector preview. | |
| # | |
| # Known rare edge case: if a PR opens with mixed MCP + non-MCP changes | |
| # (Railway preview env created), then is force-pushed to remove all | |
| # non-MCP changes before close, GitHub evaluates paths-ignore against | |
| # the final PR diff and skips the `closed` event too — orphaning the | |
| # Railway env. Manual cleanup via the Railway dashboard is the current | |
| # answer; a follow-up can split `destroy-preview` into its own | |
| # always-on-close workflow if this becomes real. | |
| # | |
| # If `upsert-preview` is ever added as a required status check in | |
| # branch protection, MCP-only PRs will show it perpetually "Pending" | |
| # and be blocked from merging — GitHub's path filter skips the workflow | |
| # entirely rather than marking it green. Fix at that time by relaxing | |
| # the rule or adding a companion always-succeeds job. | |
| paths-ignore: | |
| - "mcp/**" | |
| - ".github/workflows/pr-mcp-preview.yml" | |
| - ".github/workflows/deploy-mcp-staging.yml" | |
| - ".github/workflows/deploy-mcp-prod.yml" | |
| repository_dispatch: | |
| types: | |
| - backend_preview_ready | |
| - backend_preview_failed | |
| - backend_pr_preview_requested | |
| - backend_pr_inspector_cleanup | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| env: | |
| PREVIEW_COMMENT_MARKER: "<!-- mcpjam-preview -->" | |
| RAILWAY_ENV_PREFIX: pr- | |
| RAILWAY_BACKEND_PR_ENV_PREFIX: pr-be- | |
| jobs: | |
| upsert-preview: | |
| if: > | |
| github.event_name == 'pull_request' && | |
| github.event.action != 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| # Newer pushes cancel older upsert/sync runs for the same PR. Prevents | |
| # the "commit A's sync clobbers commit B's env vars" race the audit | |
| # caught. Shared with sync-backend-preview + destroy-preview below. | |
| concurrency: | |
| group: preview-pr-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: preview-pr-${{ github.event.pull_request.number }} | |
| url: ${{ steps.preview_domain.outputs.url }} | |
| env: | |
| BACKEND_REPO: ${{ vars.MCPJAM_BACKEND_REPO || 'MCPJam/mcpjam-backend' }} | |
| RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} | |
| RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} | |
| RAILWAY_INSPECTOR_SERVICE: ${{ vars.RAILWAY_INSPECTOR_SERVICE }} | |
| # Source environment that new preview envs are duplicated from. Prefer | |
| # the dedicated preview-template environment (scoped-down secrets) once | |
| # it is provisioned; fall back to duplicating staging — secrets and | |
| # all — until then. | |
| RAILWAY_PREVIEW_SOURCE_ENVIRONMENT: ${{ vars.RAILWAY_PREVIEW_TEMPLATE_ENVIRONMENT || vars.RAILWAY_STAGING_ENVIRONMENT }} | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS: ${{ vars.MCPJAM_EMPLOYEE_EMAIL_DOMAINS }} | |
| STAGING_WORKOS_CLIENT_ID: ${{ vars.STAGING_WORKOS_CLIENT_ID }} | |
| STAGING_WORKOS_API_HOSTNAME: ${{ vars.STAGING_WORKOS_API_HOSTNAME || 'api.workos.com' }} | |
| # Default Convex backend for previews when no per-PR backend preview | |
| # exists. Points at the shared preview deployment once the | |
| # PREVIEW_BACKEND_* repo vars are provisioned (see | |
| # mcpjam-backend/docs/preview-environments.md); falls back to the | |
| # staging deployment until then. | |
| DEFAULT_BACKEND_VITE_CONVEX_URL: ${{ vars.PREVIEW_BACKEND_VITE_CONVEX_URL || vars.STAGING_VITE_CONVEX_URL }} | |
| DEFAULT_BACKEND_CONVEX_HTTP_URL: ${{ vars.PREVIEW_BACKEND_CONVEX_HTTP_URL || vars.STAGING_CONVEX_HTTP_URL }} | |
| STAGING_APP_URL: ${{ vars.STAGING_APP_URL || 'https://staging.mcpjam.com' }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Resolve deployed commit metadata | |
| id: preview_commit | |
| run: | | |
| echo "deployed_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" | |
| - name: Install preview tooling | |
| run: npm install -g @railway/cli@4.57.1 workos | |
| - name: Resolve preview environment metadata | |
| id: meta | |
| run: | | |
| echo "environment=${RAILWAY_ENV_PREFIX}${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" | |
| echo "branch=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT" | |
| - name: Check for matching backend branch | |
| id: backend_branch | |
| env: | |
| TOKEN: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| PR_BRANCH: ${{ steps.meta.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| # GitHub returns 404 for both "branch missing" and "token can't see | |
| # this repo" on private repos. Preflight a repo-level GET so only a | |
| # real access failure hard-fails; after that, a 404 on the branch | |
| # path unambiguously means the branch doesn't exist. | |
| ACCESS_CODE=$(curl -sS -o /dev/null -w "%{http_code}" \ | |
| -H "Authorization: Bearer ${TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "https://api.github.com/repos/${BACKEND_REPO}") | |
| if [ "$ACCESS_CODE" != "200" ]; then | |
| echo "::error::BACKEND_PREVIEW_DISPATCH_TOKEN cannot access ${BACKEND_REPO} (HTTP $ACCESS_CODE)" | |
| exit 1 | |
| fi | |
| # URL-encode slashes in the branch name for the path parameter. | |
| ENCODED_BRANCH=$(jq -rn --arg s "$PR_BRANCH" '$s|@uri') | |
| HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" \ | |
| -H "Authorization: Bearer ${TOKEN}" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "https://api.github.com/repos/${BACKEND_REPO}/branches/${ENCODED_BRANCH}") | |
| case "$HTTP_CODE" in | |
| 200) echo "exists=true" >> "$GITHUB_OUTPUT" ;; | |
| 404) echo "exists=false" >> "$GITHUB_OUTPUT" ;; | |
| *) echo "::error::Unexpected HTTP $HTTP_CODE checking ${BACKEND_REPO}/${PR_BRANCH}"; exit 1 ;; | |
| esac | |
| # Railway CLI's `link`, `whoami`, `variable set`, `environment new`, | |
| # and `environment delete` all do an internal user-identity preflight | |
| # that fails with "Unauthorized" on workspace tokens. We bypass each | |
| # with direct GraphQL calls (railway-env.sh, railway-set-vars.sh). | |
| # `railway up` and `railway run` work fine with workspace tokens, so | |
| # they stay on the CLI. See deploy-staging.yml for the same pattern. | |
| - name: Create preview environment | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-env.sh new "${{ steps.meta.outputs.environment }}" \ | |
| --duplicate "$RAILWAY_PREVIEW_SOURCE_ENVIRONMENT" | |
| - name: Configure non-prod preview defaults | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ steps.meta.outputs.environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_NONPROD_LOCKDOWN=true \ | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| VITE_MCPJAM_NONPROD_LOCKDOWN=true \ | |
| VITE_MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| VITE_MCPJAM_HOSTED_MODE=true \ | |
| VITE_WORKOS_CLIENT_ID="$STAGING_WORKOS_CLIENT_ID" \ | |
| VITE_WORKOS_API_HOSTNAME="$STAGING_WORKOS_API_HOSTNAME" \ | |
| ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \ | |
| WEB_ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \ | |
| VITE_CONVEX_URL="$DEFAULT_BACKEND_VITE_CONVEX_URL" \ | |
| CONVEX_HTTP_URL="$DEFAULT_BACKEND_CONVEX_HTTP_URL" | |
| - name: Deploy preview to Railway | |
| run: | | |
| .github/scripts/railway-retry.sh railway up \ | |
| --ci \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ steps.meta.outputs.environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" | |
| - name: Read preview public domain | |
| id: preview_domain | |
| continue-on-error: true | |
| run: | | |
| DOMAIN=$(.github/scripts/railway-retry.sh railway run \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ steps.meta.outputs.environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" \ | |
| printenv RAILWAY_PUBLIC_DOMAIN | tail -n 1 | tr -d '\r') | |
| if [ -n "$DOMAIN" ]; then | |
| echo "url=https://${DOMAIN}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Configure preview origin allowlists | |
| if: steps.preview_domain.outputs.url != '' | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ steps.meta.outputs.environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" \ | |
| WEB_ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" | |
| - name: Register preview URL with WorkOS staging | |
| if: steps.preview_domain.outputs.url != '' | |
| env: | |
| STAGING_WORKOS_API_KEY: ${{ secrets.STAGING_WORKOS_API_KEY }} | |
| run: | | |
| workos config redirect add "${{ steps.preview_domain.outputs.url }}/callback" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json | |
| workos config cors add "${{ steps.preview_domain.outputs.url }}" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json || true | |
| # Initial health check: the preview starts wired to the default | |
| # backend (shared preview deployment, or staging until that's | |
| # provisioned). If that backend is broken (e.g., a rename shipped to | |
| # main without `convex deploy` against it), fail loudly now so the | |
| # author isn't debugging a 404 from the fallback case later. | |
| - name: Verify initial preview can reach Convex (default backend) | |
| id: initial_health | |
| if: steps.preview_domain.outputs.url != '' | |
| continue-on-error: true | |
| env: | |
| # The script posts to <url>/api/query, which only exists on the | |
| # .convex.cloud (client API) domain. The .convex.site HTTP-router | |
| # domain 404s every probe ("No matching routes found"), which made | |
| # this check report the backend as down on every PR. | |
| CONVEX_HTTP: ${{ env.DEFAULT_BACKEND_VITE_CONVEX_URL }} | |
| run: | | |
| .github/scripts/convex-health-check.sh "$CONVEX_HTTP" "chatboxes:listChatboxes" | |
| - name: Dispatch backend preview request | |
| if: steps.backend_branch.outputs.exists == 'true' && steps.preview_domain.outputs.url != '' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| script: | | |
| const [owner, repo] = process.env.BACKEND_REPO.split("/"); | |
| await github.rest.repos.createDispatchEvent({ | |
| owner, | |
| repo, | |
| event_type: "inspector_preview_requested", | |
| client_payload: { | |
| branch: "${{ steps.meta.outputs.branch }}", | |
| inspector_sha: "${{ steps.preview_commit.outputs.deployed_sha }}", | |
| inspector_head_sha: "${{ steps.preview_commit.outputs.head_sha }}", | |
| inspector_repo: context.repo.owner + "/" + context.repo.repo, | |
| inspector_pr_number: context.payload.pull_request.number, | |
| inspector_environment: "${{ steps.meta.outputs.environment }}", | |
| preview_url: "${{ steps.preview_domain.outputs.url }}", | |
| }, | |
| }); | |
| - name: Upsert preview comment | |
| # always() so the comment reflects truth even when an earlier step | |
| # (Railway config, origin allowlists, health check) failed. | |
| if: always() | |
| uses: actions/github-script@v7 | |
| env: | |
| PREVIEW_URL: ${{ steps.preview_domain.outputs.url }} | |
| # 'preview requested' is string-matched by preview-watchdog.yml — | |
| # keep that label verbatim when rewording. | |
| BACKEND_MODE: ${{ steps.backend_branch.outputs.exists == 'true' && 'preview requested' || (vars.PREVIEW_BACKEND_VITE_CONVEX_URL != '' && 'shared preview backend' || 'staging fallback') }} | |
| HEALTH_OUTCOME: ${{ steps.initial_health.outcome }} | |
| PREVIEW_DEPLOYED_SHA: ${{ steps.preview_commit.outputs.deployed_sha }} | |
| PREVIEW_HEAD_SHA: ${{ steps.preview_commit.outputs.head_sha }} | |
| with: | |
| script: | | |
| const marker = process.env.PREVIEW_COMMENT_MARKER; | |
| const previewUrl = process.env.PREVIEW_URL; | |
| const backendMode = process.env.BACKEND_MODE; | |
| const healthOutcome = process.env.HEALTH_OUTCOME || "skipped"; | |
| const deployedSha = process.env.PREVIEW_DEPLOYED_SHA; | |
| const headSha = process.env.PREVIEW_HEAD_SHA; | |
| const issue_number = context.payload.pull_request.number; | |
| const commitLink = (sha) => | |
| sha | |
| ? `[${sha.slice(0, 7)}](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha})` | |
| : null; | |
| const healthLine = | |
| healthOutcome === "success" | |
| ? "Health: ✅ Convex reachable" | |
| : healthOutcome === "failure" | |
| ? "Health: ❌ Convex unreachable — see upsert-preview job logs (the default preview backend may need `convex deploy`)" | |
| : null; | |
| const bodyLines = [ | |
| marker, | |
| "### Internal preview", | |
| previewUrl | |
| ? `Preview URL: ${previewUrl}` | |
| : "Preview URL will appear in Railway after the deploy finishes.", | |
| deployedSha | |
| ? `Deployed commit: ${commitLink(deployedSha)}` | |
| : null, | |
| headSha && headSha !== deployedSha | |
| ? `PR head commit: ${commitLink(headSha)}` | |
| : null, | |
| `Backend target: ${backendMode}.`, | |
| healthLine, | |
| "Access is employee-only in non-production environments.", | |
| ].filter(Boolean); | |
| const body = bodyLines.join("\n"); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((comment) => comment.body?.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| return; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number, | |
| body, | |
| }); | |
| upsert-backend-pr-preview: | |
| if: github.event_name == 'repository_dispatch' && github.event.action == 'backend_pr_preview_requested' | |
| concurrency: | |
| group: preview-pr-be-${{ github.event.client_payload.backend_pr_number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: preview-pr-be-${{ github.event.client_payload.backend_pr_number }} | |
| url: ${{ steps.preview_domain.outputs.url }} | |
| env: | |
| BACKEND_REPO: ${{ vars.MCPJAM_BACKEND_REPO || 'MCPJam/mcpjam-backend' }} | |
| RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} | |
| RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} | |
| RAILWAY_INSPECTOR_SERVICE: ${{ vars.RAILWAY_INSPECTOR_SERVICE }} | |
| # Source environment that new preview envs are duplicated from. Prefer | |
| # the dedicated preview-template environment (scoped-down secrets) once | |
| # it is provisioned; fall back to duplicating staging — secrets and | |
| # all — until then. | |
| RAILWAY_PREVIEW_SOURCE_ENVIRONMENT: ${{ vars.RAILWAY_PREVIEW_TEMPLATE_ENVIRONMENT || vars.RAILWAY_STAGING_ENVIRONMENT }} | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS: ${{ vars.MCPJAM_EMPLOYEE_EMAIL_DOMAINS }} | |
| STAGING_WORKOS_CLIENT_ID: ${{ vars.STAGING_WORKOS_CLIENT_ID }} | |
| STAGING_WORKOS_API_HOSTNAME: ${{ vars.STAGING_WORKOS_API_HOSTNAME || 'api.workos.com' }} | |
| # Default Convex backend for previews when no per-PR backend preview | |
| # exists. Points at the shared preview deployment once the | |
| # PREVIEW_BACKEND_* repo vars are provisioned (see | |
| # mcpjam-backend/docs/preview-environments.md); falls back to the | |
| # staging deployment until then. | |
| DEFAULT_BACKEND_VITE_CONVEX_URL: ${{ vars.PREVIEW_BACKEND_VITE_CONVEX_URL || vars.STAGING_VITE_CONVEX_URL }} | |
| DEFAULT_BACKEND_CONVEX_HTTP_URL: ${{ vars.PREVIEW_BACKEND_CONVEX_HTTP_URL || vars.STAGING_CONVEX_HTTP_URL }} | |
| STAGING_APP_URL: ${{ vars.STAGING_APP_URL || 'https://staging.mcpjam.com' }} | |
| steps: | |
| - name: Checkout default branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.client_payload.inspector_sha || github.event.client_payload.inspector_git_ref || github.event.client_payload.branch || 'main' }} | |
| - name: Resolve deployed commit metadata | |
| id: preview_commit | |
| run: | | |
| echo "deployed_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=${{ github.event.client_payload.inspector_head_sha || '' }}" >> "$GITHUB_OUTPUT" | |
| - name: Install preview tooling | |
| run: npm install -g @railway/cli@4.57.1 workos | |
| - name: Resolve preview environment metadata | |
| id: meta | |
| env: | |
| BACKEND_PR_NUMBER: ${{ github.event.client_payload.backend_pr_number }} | |
| run: | | |
| if ! [[ "$BACKEND_PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "::error::backend_pr_number is missing or non-numeric: '$BACKEND_PR_NUMBER'" | |
| exit 1 | |
| fi | |
| echo "environment=${RAILWAY_BACKEND_PR_ENV_PREFIX}${BACKEND_PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| echo "branch=${{ github.event.client_payload.branch }}" >> "$GITHUB_OUTPUT" | |
| # See upsert-preview above for why `railway link` is skipped. | |
| - name: Create preview environment | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-env.sh new "${{ steps.meta.outputs.environment }}" \ | |
| --duplicate "$RAILWAY_PREVIEW_SOURCE_ENVIRONMENT" | |
| - name: Configure non-prod preview defaults | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ steps.meta.outputs.environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_NONPROD_LOCKDOWN=true \ | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| VITE_MCPJAM_NONPROD_LOCKDOWN=true \ | |
| VITE_MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| VITE_MCPJAM_HOSTED_MODE=true \ | |
| VITE_WORKOS_CLIENT_ID="$STAGING_WORKOS_CLIENT_ID" \ | |
| VITE_WORKOS_API_HOSTNAME="$STAGING_WORKOS_API_HOSTNAME" \ | |
| ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \ | |
| WEB_ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \ | |
| VITE_CONVEX_URL="$DEFAULT_BACKEND_VITE_CONVEX_URL" \ | |
| CONVEX_HTTP_URL="$DEFAULT_BACKEND_CONVEX_HTTP_URL" | |
| - name: Deploy preview to Railway | |
| run: | | |
| .github/scripts/railway-retry.sh railway up \ | |
| --ci \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ steps.meta.outputs.environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" | |
| - name: Read preview public domain | |
| id: preview_domain | |
| continue-on-error: true | |
| run: | | |
| DOMAIN=$(.github/scripts/railway-retry.sh railway run \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ steps.meta.outputs.environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" \ | |
| printenv RAILWAY_PUBLIC_DOMAIN | tail -n 1 | tr -d '\r') | |
| if [ -n "$DOMAIN" ]; then | |
| echo "url=https://${DOMAIN}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Configure preview origin allowlists | |
| if: steps.preview_domain.outputs.url != '' | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ steps.meta.outputs.environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" \ | |
| WEB_ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" | |
| - name: Register preview URL with WorkOS staging | |
| if: steps.preview_domain.outputs.url != '' | |
| env: | |
| STAGING_WORKOS_API_KEY: ${{ secrets.STAGING_WORKOS_API_KEY }} | |
| run: | | |
| workos config redirect add "${{ steps.preview_domain.outputs.url }}/callback" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json | |
| workos config cors add "${{ steps.preview_domain.outputs.url }}" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json || true | |
| - name: Dispatch backend preview request | |
| if: steps.preview_domain.outputs.url != '' | |
| uses: actions/github-script@v7 | |
| env: | |
| BACKEND_REPO_FULL: ${{ github.event.client_payload.backend_repo }} | |
| BACKEND_PR_NUMBER: ${{ github.event.client_payload.backend_pr_number }} | |
| with: | |
| github-token: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| script: | | |
| // Only allow dispatching to the configured backend repo | |
| if (process.env.BACKEND_REPO_FULL !== process.env.BACKEND_REPO) { | |
| core.setFailed( | |
| `backend_repo '${process.env.BACKEND_REPO_FULL}' does not match allowed repo '${process.env.BACKEND_REPO}'` | |
| ); | |
| return; | |
| } | |
| const [owner, repo] = process.env.BACKEND_REPO.split("/"); | |
| const [beOwner, beRepo] = process.env.BACKEND_REPO_FULL.split("/"); | |
| await github.rest.repos.createDispatchEvent({ | |
| owner, | |
| repo, | |
| event_type: "inspector_preview_requested", | |
| client_payload: { | |
| branch: "${{ steps.meta.outputs.branch }}", | |
| inspector_sha: "${{ steps.preview_commit.outputs.deployed_sha }}", | |
| inspector_head_sha: "${{ steps.preview_commit.outputs.head_sha || steps.preview_commit.outputs.deployed_sha }}", | |
| inspector_repo: context.repo.owner + "/" + context.repo.repo, | |
| inspector_pr_number: "0", | |
| inspector_environment: "${{ steps.meta.outputs.environment }}", | |
| preview_url: "${{ steps.preview_domain.outputs.url }}", | |
| comment_issue_owner: beOwner, | |
| comment_issue_repo: beRepo, | |
| comment_issue_number: String(process.env.BACKEND_PR_NUMBER), | |
| }, | |
| }); | |
| - name: Upsert preview comment on backend PR | |
| uses: actions/github-script@v7 | |
| env: | |
| PREVIEW_URL: ${{ steps.preview_domain.outputs.url }} | |
| BACKEND_REPO_FULL: ${{ github.event.client_payload.backend_repo }} | |
| BACKEND_PR_NUMBER: ${{ github.event.client_payload.backend_pr_number }} | |
| PREVIEW_DEPLOYED_SHA: ${{ steps.preview_commit.outputs.deployed_sha }} | |
| PREVIEW_HEAD_SHA: ${{ steps.preview_commit.outputs.head_sha || steps.preview_commit.outputs.deployed_sha }} | |
| with: | |
| github-token: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| script: | | |
| const marker = process.env.PREVIEW_COMMENT_MARKER; | |
| const previewUrl = process.env.PREVIEW_URL; | |
| const deployedSha = process.env.PREVIEW_DEPLOYED_SHA; | |
| const headSha = process.env.PREVIEW_HEAD_SHA; | |
| if (process.env.BACKEND_REPO_FULL !== process.env.BACKEND_REPO) { | |
| core.setFailed( | |
| `backend_repo '${process.env.BACKEND_REPO_FULL}' does not match allowed repo '${process.env.BACKEND_REPO}'` | |
| ); | |
| return; | |
| } | |
| const [owner, repo] = (process.env.BACKEND_REPO_FULL || "").split("/"); | |
| const issue_number = Number(process.env.BACKEND_PR_NUMBER); | |
| if (!owner || !repo || !issue_number) { | |
| core.setFailed("Missing backend_repo or backend_pr_number"); | |
| return; | |
| } | |
| const bodyLines = [ | |
| marker, | |
| "### Internal preview", | |
| previewUrl | |
| ? `Preview URL: ${previewUrl}` | |
| : "Preview URL will appear in Railway after the deploy finishes.", | |
| deployedSha | |
| ? `Deployed commit: [${deployedSha.slice(0, 7)}](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${deployedSha})` | |
| : null, | |
| headSha && headSha !== deployedSha | |
| ? `PR head commit: [${headSha.slice(0, 7)}](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${headSha})` | |
| : null, | |
| "Backend target: Convex preview requested (falls back to the shared non-prod backend if deploy fails).", | |
| "Access is employee-only in non-production environments.", | |
| ].filter(Boolean); | |
| const body = bodyLines.join("\n"); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((comment) => comment.body?.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| return; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body, | |
| }); | |
| destroy-preview: | |
| if: > | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.head.repo.full_name == github.repository | |
| # Same group as upsert so closing a PR cancels any in-flight upsert | |
| # for that PR; destroy runs last. | |
| concurrency: | |
| group: preview-pr-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| env: | |
| BACKEND_REPO: ${{ vars.MCPJAM_BACKEND_REPO || 'MCPJam/mcpjam-backend' }} | |
| RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} | |
| RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} | |
| RAILWAY_INSPECTOR_SERVICE: ${{ vars.RAILWAY_INSPECTOR_SERVICE }} | |
| RAILWAY_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }} | |
| steps: | |
| # Checkout is required so the `.github/scripts/` helpers are on disk. | |
| - uses: actions/checkout@v4 | |
| - name: Install Railway CLI | |
| run: npm install -g @railway/cli@4.57.1 | |
| # The preview's public domain only exists as RAILWAY_PUBLIC_DOMAIN | |
| # inside the Railway environment, so it must be read BEFORE the | |
| # environment is deleted — it's what the upsert job registered with | |
| # WorkOS staging. Unlike the upsert jobs' version of this step, the | |
| # environment may legitimately not exist here (manual deletion, an | |
| # upsert that never ran), and railway-retry replays CLI error text on | |
| # stdout — so only accept values shaped like a hostname. | |
| - name: Read preview public domain | |
| id: preview_domain | |
| continue-on-error: true | |
| run: | | |
| DOMAIN=$(.github/scripts/railway-retry.sh railway run \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${RAILWAY_ENV_PREFIX}${{ github.event.pull_request.number }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" \ | |
| printenv RAILWAY_PUBLIC_DOMAIN | tail -n 1 | tr -d '\r') | |
| if [[ "$DOMAIN" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then | |
| echo "url=https://${DOMAIN}" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Inverse of "Register preview URL with WorkOS staging" in the upsert | |
| # job. Without this, the staging WorkOS app accumulates redirect URIs | |
| # and CORS origins pointing at deleted (reclaimable) *.up.railway.app | |
| # domains — an auth-code-interception hygiene issue. Best-effort: must | |
| # never block PR close. Runs before the env delete so a cancelled run | |
| # leaves the env (re-registered on reopen) rather than dangling | |
| # WorkOS entries. Uses curl, not the workos CLI — the CLI has no | |
| # `config redirect/cors remove` subcommand (see workos-cleanup.sh). | |
| - name: Deregister preview URL from WorkOS staging | |
| if: steps.preview_domain.outputs.url != '' | |
| env: | |
| STAGING_WORKOS_API_KEY: ${{ secrets.STAGING_WORKOS_API_KEY }} | |
| run: | | |
| .github/scripts/workos-cleanup.sh "${{ steps.preview_domain.outputs.url }}" || true | |
| - name: Delete Railway preview environment | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-env.sh delete "${RAILWAY_ENV_PREFIX}${{ github.event.pull_request.number }}" \ | |
| --yes || true | |
| - name: Dispatch backend preview cleanup | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| script: | | |
| const [owner, repo] = process.env.BACKEND_REPO.split("/"); | |
| await github.rest.repos.createDispatchEvent({ | |
| owner, | |
| repo, | |
| event_type: "inspector_preview_cleanup", | |
| client_payload: { | |
| branch: context.payload.pull_request.head.ref, | |
| inspector_pr_number: context.payload.pull_request.number, | |
| }, | |
| }); | |
| destroy-backend-pr-preview: | |
| if: github.event_name == 'repository_dispatch' && github.event.action == 'backend_pr_inspector_cleanup' | |
| concurrency: | |
| group: preview-pr-be-${{ github.event.client_payload.backend_pr_number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| env: | |
| RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} | |
| RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} | |
| RAILWAY_INSPECTOR_SERVICE: ${{ vars.RAILWAY_INSPECTOR_SERVICE }} | |
| RAILWAY_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }} | |
| steps: | |
| # Checkout is required so the `.github/scripts/` helpers are on disk. | |
| - uses: actions/checkout@v4 | |
| - name: Install Railway CLI | |
| run: npm install -g @railway/cli@4.57.1 | |
| # The validated environment name is shared by the read/deregister/ | |
| # delete steps below; a bad payload fails the job here, before | |
| # anything is read or deleted. | |
| - name: Resolve preview environment metadata | |
| id: meta | |
| env: | |
| BACKEND_PR_NUMBER: ${{ github.event.client_payload.backend_pr_number }} | |
| run: | | |
| if ! [[ "$BACKEND_PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "::error::backend_pr_number is missing or non-numeric: '$BACKEND_PR_NUMBER'" | |
| exit 1 | |
| fi | |
| echo "environment=${RAILWAY_BACKEND_PR_ENV_PREFIX}${BACKEND_PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| # See destroy-preview above: the domain must be read before the | |
| # environment is deleted, then its WorkOS registrations are removed. | |
| - name: Read preview public domain | |
| id: preview_domain | |
| continue-on-error: true | |
| run: | | |
| DOMAIN=$(.github/scripts/railway-retry.sh railway run \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ steps.meta.outputs.environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" \ | |
| printenv RAILWAY_PUBLIC_DOMAIN | tail -n 1 | tr -d '\r') | |
| if [[ "$DOMAIN" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then | |
| echo "url=https://${DOMAIN}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Deregister preview URL from WorkOS staging | |
| if: steps.preview_domain.outputs.url != '' | |
| env: | |
| STAGING_WORKOS_API_KEY: ${{ secrets.STAGING_WORKOS_API_KEY }} | |
| run: | | |
| .github/scripts/workos-cleanup.sh "${{ steps.preview_domain.outputs.url }}" || true | |
| - name: Delete Railway backend-PR preview environment | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-env.sh delete "${{ steps.meta.outputs.environment }}" \ | |
| --yes || true | |
| sync-backend-preview: | |
| if: > | |
| github.event_name == 'repository_dispatch' && | |
| (github.event.action == 'backend_preview_ready' || github.event.action == 'backend_preview_failed') | |
| # Share the upsert group so a newer inspector push pre-empts a stale | |
| # sync from an earlier commit's backend callback. | |
| concurrency: | |
| group: preview-pr-${{ github.event.client_payload.inspector_pr_number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| env: | |
| RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} | |
| RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} | |
| RAILWAY_INSPECTOR_SERVICE: ${{ vars.RAILWAY_INSPECTOR_SERVICE }} | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS: ${{ vars.MCPJAM_EMPLOYEE_EMAIL_DOMAINS }} | |
| STAGING_WORKOS_CLIENT_ID: ${{ vars.STAGING_WORKOS_CLIENT_ID }} | |
| STAGING_WORKOS_API_HOSTNAME: ${{ vars.STAGING_WORKOS_API_HOSTNAME || 'api.workos.com' }} | |
| # See upsert-preview: shared preview deployment when provisioned, | |
| # staging deployment until then. | |
| DEFAULT_BACKEND_VITE_CONVEX_URL: ${{ vars.PREVIEW_BACKEND_VITE_CONVEX_URL || vars.STAGING_VITE_CONVEX_URL }} | |
| DEFAULT_BACKEND_CONVEX_HTTP_URL: ${{ vars.PREVIEW_BACKEND_CONVEX_HTTP_URL || vars.STAGING_CONVEX_HTTP_URL }} | |
| PREVIEW_BACKEND_CONFIGURED: ${{ vars.PREVIEW_BACKEND_VITE_CONVEX_URL != '' }} | |
| STAGING_APP_URL: ${{ vars.STAGING_APP_URL || 'https://staging.mcpjam.com' }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.client_payload.inspector_sha || github.event.client_payload.inspector_git_ref || github.event.client_payload.branch || 'main' }} | |
| - name: Resolve deployed commit metadata | |
| id: preview_commit | |
| run: | | |
| echo "deployed_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=${{ github.event.client_payload.inspector_head_sha || '' }}" >> "$GITHUB_OUTPUT" | |
| - name: Install preview tooling | |
| run: npm install -g @railway/cli@4.57.1 workos | |
| - name: Resolve backend target | |
| id: backend_target | |
| run: | | |
| if [ "${{ github.event.action }}" = "backend_preview_ready" ]; then | |
| echo "vite_convex_url=${{ github.event.client_payload.vite_convex_url }}" >> "$GITHUB_OUTPUT" | |
| echo "convex_http_url=${{ github.event.client_payload.convex_http_url }}" >> "$GITHUB_OUTPUT" | |
| echo "backend_mode=preview" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "vite_convex_url=$DEFAULT_BACKEND_VITE_CONVEX_URL" >> "$GITHUB_OUTPUT" | |
| echo "convex_http_url=$DEFAULT_BACKEND_CONVEX_HTTP_URL" >> "$GITHUB_OUTPUT" | |
| if [ "$PREVIEW_BACKEND_CONFIGURED" = "true" ]; then | |
| echo "backend_mode=shared preview fallback" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "backend_mode=staging fallback" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| - name: Update preview environment variables | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ github.event.client_payload.inspector_environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_NONPROD_LOCKDOWN=true \ | |
| MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| VITE_MCPJAM_NONPROD_LOCKDOWN=true \ | |
| VITE_MCPJAM_EMPLOYEE_EMAIL_DOMAINS="$MCPJAM_EMPLOYEE_EMAIL_DOMAINS" \ | |
| VITE_MCPJAM_HOSTED_MODE=true \ | |
| VITE_WORKOS_CLIENT_ID="$STAGING_WORKOS_CLIENT_ID" \ | |
| VITE_WORKOS_API_HOSTNAME="$STAGING_WORKOS_API_HOSTNAME" \ | |
| VITE_CONVEX_URL="${{ steps.backend_target.outputs.vite_convex_url }}" \ | |
| CONVEX_HTTP_URL="${{ steps.backend_target.outputs.convex_http_url }}" | |
| - name: Redeploy preview after backend update | |
| run: | | |
| .github/scripts/railway-retry.sh railway up \ | |
| --ci \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ github.event.client_payload.inspector_environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" | |
| - name: Read preview public domain | |
| id: preview_domain | |
| continue-on-error: true | |
| run: | | |
| DOMAIN=$(.github/scripts/railway-retry.sh railway run \ | |
| --project "$RAILWAY_PROJECT_ID" \ | |
| --environment "${{ github.event.client_payload.inspector_environment }}" \ | |
| --service "$RAILWAY_INSPECTOR_SERVICE" \ | |
| printenv RAILWAY_PUBLIC_DOMAIN | tail -n 1 | tr -d '\r') | |
| if [ -n "$DOMAIN" ]; then | |
| echo "url=https://${DOMAIN}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Refresh preview origin allowlists | |
| if: steps.preview_domain.outputs.url != '' | |
| run: | | |
| .github/scripts/railway-retry.sh .github/scripts/railway-set-vars.sh \ | |
| -e "${{ github.event.client_payload.inspector_environment }}" \ | |
| -s "$RAILWAY_INSPECTOR_SERVICE" \ | |
| MCPJAM_ALLOWED_HOSTS="staging.mcpjam.com,*.up.railway.app" \ | |
| ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" \ | |
| WEB_ALLOWED_ORIGINS="${{ steps.preview_domain.outputs.url }},$STAGING_APP_URL" | |
| - name: Register preview URL with WorkOS staging | |
| if: steps.preview_domain.outputs.url != '' | |
| env: | |
| STAGING_WORKOS_API_KEY: ${{ secrets.STAGING_WORKOS_API_KEY }} | |
| run: | | |
| workos config redirect add "${{ steps.preview_domain.outputs.url }}/callback" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json | |
| workos config cors add "${{ steps.preview_domain.outputs.url }}" \ | |
| --api-key "$STAGING_WORKOS_API_KEY" \ | |
| --json || true | |
| # End-to-end health check: whatever Convex URL the inspector is now | |
| # wired to (preview or staging fallback) must actually have the public | |
| # functions the inspector calls. Fails loudly if it doesn't so the | |
| # final PR comment can reflect the truth. | |
| - name: Verify preview can reach Convex | |
| id: final_health | |
| if: steps.preview_domain.outputs.url != '' | |
| continue-on-error: true | |
| env: | |
| # /api/query lives on the .convex.cloud client-API domain, not the | |
| # .convex.site HTTP-router domain (see initial_health above). | |
| CONVEX_HTTP: ${{ steps.backend_target.outputs.vite_convex_url }} | |
| run: | | |
| .github/scripts/convex-health-check.sh "$CONVEX_HTTP" "chatboxes:listChatboxes" | |
| - name: Update preview comment | |
| # always() so a failed Railway/WorkOS/health step doesn't leave the | |
| # comment lying about the previous state. | |
| if: always() | |
| uses: actions/github-script@v7 | |
| env: | |
| PREVIEW_URL: ${{ steps.preview_domain.outputs.url }} | |
| BACKEND_MODE: ${{ steps.backend_target.outputs.backend_mode }} | |
| HEALTH_OUTCOME: ${{ steps.final_health.outcome }} | |
| PREVIEW_DEPLOYED_SHA: ${{ steps.preview_commit.outputs.deployed_sha }} | |
| PREVIEW_HEAD_SHA: ${{ steps.preview_commit.outputs.head_sha }} | |
| CROSS_REPO_TOKEN: ${{ secrets.BACKEND_PREVIEW_DISPATCH_TOKEN }} | |
| BACKEND_REPO: ${{ vars.MCPJAM_BACKEND_REPO || 'MCPJam/mcpjam-backend' }} | |
| with: | |
| github-token: ${{ (github.event.client_payload.comment_issue_owner || '') != '' && secrets.BACKEND_PREVIEW_DISPATCH_TOKEN || github.token }} | |
| script: | | |
| const marker = process.env.PREVIEW_COMMENT_MARKER; | |
| const deployedSha = process.env.PREVIEW_DEPLOYED_SHA; | |
| const headSha = process.env.PREVIEW_HEAD_SHA; | |
| const p = context.payload.client_payload; | |
| const issueOwner = p.comment_issue_owner; | |
| const issueRepo = p.comment_issue_repo; | |
| const issueNumber = issueOwner | |
| ? Number(p.comment_issue_number) | |
| : Number(p.inspector_pr_number); | |
| const owner = issueOwner || context.repo.owner; | |
| const repo = issueRepo || context.repo.repo; | |
| const commitLink = (sha) => | |
| sha | |
| ? `[${sha.slice(0, 7)}](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha})` | |
| : null; | |
| // Fail explicitly if cross-repo commenting was requested but token is missing | |
| if (issueOwner && !process.env.CROSS_REPO_TOKEN) { | |
| core.setFailed( | |
| "Cross-repo comment requested but BACKEND_PREVIEW_DISPATCH_TOKEN is not configured" | |
| ); | |
| return; | |
| } | |
| // Validate cross-repo target matches the allowlisted backend repo | |
| if (issueOwner) { | |
| const crossRepo = `${issueOwner}/${issueRepo}`; | |
| if (crossRepo !== process.env.BACKEND_REPO) { | |
| core.setFailed( | |
| `Cross-repo target '${crossRepo}' does not match allowed repo '${process.env.BACKEND_REPO}'` | |
| ); | |
| return; | |
| } | |
| } | |
| // Guard against invalid issue number (e.g. sentinel "0" leaking through) | |
| if (!issueNumber || issueNumber <= 0) { | |
| core.setFailed( | |
| `Invalid issue number '${issueNumber}' — cannot post comment` | |
| ); | |
| return; | |
| } | |
| const healthOutcome = process.env.HEALTH_OUTCOME || "skipped"; | |
| const healthLine = | |
| healthOutcome === "success" | |
| ? "Health: ✅ Convex reachable" | |
| : healthOutcome === "failure" | |
| ? "Health: ❌ Convex unreachable — see job logs (the active preview backend may need `convex deploy`)" | |
| : null; | |
| const bodyLines = [ | |
| marker, | |
| "### Internal preview", | |
| process.env.PREVIEW_URL | |
| ? `Preview URL: ${process.env.PREVIEW_URL}` | |
| : "Preview URL will appear in Railway after the deploy finishes.", | |
| deployedSha | |
| ? `Deployed commit: ${commitLink(deployedSha)}` | |
| : null, | |
| headSha && headSha !== deployedSha | |
| ? `PR head commit: ${commitLink(headSha)}` | |
| : null, | |
| `Backend target: ${process.env.BACKEND_MODE}.`, | |
| healthLine, | |
| "Access is employee-only in non-production environments.", | |
| ].filter(Boolean); | |
| const body = bodyLines.join("\n"); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((comment) => comment.body?.includes(marker)); | |
| if (!existing) { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body, | |
| }); | |
| return; | |
| } | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existing.id, | |
| body, | |
| }); |