Repository navigation
Merge pull request #770 from CodingCatDev/feat/guest-portal-portable-… #137
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: Deploy Sanity Studio | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [dev, main] | |
| paths: | |
| - "apps/sanity/**" | |
| - "apps/ops/**" | |
| - "sanity.blueprint.ts" | |
| - "package.json" | |
| - "pnpm-lock.yaml" | |
| - ".github/workflows/deploy.yml" | |
| jobs: | |
| deploy-dev: | |
| if: github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| environment: dev | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| cache: "pnpm" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Export Sanity production dataset backup before deploy | |
| run: | | |
| curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz | |
| - name: Upload Sanity production dataset backup artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: sanity-production-predeploy-backup-dev-${{ github.sha }} | |
| path: /tmp/sanity-production-backup.ndjson.gz | |
| retention-days: 30 | |
| - name: Sync Enriched Guest Socials to Sanity Dataset | |
| run: node apps/sanity/scripts/sync-guest-socials.mjs | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| - name: Deploy Sanity Studio | |
| run: npx sanity deploy -y | |
| working-directory: apps/sanity | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_STUDIO_HOSTNAME: ${{ vars.SANITY_STUDIO_HOSTNAME }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| - name: Deploy Sanity Ops App (Sanity App SDK) | |
| working-directory: apps/ops | |
| run: | | |
| ORG_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2021-06-07/projects/${{ vars.SANITY_STUDIO_PROJECT_ID }}', { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (!d.organizationId) throw new Error('No organizationId in project response'); | |
| console.log(d.organizationId); | |
| }) | |
| .catch(err => { console.error(err); process.exit(1); }); | |
| ") | |
| export SANITY_ORGANIZATION_ID="$ORG_ID" | |
| if [ "${{ vars.SANITY_STUDIO_DATASET }}" = "dev" ]; then | |
| APP_TITLE="CodingCat Ops & Revenue OS (Dev)" | |
| else | |
| APP_TITLE="CodingCat Ops & Revenue OS (Production)" | |
| fi | |
| export APP_TITLE | |
| EXISTING_APP_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2024-08-01/user-applications?appType=coreApp&organizationId=' + process.env.SANITY_ORGANIZATION_ID, { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.ok ? r.json() : []) | |
| .then(apps => { | |
| const list = Array.isArray(apps) ? apps : []; | |
| const targetTitle = process.env.APP_TITLE; | |
| const isDev = process.env.SANITY_APP_DATASET === 'dev'; | |
| const match = list.find(a => | |
| a.title === targetTitle || | |
| (isDev && (a.id === 'yiugiba2sjnhpehqmky6nulc' || a.title === 'CodingCat Ops & Revenue OS')) | |
| ); | |
| if (match && match.id) console.log(match.id); | |
| }) | |
| .catch(() => {}); | |
| ") | |
| if [ -n "$EXISTING_APP_ID" ]; then | |
| echo "Found existing $APP_TITLE app: $EXISTING_APP_ID" | |
| SANITY_OPS_APP_ID="$EXISTING_APP_ID" npx sanity deploy -y | |
| else | |
| echo "Creating and deploying new $APP_TITLE app in organization $ORG_ID..." | |
| npx sanity deploy -y --create --title "$APP_TITLE" | |
| fi | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_APP_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_APP_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| - name: Promote Stack to Organization Scope | |
| run: | | |
| mkdir -p .sanity | |
| echo '{"projectId":"${{ vars.SANITY_STUDIO_PROJECT_ID }}"}' > .sanity/blueprint.config.json | |
| pnpm exec sanity blueprints promote --project-id ${{ vars.SANITY_STUDIO_PROJECT_ID }} --stack ${{ vars.SANITY_STUDIO_DATASET }} --force --verbose || true | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| - name: Deploy Sanity Blueprints & Poll Synchronously Until Completed | |
| run: | | |
| ORG_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2021-06-07/projects/${{ vars.SANITY_STUDIO_PROJECT_ID }}', { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (!d.organizationId) throw new Error('No organizationId in project response: ' + JSON.stringify(d)); | |
| console.log(d.organizationId); | |
| }) | |
| .catch(err => { console.error(err); process.exit(1); }); | |
| ") | |
| echo "Resolved Organization ID: $ORG_ID" | |
| mkdir -p .sanity | |
| echo "{\"organizationId\":\"$ORG_ID\"}" > .sanity/blueprint.config.json | |
| echo "=== Previous Stack Info & Logs ===" | |
| pnpm exec sanity blueprints info --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| pnpm exec sanity blueprints logs --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| echo "=== Blueprint Deployment Plan ===" | |
| pnpm exec sanity blueprints plan --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| echo "=== Starting Blueprint Deployment (--verbose) ===" | |
| pnpm exec sanity blueprints deploy --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --no-wait --verbose | |
| echo "=== Synchronously Polling Blueprint Stack Operation Until Terminal State ===" | |
| ORG_ID="$ORG_ID" STACK_NAME="${{ vars.SANITY_STUDIO_DATASET }}" node -e " | |
| const token = process.env.SANITY_AUTH_TOKEN; | |
| const orgId = process.env.ORG_ID; | |
| const stackName = process.env.STACK_NAME; | |
| const headers = { | |
| Accept: 'application/json', | |
| Authorization: 'Bearer ' + token, | |
| 'X-Sanity-Scope-Type': 'organization', | |
| 'X-Sanity-Scope-Id': orgId, | |
| }; | |
| (async () => { | |
| const stacksRes = await fetch('https://api.sanity.io/v2026-06-15/blueprints/stacks', { headers }); | |
| if (!stacksRes.ok) throw new Error('Failed to list stacks: HTTP ' + stacksRes.status); | |
| const stacks = await stacksRes.json(); | |
| const stack = stacks.find(s => s.name === stackName || s.id === stackName); | |
| if (!stack) throw new Error('Stack not found: ' + stackName); | |
| console.log('Polling Stack:', stack.name, '(' + stack.id + ')'); | |
| const deadline = Date.now() + 5 * 60 * 1000; | |
| while (Date.now() < deadline) { | |
| const res = await fetch('https://api.sanity.io/v2026-06-15/blueprints/stacks/' + stack.id, { headers }); | |
| if (!res.ok) throw new Error('Failed to fetch stack ' + stack.id + ': HTTP ' + res.status); | |
| const data = await res.json(); | |
| const op = data.recentOperation; | |
| const status = op?.status || 'NONE'; | |
| console.log('[' + new Date().toISOString() + '] Operation ' + (op?.id || 'n/a') + ' status: ' + status); | |
| if (status === 'COMPLETED' || status === 'NONE') { | |
| console.log('✔ Blueprint Stack operation settled with status:', status); | |
| process.exit(0); | |
| } | |
| if (status === 'FAILED' || status === 'CANCELLED') { | |
| console.error('✖ Blueprint Stack operation failed:', JSON.stringify(op, null, 2)); | |
| const logsRes = await fetch('https://api.sanity.io/v2026-06-15/blueprints/logs?stackId=' + stack.id + (op?.id ? '&operationId=' + op.id : ''), { headers }); | |
| if (logsRes.ok) { | |
| const logs = await logsRes.json(); | |
| console.error('Operation logs:', JSON.stringify(logs, null, 2)); | |
| } | |
| process.exit(1); | |
| } | |
| await new Promise(r => setTimeout(r, 4000)); | |
| } | |
| console.error('✖ Timed out waiting 5 minutes for Blueprint Stack operation to complete'); | |
| process.exit(1); | |
| })().catch(err => { console.error(err); process.exit(1); }); | |
| " | |
| echo "=== Verified Post-Deploy Stack Info & Logs (--verbose) ===" | |
| pnpm exec sanity blueprints info --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose | |
| pnpm exec sanity blueprints logs --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --limit 50 --verbose || true | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} | |
| PRIVATE_DEVTO: ${{ secrets.PRIVATE_DEVTO }} | |
| PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }} | |
| deploy-production: | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| environment: production | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| cache: "pnpm" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Export Sanity production dataset backup before deploy | |
| run: | | |
| curl -f -sS "https://hfh83o0w.api.sanity.io/v2021-06-07/data/export/production" | gzip > /tmp/sanity-production-backup.ndjson.gz | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| - name: Upload Sanity production dataset backup artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: sanity-production-predeploy-backup-${{ github.sha }} | |
| path: /tmp/sanity-production-backup.ndjson.gz | |
| retention-days: 30 | |
| - name: Sync Enriched Guest Socials to Sanity Dataset | |
| run: node apps/sanity/scripts/sync-guest-socials.mjs | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| - name: Deploy Sanity Studio | |
| run: npx sanity deploy -y | |
| working-directory: apps/sanity | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_STUDIO_HOSTNAME: ${{ vars.SANITY_STUDIO_HOSTNAME }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| - name: Deploy Sanity Ops App (Sanity App SDK) | |
| working-directory: apps/ops | |
| run: | | |
| ORG_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2021-06-07/projects/${{ vars.SANITY_STUDIO_PROJECT_ID }}', { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (!d.organizationId) throw new Error('No organizationId in project response'); | |
| console.log(d.organizationId); | |
| }) | |
| .catch(err => { console.error(err); process.exit(1); }); | |
| ") | |
| export SANITY_ORGANIZATION_ID="$ORG_ID" | |
| if [ "${{ vars.SANITY_STUDIO_DATASET }}" = "dev" ]; then | |
| APP_TITLE="CodingCat Ops & Revenue OS (Dev)" | |
| else | |
| APP_TITLE="CodingCat Ops & Revenue OS (Production)" | |
| fi | |
| export APP_TITLE | |
| EXISTING_APP_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2024-08-01/user-applications?appType=coreApp&organizationId=' + process.env.SANITY_ORGANIZATION_ID, { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.ok ? r.json() : []) | |
| .then(apps => { | |
| const list = Array.isArray(apps) ? apps : []; | |
| const targetTitle = process.env.APP_TITLE; | |
| const isDev = process.env.SANITY_APP_DATASET === 'dev'; | |
| const match = list.find(a => | |
| a.title === targetTitle || | |
| (isDev && (a.id === 'yiugiba2sjnhpehqmky6nulc' || a.title === 'CodingCat Ops & Revenue OS')) | |
| ); | |
| if (match && match.id) console.log(match.id); | |
| }) | |
| .catch(() => {}); | |
| ") | |
| if [ -n "$EXISTING_APP_ID" ]; then | |
| echo "Found existing $APP_TITLE app: $EXISTING_APP_ID" | |
| SANITY_OPS_APP_ID="$EXISTING_APP_ID" npx sanity deploy -y | |
| else | |
| echo "Creating and deploying new $APP_TITLE app in organization $ORG_ID..." | |
| npx sanity deploy -y --create --title "$APP_TITLE" | |
| fi | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_APP_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| SANITY_APP_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| - name: Promote Stack to Organization Scope | |
| run: | | |
| mkdir -p .sanity | |
| echo '{"projectId":"${{ vars.SANITY_STUDIO_PROJECT_ID }}"}' > .sanity/blueprint.config.json | |
| pnpm exec sanity blueprints promote --project-id ${{ vars.SANITY_STUDIO_PROJECT_ID }} --stack ${{ vars.SANITY_STUDIO_DATASET }} --force --verbose || true | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| - name: Deploy Sanity Blueprints & Poll Synchronously Until Completed | |
| run: | | |
| ORG_ID=$(node -e " | |
| fetch('https://api.sanity.io/v2021-06-07/projects/${{ vars.SANITY_STUDIO_PROJECT_ID }}', { | |
| headers: { Authorization: 'Bearer ' + process.env.SANITY_AUTH_TOKEN } | |
| }) | |
| .then(r => r.json()) | |
| .then(d => { | |
| if (!d.organizationId) throw new Error('No organizationId in project response: ' + JSON.stringify(d)); | |
| console.log(d.organizationId); | |
| }) | |
| .catch(err => { console.error(err); process.exit(1); }); | |
| ") | |
| echo "Resolved Organization ID: $ORG_ID" | |
| mkdir -p .sanity | |
| echo "{\"organizationId\":\"$ORG_ID\"}" > .sanity/blueprint.config.json | |
| echo "=== Previous Stack Info & Logs ===" | |
| pnpm exec sanity blueprints info --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| pnpm exec sanity blueprints logs --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| echo "=== Blueprint Deployment Plan ===" | |
| pnpm exec sanity blueprints plan --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose || true | |
| echo "=== Starting Blueprint Deployment (--verbose) ===" | |
| pnpm exec sanity blueprints deploy --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --no-wait --verbose | |
| echo "=== Synchronously Polling Blueprint Stack Operation Until Terminal State ===" | |
| ORG_ID="$ORG_ID" STACK_NAME="${{ vars.SANITY_STUDIO_DATASET }}" node -e " | |
| const token = process.env.SANITY_AUTH_TOKEN; | |
| const orgId = process.env.ORG_ID; | |
| const stackName = process.env.STACK_NAME; | |
| const headers = { | |
| Accept: 'application/json', | |
| Authorization: 'Bearer ' + token, | |
| 'X-Sanity-Scope-Type': 'organization', | |
| 'X-Sanity-Scope-Id': orgId, | |
| }; | |
| (async () => { | |
| const stacksRes = await fetch('https://api.sanity.io/v2026-06-15/blueprints/stacks', { headers }); | |
| if (!stacksRes.ok) throw new Error('Failed to list stacks: HTTP ' + stacksRes.status); | |
| const stacks = await stacksRes.json(); | |
| const stack = stacks.find(s => s.name === stackName || s.id === stackName); | |
| if (!stack) throw new Error('Stack not found: ' + stackName); | |
| console.log('Polling Stack:', stack.name, '(' + stack.id + ')'); | |
| const deadline = Date.now() + 5 * 60 * 1000; | |
| while (Date.now() < deadline) { | |
| const res = await fetch('https://api.sanity.io/v2026-06-15/blueprints/stacks/' + stack.id, { headers }); | |
| if (!res.ok) throw new Error('Failed to fetch stack ' + stack.id + ': HTTP ' + res.status); | |
| const data = await res.json(); | |
| const op = data.recentOperation; | |
| const status = op?.status || 'NONE'; | |
| console.log('[' + new Date().toISOString() + '] Operation ' + (op?.id || 'n/a') + ' status: ' + status); | |
| if (status === 'COMPLETED' || status === 'NONE') { | |
| console.log('✔ Blueprint Stack operation settled with status:', status); | |
| process.exit(0); | |
| } | |
| if (status === 'FAILED' || status === 'CANCELLED') { | |
| console.error('✖ Blueprint Stack operation failed:', JSON.stringify(op, null, 2)); | |
| const logsRes = await fetch('https://api.sanity.io/v2026-06-15/blueprints/logs?stackId=' + stack.id + (op?.id ? '&operationId=' + op.id : ''), { headers }); | |
| if (logsRes.ok) { | |
| const logs = await logsRes.json(); | |
| console.error('Operation logs:', JSON.stringify(logs, null, 2)); | |
| } | |
| process.exit(1); | |
| } | |
| await new Promise(r => setTimeout(r, 4000)); | |
| } | |
| console.error('✖ Timed out waiting 5 minutes for Blueprint Stack operation to complete'); | |
| process.exit(1); | |
| })().catch(err => { console.error(err); process.exit(1); }); | |
| " | |
| echo "=== Verified Post-Deploy Stack Info & Logs (--verbose) ===" | |
| pnpm exec sanity blueprints info --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --verbose | |
| pnpm exec sanity blueprints logs --organization-id "$ORG_ID" --stack ${{ vars.SANITY_STUDIO_DATASET }} --limit 50 --verbose || true | |
| env: | |
| SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} | |
| SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} | |
| SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} | |
| SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} | |
| YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} | |
| PRIVATE_DEVTO: ${{ secrets.PRIVATE_DEVTO }} | |
| PRIVATE_HASHNODE: ${{ secrets.PRIVATE_HASHNODE }} |