Skip to content

charge users after trial if paid during trial #1521

charge users after trial if paid during trial

charge users after trial if paid during trial #1521

Workflow file for this run

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 }}
RAILWAY_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }}
MCPJAM_EMPLOYEE_EMAIL_DOMAINS: ${{ vars.MCPJAM_EMPLOYEE_EMAIL_DOMAINS }}
STAGING_WORKOS_CLIENT_ID: ${{ vars.STAGING_WORKOS_CLIENT_ID }}
STAGING_VITE_CONVEX_URL: ${{ vars.STAGING_VITE_CONVEX_URL }}
STAGING_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 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
- name: Create or refresh Railway preview environment
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null
.github/scripts/railway-retry.sh railway environment new "${{ steps.meta.outputs.environment }}" \
--duplicate "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null || true
- name: Configure staging-safe preview defaults
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ steps.meta.outputs.environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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" \
ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \
WEB_ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \
VITE_CONVEX_URL="$STAGING_VITE_CONVEX_URL" \
CONVEX_HTTP_URL="$STAGING_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 railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ steps.meta.outputs.environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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 is currently wired to staging
# Convex. If staging itself is broken (e.g., a rename shipped to main
# without `convex deploy` against staging), fail loudly now so the
# author isn't debugging a 404 from the "staging fallback" case later.
- name: Verify initial preview can reach Convex (staging)
id: initial_health
if: steps.preview_domain.outputs.url != ''
continue-on-error: true
env:
CONVEX_HTTP: ${{ env.STAGING_CONVEX_HTTP_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 }}
BACKEND_MODE: ${{ steps.backend_branch.outputs.exists == 'true' && 'preview requested' || '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 (staging 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 }}
RAILWAY_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }}
MCPJAM_EMPLOYEE_EMAIL_DOMAINS: ${{ vars.MCPJAM_EMPLOYEE_EMAIL_DOMAINS }}
STAGING_WORKOS_CLIENT_ID: ${{ vars.STAGING_WORKOS_CLIENT_ID }}
STAGING_VITE_CONVEX_URL: ${{ vars.STAGING_VITE_CONVEX_URL }}
STAGING_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 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"
- name: Create or refresh Railway preview environment
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null
.github/scripts/railway-retry.sh railway environment new "${{ steps.meta.outputs.environment }}" \
--duplicate "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null || true
- name: Configure staging-safe preview defaults
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ steps.meta.outputs.environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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" \
ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \
WEB_ALLOWED_ORIGINS="https://*.up.railway.app,$STAGING_APP_URL" \
VITE_CONVEX_URL="$STAGING_VITE_CONVEX_URL" \
CONVEX_HTTP_URL="$STAGING_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 railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ steps.meta.outputs.environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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 staging 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_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }}
steps:
# Checkout is required so `.github/scripts/railway-retry.sh` is on disk.
- uses: actions/checkout@v4
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Delete Railway preview environment
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null
.github/scripts/railway-retry.sh railway environment 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_STAGING_ENVIRONMENT: ${{ vars.RAILWAY_STAGING_ENVIRONMENT }}
steps:
# Checkout is required so `.github/scripts/railway-retry.sh` is on disk.
- uses: actions/checkout@v4
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Delete Railway backend-PR preview environment
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
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "$RAILWAY_STAGING_ENVIRONMENT" \
--json >/dev/null
.github/scripts/railway-retry.sh railway environment delete "${RAILWAY_BACKEND_PR_ENV_PREFIX}${BACKEND_PR_NUMBER}" \
--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_VITE_CONVEX_URL: ${{ vars.STAGING_VITE_CONVEX_URL }}
STAGING_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
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 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=$STAGING_VITE_CONVEX_URL" >> "$GITHUB_OUTPUT"
echo "convex_http_url=$STAGING_CONVEX_HTTP_URL" >> "$GITHUB_OUTPUT"
echo "backend_mode=staging fallback" >> "$GITHUB_OUTPUT"
fi
- name: Update preview environment variables
run: |
.github/scripts/railway-retry.sh railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ github.event.client_payload.inspector_environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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_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 railway link \
--project "$RAILWAY_PROJECT_ID" \
--environment "${{ github.event.client_payload.inspector_environment }}" \
--service "$RAILWAY_INSPECTOR_SERVICE" \
--json >/dev/null
.github/scripts/railway-retry.sh railway variable set \
-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:
CONVEX_HTTP: ${{ steps.backend_target.outputs.convex_http_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 (staging 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,
});