diff --git a/.github/actions/upload-source-maps/action.yml b/.github/actions/upload-source-maps/action.yml new file mode 100644 index 00000000..73da05c4 --- /dev/null +++ b/.github/actions/upload-source-maps/action.yml @@ -0,0 +1,114 @@ +name: 'Upload Source Maps' +description: 'Uploads source maps to Telemetry-Tracker on release' +inputs: + session_cookie: + description: 'Dashboard session cookie for authentication' + required: true + project_id: + description: 'Telemetry-Tracker Project ID (X-Project-Id)' + required: true + release: + description: 'Release version/tag' + required: true + app: + description: 'Application name' + required: true + artifact_path: + description: 'Path to look for source map files recursively' + required: false + default: './dist' + base_url: + description: 'Base URL for your deployed JS bundles (e.g., https://example.com)' + required: true + +runs: + using: 'composite' + steps: + - name: Upload maps via JSON API + shell: bash + run: | + node -e " + const fs = require('fs'); + const path = require('path'); + const http = require('https'); + + function findFiles(dir) { + let results = []; + if (!fs.existsSync(dir)) return results; + const list = fs.readdirSync(dir); + list.forEach(file => { + file = path.join(dir, file); + const stat = fs.statSync(file); + if (stat && stat.isDirectory()) results = results.concat(findFiles(file)); + else if (file.endsWith('.map')) results.push(file); + }); + return results; + } + + function uploadFile(filePath) { + return new Promise((resolve, reject) => { + const content = fs.readFileSync(filePath, 'utf8'); + const artifactPath = '${{ inputs.artifact_path }}'; + // 1. Get path relative to artifact_path + const resolvedArtifactPath = path.resolve(artifactPath); + const resolvedFilePath = path.resolve(filePath); + const relativePath = path.relative(resolvedArtifactPath, resolvedFilePath); + // 2. Remove .map to get the original JS path + const jsPath = relativePath.replace(/\.map$/, ''); + // 3. Build the full URL + const bundleUrl = \`\${{ inputs.base_url }}/\${jsPath}\`; + + const payload = JSON.stringify({ + app: '${{ inputs.app }}', + release: '${{ inputs.release }}', + bundle_url: bundleUrl, + content: JSON.parse(content) + }); + + const options = { + hostname: 'api.telemetry-tracker.com', + path: '/api/project/source-maps', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Project-Id': '${{ inputs.project_id }}', + 'Cookie': '${{ inputs.session_cookie }}', + 'Content-Length': Buffer.byteLength(payload) + } + }; + + const req = http.request(options, (res) => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + if (res.statusCode >= 400) { + reject(new Error(\`Failed to upload \${path.basename(filePath)}: \${res.statusCode} - \${body}\`)); + } else { + console.log(\`Successfully uploaded \${path.basename(filePath)}\`); + resolve(); + } + }); + }); + + req.on('error', (e) => reject(e)); + req.write(payload); + req.end(); + }); + } + + (async () => { + try { + const files = findFiles('${{ inputs.artifact_path }}'); + if (files.length === 0) { + console.log('No source maps found to upload.'); + return; + } + for (const file of files) { + await uploadFile(file); + } + } catch (err) { + console.error(err.message); + process.exit(1); + } + })(); + " diff --git a/.github/workflows/release-email.yml b/.github/workflows/release-email.yml index 267e2aae..e2d4cdbd 100644 --- a/.github/workflows/release-email.yml +++ b/.github/workflows/release-email.yml @@ -81,12 +81,6 @@ jobs: echo "reason=$(node -e "console.log(JSON.parse(process.argv[1]).reason)" "$BUMP")" >> "$GITHUB_OUTPUT" echo "Bump decision: $BUMP" - - name: Apply database migrations - if: steps.bump.outputs.should_send == 'true' - run: pnpm --filter api exec prisma migrate deploy - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - - name: Send product update email if: steps.bump.outputs.should_send == 'true' working-directory: apps/api diff --git a/docs/MARKETING-EMAIL.md b/docs/MARKETING-EMAIL.md index 1343cc22..69651793 100644 --- a/docs/MARKETING-EMAIL.md +++ b/docs/MARKETING-EMAIL.md @@ -56,19 +56,20 @@ Delivery is recorded in `MarketingReleaseEmailSend` (one row per subscriber per Pushing a semver tag `vX.Y.Z` to GitHub triggers **Release product email** when **X** (major) or **Y** (minor) increases vs the previous tag; **Z**-only (patch/hotfix) tags are skipped: 1. Resolves the previous semver tag and compares **X** and **Y** (Z-only → skip). -2. Runs `prisma migrate deploy` against production (applies pending migrations from the tagged commit, including `MarketingReleaseEmailSend`). -3. Runs `send-release-email.ts --version=X.Y.Z --previous-version=…` with repository secrets. +2. Runs `send-release-email.ts --version=X.Y.Z --previous-version=…` with repository secrets. -Required GitHub repository secrets (production): +Apply production migrations **before tagging** ([RELEASE.md](./RELEASE.md#3-database-migrations-production)); this workflow does not run `prisma migrate deploy` (GitHub Actions cannot reach Railway private `*.railway.internal` URLs). + +Required GitHub secrets in the **`production`** environment: | Secret | Purpose | |--------|---------| -| `DATABASE_URL` | Production Postgres (`MarketingSubscriber`) | +| `DATABASE_URL` | Production Postgres **public** URL (Railway TCP proxy / public host — not `postgres.railway.internal`) | | `RESEND_API_KEY` | Resend API | | `TELEMETRY_EMAIL_FROM` | From address | | `TELEMETRY_DASHBOARD_ORIGIN` | Unsubscribe / docs links (e.g. `https://telemetry-tracker.com`) | -Store these in the GitHub **`production`** environment (the workflow job uses `environment: production`). To retry a send without re-tagging, use **Actions → Release product email → Run workflow** with `version` and `previous_version`. +Store these in the GitHub **`production`** environment. To retry a send without re-tagging, use **Actions → Release product email → Run workflow** with `version` and `previous_version`. The workflow runs **after** the tag is pushed — finalize `CHANGELOG.md` on `main` **before** tagging so the email body matches the release. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 6bf97661..f8b4fbd8 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -138,7 +138,7 @@ Always document **migrations**, **new env vars**, and **breaking changes** in CH 1. **Finalize CHANGELOG** — rename `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD`. Prefer doing this in the **`develop` → `main`** release PR so `develop` and `main` stay aligned; if you commit on `main` after promotion, you **must** sync `develop` in step 9. 2. **Deploy** — Railway rebuilds `main` automatically when the release PR merges; see [Deploy runbook](#deploy-runbook-railway). -3. **Production DB** — run [migrations](#3-database-migrations-production) **before tagging** on MINOR (Y) / MAJOR (X) releases (the [Release product email](../.github/workflows/release-email.yml) workflow also runs `prisma migrate deploy` immediately before send, but apply migrations here as part of the normal release gate). +3. **Production DB** — run [migrations](#3-database-migrations-production) **before tagging** on MINOR (Y) / MAJOR (X) releases. The release email workflow reads production Postgres but does not migrate (use the public `DATABASE_URL` in GitHub secrets — see [MARKETING-EMAIL.md](./MARKETING-EMAIL.md#automated-send-minor--major-tags)). 4. **Post-deploy** — [verification](#post-deploy-verification). 5. **Tag** — after deploy and migrations are green (tag push triggers the product update email workflow for MINOR/MAJOR — **X** or **Y** bump, not Z-only hotfixes): ```bash @@ -198,7 +198,7 @@ On push and pull requests to **`develop`** and **`main`**, CI runs ([`.github/wo ### 3. Database migrations (production) -CI does **not** migrate your production database during normal builds. Apply migrations after API deploy and **before pushing a MINOR (Y) / MAJOR (X) tag** (see [On `main` after promotion](#on-main-after-promotion)). The [Release product email](../.github/workflows/release-email.yml) workflow also runs `prisma migrate deploy` immediately before sending so delivery records can be written. +CI does **not** migrate your production database during normal builds. Apply migrations after API deploy and **before pushing a MINOR (Y) / MAJOR (X) tag** (see [On `main` after promotion](#on-main-after-promotion)). The [Release product email](../.github/workflows/release-email.yml) workflow connects to production Postgres for the send ledger only — use Railway’s **public** database URL in GitHub secrets, not `postgres.railway.internal`. ```bash DATABASE_URL="postgresql://..." pnpm --filter api exec prisma migrate deploy diff --git a/docs/source-maps.md b/docs/source-maps.md index aff6138c..4ba94d77 100644 --- a/docs/source-maps.md +++ b/docs/source-maps.md @@ -145,3 +145,29 @@ Symbolication is display-only; grouping fingerprints stay on raw minified stacks - [sdk-core.md](./sdk-core.md) — `trackError`, `release` in payloads - [ARCHITECTURE.md](./ARCHITECTURE.md) — ingest pipeline - [ENTITLEMENTS.md](./ENTITLEMENTS.md) — retention by plan tier + +## GitHub Action Workflow Example + +You can automatically upload source maps on every release using our GitHub Action: + +```yaml +name: Release Configuration +on: + release: + types: [published] + +jobs: + upload-maps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Upload Source Maps + uses: ./.github/actions/upload-source-maps + with: + session_cookie: ${{ secrets.TT_SESSION_COOKIE }} + project_id: "your-project-uuid-here" + release: ${{ github.event.release.tag_name }} + app: "my-telemetry-app" + artifact_path: "./dist" + base_url: "https://example.com" +```