diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index e62a45fc6a..c5f4e2a477 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -35,17 +35,23 @@ jobs: id: version env: INPUT_VERSION: ${{ github.event.inputs.version }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "v=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + VERSION="$INPUT_VERSION" else - echo "v=${GITHUB_REF_NAME#selfhost-v}" >> "$GITHUB_OUTPUT" + case "$REF_NAME" in + selfhost-v*) VERSION="${REF_NAME#selfhost-v}" ;; + *) echo "expected a selfhost-v tag, got $REF_NAME" >&2; exit 1 ;; + esac fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${INPUT_VERSION}" - else - VERSION="${GITHUB_REF_NAME#selfhost-v}" + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "expected semver version X.Y.Z, got $VERSION" >&2 + exit 1 fi + echo "v=${VERSION}" >> "$GITHUB_OUTPUT" echo "release=gittensory-selfhost@${VERSION}" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 @@ -67,16 +73,21 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_URL: ${{ vars.SENTRY_URL }} SENTRY_RELEASE: ${{ steps.version.outputs.release }} + SENTRY_REPOSITORY: ${{ github.repository }} + SENTRY_COMMIT_SHA: ${{ github.sha }} run: | + set -euo pipefail test -n "$SENTRY_AUTH_TOKEN" test -n "$SENTRY_ORG" test -n "$SENTRY_PROJECT" - npx -y @sentry/cli@latest releases new "$SENTRY_RELEASE" - npx -y @sentry/cli@latest releases set-commits "$SENTRY_RELEASE" --auto - npx -y @sentry/cli@latest sourcemaps inject dist + if [ -z "${SENTRY_URL:-}" ]; then unset SENTRY_URL; fi + npx -y @sentry/cli@3.6.0 releases new "$SENTRY_RELEASE" + npx -y @sentry/cli@3.6.0 releases set-commits "$SENTRY_RELEASE" --commit "$SENTRY_REPOSITORY@$SENTRY_COMMIT_SHA" --ignore-missing + npx -y @sentry/cli@3.6.0 sourcemaps inject dist node scripts/validate-selfhost-sourcemap.mjs - npx -y @sentry/cli@latest sourcemaps upload --release="$SENTRY_RELEASE" dist + npx -y @sentry/cli@3.6.0 sourcemaps upload --release="$SENTRY_RELEASE" --validate --wait --strict dist - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -121,8 +132,26 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_URL: ${{ vars.SENTRY_URL }} + SENTRY_RELEASE: ${{ steps.version.outputs.release }} + run: | + set -euo pipefail + if [ -z "${SENTRY_URL:-}" ]; then unset SENTRY_URL; fi + npx -y @sentry/cli@3.6.0 releases finalize "$SENTRY_RELEASE" + + - name: Validate Sentry release + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + SENTRY_URL: ${{ vars.SENTRY_URL }} SENTRY_RELEASE: ${{ steps.version.outputs.release }} - run: npx -y @sentry/cli@latest releases finalize "$SENTRY_RELEASE" + SENTRY_REPOSITORY: ${{ github.repository }} + SENTRY_COMMIT_SHA: ${{ github.sha }} + SENTRY_REQUIRE_COMMITS: "true" + SENTRY_REQUIRE_DEPLOY: "false" + SENTRY_REQUIRE_FINALIZED: "true" + run: node review-enrichment/scripts/validate-sentry-release.mjs - name: GitHub Release if: github.event_name == 'push' diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 184116e9b9..d369d1c18d 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -279,13 +279,15 @@ The maintainer release workflow expects: | Secret `SENTRY_AUTH_TOKEN` | Sentry auth token allowed to create releases and upload source maps | | Variable `SENTRY_ORG` | Sentry organization slug | | Variable `SENTRY_PROJECT` | Sentry project slug | +| Variable `SENTRY_URL` | Optional Sentry API URL; defaults to `https://sentry.io` | | Sentry GitHub integration | Installed for `JSONbored/gittensory`, with the code mapping above | The workflow builds `dist/server.mjs` with `dist/server.mjs.map`, validates the `sourceMappingURL` and embedded `sourcesContent`, injects Sentry debug ids, creates release `gittensory-selfhost@`, associates commits with -`set-commits --auto`, uploads the source maps, and then builds the image from that injected `dist/server.mjs`. -`dist/server.mjs.map` is **not** copied into the runtime image and is not served by the app; it only exists as a -private Sentry release artifact. +the tagged commit, uploads the source maps with Sentry validation/waiting enabled, finalizes the release, and then +validates through the Sentry API that the exact release exists, is finalized, and includes the release commit. It then +builds the image from that injected `dist/server.mjs`. `dist/server.mjs.map` is **not** copied into the runtime image +and is not served by the app; it only exists as a private Sentry release artifact. For a custom image, source maps only work when the deployed JS bundle is the exact post-injection bundle whose map was uploaded. If you build locally and do not upload maps, leave `SENTRY_RELEASE` unset. Events still report to Sentry, @@ -296,9 +298,10 @@ If a new event still shows `/app/dist/server.mjs`: 1. Confirm the event's `release` exactly matches the release that has the uploaded artifact bundle. 2. Confirm the image was built from the injected `dist/server.mjs`, not from a later Docker-internal rebuild. 3. Confirm the Sentry code mapping is `/app` → `.` on branch `main`. -4. Confirm `dist/server.mjs` had `//# sourceMappingURL=server.mjs.map` before upload and the map includes +4. Confirm the release workflow's `Validate Sentry release` step passed for that exact `gittensory-selfhost@`. +5. Confirm `dist/server.mjs` had `//# sourceMappingURL=server.mjs.map` before upload and the map includes `sourcesContent`. -5. Trigger a fresh event after the upload; old events may need reprocessing before they pick up newly uploaded maps. +6. Trigger a fresh event after the upload; old events may need reprocessing before they pick up newly uploaded maps. --- diff --git a/review-enrichment/Dockerfile b/review-enrichment/Dockerfile index fb62962d1e..e06eb5c158 100644 --- a/review-enrichment/Dockerfile +++ b/review-enrichment/Dockerfile @@ -18,6 +18,7 @@ RUN useradd --create-home --uid 10001 rees WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules +COPY --from=build --chown=rees:rees /app/scripts ./scripts COPY --from=build --chown=rees:rees /app/dist ./dist COPY --from=build --chown=rees:rees /app/package.json ./package.json USER rees diff --git a/review-enrichment/README.md b/review-enrichment/README.md index c3b704c475..2fdb5f250e 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -62,8 +62,10 @@ Set these Railway service variables: | `SENTRY_ENVIRONMENT` | Optional; defaults to Railway's environment name, then `production`. | | `SENTRY_TRACES_SAMPLE_RATE` | Optional; defaults to `0`, so errors report without tracing. | | `SENTRY_RELEASE` | Optional override. Only set it when that exact REES bundle is uploaded. | +| `SENTRY_URL` | Optional Sentry API URL; defaults to `https://sentry.io`. | | `SENTRY_REPOSITORY` | Optional; defaults to `JSONbored/gittensory` for commit association. | | `REES_SENTRY_UPLOAD_STRICT` | Optional. Set `true` to fail startup if source-map upload fails. | +| `REES_SENTRY_VALIDATE_RELEASE` | Optional. Set `false` only to disable post-upload release validation. | By default the release id is `gittensory-rees@`, using Railway's Git metadata. The Sentry GitHub code mapping should be: @@ -78,6 +80,11 @@ Do **not** pass `SENTRY_AUTH_TOKEN` as a Docker build arg. Railway deploys this can leak through image metadata. Keeping the upload at runtime means Sentry sees the same `dist/` files that the service executes, without exposing source maps over HTTP. +After upload, startup validates the exact `gittensory-rees@` release through the Sentry API: +the release must exist, be finalized, include the deployed commit, and include the Railway deploy id/environment. If +`REES_SENTRY_UPLOAD_STRICT=true`, a failed upload or failed validation stops the Railway deployment; otherwise it logs a +`rees_sentry_sourcemap_upload_failed` warning so the problem is visible without blocking startup. + Analyzer failures are still fail-open: the `/v1/enrich` response marks the analyzer as `degraded` and returns a partial brief. When Sentry is enabled, those degradations are captured as `rees_analyzer_degraded` events with tags for `analyzer`, `repo`, `pullNumber`, `headSha`, `release`, `environment`, and `timeoutMs`. Use those tags to spot a broken @@ -88,5 +95,6 @@ If Sentry still shows frames such as `/app/dist/server.js`, check: 1. The event's `release` is `gittensory-rees@` or your exact `SENTRY_RELEASE` override. 2. The Sentry release has an artifact bundle uploaded for the REES project. 3. Railway has `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` set on the REES service. -4. The Sentry code mapping is `/app` → `review-enrichment` on branch `main`. -5. `npm --prefix review-enrichment run validate:sourcemaps` passes locally. +4. Startup logs include `sentry_release_validation_complete` for the same release id and Railway deployment id. +5. The Sentry code mapping is `/app` → `review-enrichment` on branch `main`. +6. `npm --prefix review-enrichment run validate:sourcemaps` passes locally. diff --git a/review-enrichment/scripts/validate-sentry-release.mjs b/review-enrichment/scripts/validate-sentry-release.mjs new file mode 100644 index 0000000000..6391fdfbf0 --- /dev/null +++ b/review-enrichment/scripts/validate-sentry-release.mjs @@ -0,0 +1,288 @@ +import { pathToFileURL } from "node:url"; + +const DEFAULT_SENTRY_URL = "https://sentry.io"; + +const TRUE_VALUE = /^(1|true|yes|on)$/i; +const FALSE_VALUE = /^(0|false|no|off)$/i; + +export class SentryReleaseValidationError extends Error { + constructor(message, failures = []) { + super(message); + this.name = "SentryReleaseValidationError"; + this.failures = failures; + } +} + +function nonBlank(value) { + const text = typeof value === "string" ? value.trim() : undefined; + return text ? text : undefined; +} + +function boolEnv(value, fallback) { + const text = nonBlank(value); + if (!text) return fallback; + if (TRUE_VALUE.test(text)) return true; + if (FALSE_VALUE.test(text)) return false; + return fallback; +} + +function apiBaseUrl(value) { + return (nonBlank(value) ?? DEFAULT_SENTRY_URL).replace(/\/+$/, ""); +} + +export function loadSentryReleaseValidationConfig(env = process.env) { + return { + authToken: nonBlank(env.SENTRY_AUTH_TOKEN), + org: nonBlank(env.SENTRY_ORG), + project: nonBlank(env.SENTRY_PROJECT), + release: nonBlank(env.SENTRY_RELEASE), + baseUrl: apiBaseUrl(env.SENTRY_URL), + expectedCommitSha: + nonBlank(env.SENTRY_EXPECT_COMMIT_SHA) ?? + nonBlank(env.SENTRY_COMMIT_SHA) ?? + nonBlank(env.RAILWAY_GIT_COMMIT_SHA), + expectedDeployName: nonBlank(env.SENTRY_DEPLOY_NAME) ?? nonBlank(env.RAILWAY_DEPLOYMENT_ID), + expectedEnvironment: + nonBlank(env.SENTRY_ENVIRONMENT) ?? + nonBlank(env.RAILWAY_ENVIRONMENT_NAME) ?? + "production", + requireCommits: boolEnv(env.SENTRY_REQUIRE_COMMITS, true), + requireDeploy: boolEnv(env.SENTRY_REQUIRE_DEPLOY, false), + requireFinalized: boolEnv(env.SENTRY_REQUIRE_FINALIZED, true), + requireReleaseFiles: boolEnv(env.SENTRY_REQUIRE_RELEASE_FILES, false), + }; +} + +function requireConfig(config) { + const missing = [ + ["SENTRY_AUTH_TOKEN", config.authToken], + ["SENTRY_ORG", config.org], + ["SENTRY_PROJECT", config.project], + ["SENTRY_RELEASE", config.release], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0) { + throw new SentryReleaseValidationError("missing Sentry release validation config", [ + `missing ${missing.join(", ")}`, + ]); + } +} + +function apiUrl(config, segments) { + const encoded = segments.map((segment) => encodeURIComponent(segment)).join("/"); + return `${config.baseUrl}/api/0/${encoded}/`; +} + +async function sentryJson(config, segments, fetchImpl) { + const response = await fetchImpl(apiUrl(config, segments), { + headers: { + accept: "application/json", + authorization: `Bearer ${config.authToken}`, + }, + }); + if (!response.ok) { + let message = response.statusText; + try { + const body = await response.json(); + message = body?.detail ?? body?.error ?? body?.message ?? message; + } catch { + /* Keep the status text when the body is not JSON. */ + } + throw new SentryReleaseValidationError("Sentry API request failed", [ + `${segments.join("/")} returned HTTP ${response.status}${message ? ` (${message})` : ""}`, + ]); + } + return response.json(); +} + +function asArray(value) { + if (Array.isArray(value)) return value; + if (value && Array.isArray(value.data)) return value.data; + return []; +} + +function stringField(value, keys) { + if (!value || typeof value !== "object") return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === "string" && field.trim()) return field.trim(); + } + return undefined; +} + +function releaseProjects(release) { + return asArray(release?.projects) + .map((project) => stringField(project, ["slug", "name"])) + .filter(Boolean); +} + +function isFinalized(release) { + return Boolean(stringField(release, ["dateReleased", "released", "releaseDate"])); +} + +function commitIdsFrom(value, results = []) { + if (!value || typeof value !== "object") return results; + for (const key of ["id", "sha", "commitId", "shortId"]) { + const id = value[key]; + if (typeof id === "string" && id.trim()) results.push(id.trim()); + } + for (const key of ["commit", "lastCommit", "previousCommit"]) commitIdsFrom(value[key], results); + return results; +} + +function commitMatches(expected, candidates) { + const wanted = expected.toLowerCase(); + return candidates.some((candidate) => { + const got = candidate.toLowerCase(); + return got === wanted || got.startsWith(wanted) || wanted.startsWith(got); + }); +} + +function deployField(deploy, keys) { + if (!deploy || typeof deploy !== "object") return undefined; + for (const key of keys) { + const value = deploy[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object") { + const nested = stringField(value, ["name", "slug", "id"]); + if (nested) return nested; + } + } + return undefined; +} + +function deployMatches(deploy, config) { + const name = deployField(deploy, ["name", "id"]); + const environment = deployField(deploy, ["environment", "env"]); + if (config.expectedDeployName && name !== config.expectedDeployName) return false; + if (config.expectedEnvironment && environment !== config.expectedEnvironment) return false; + return true; +} + +function log(event, fields = {}) { + console.log(JSON.stringify({ event, ...fields })); +} + +function logError(event, fields = {}) { + console.error(JSON.stringify({ level: "error", event, ...fields })); +} + +export async function validateSentryRelease(env = process.env, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== "function") { + throw new SentryReleaseValidationError("fetch is unavailable", ["Node 20+ fetch support is required"]); + } + + const config = loadSentryReleaseValidationConfig(env); + requireConfig(config); + + const release = await sentryJson( + config, + ["organizations", config.org, "releases", config.release], + fetchImpl, + ); + + const failures = []; + if (release?.version && release.version !== config.release) { + failures.push(`release version mismatch: expected ${config.release}, got ${release.version}`); + } + + const projects = releaseProjects(release); + if (projects.length > 0 && !projects.includes(config.project)) { + failures.push(`release is not associated with Sentry project ${config.project}`); + } + + if (config.requireFinalized && !isFinalized(release)) { + failures.push("release is not finalized"); + } + + let commits = []; + if (config.requireCommits || config.expectedCommitSha) { + commits = asArray( + await sentryJson( + config, + ["organizations", config.org, "releases", config.release, "commits"], + fetchImpl, + ), + ); + const commitCount = + typeof release?.commitCount === "number" ? release.commitCount : commits.length; + const commitIds = [ + ...commitIdsFrom(release), + ...commits.flatMap((commit) => commitIdsFrom(commit)), + ]; + if (config.requireCommits && commitCount <= 0 && commitIds.length === 0) { + failures.push("release has no associated commits"); + } + if (config.expectedCommitSha && !commitMatches(config.expectedCommitSha, commitIds)) { + failures.push(`release commits do not include expected commit ${config.expectedCommitSha}`); + } + } + + let deploys = []; + if (config.requireDeploy) { + deploys = asArray( + await sentryJson( + config, + ["organizations", config.org, "releases", config.release, "deploys"], + fetchImpl, + ), + ); + const deployCount = + typeof release?.deployCount === "number" ? release.deployCount : deploys.length; + const releaseDeploy = release?.lastDeploy ? [release.lastDeploy] : []; + const allDeploys = [...deploys, ...releaseDeploy]; + if (deployCount <= 0 && allDeploys.length === 0) { + failures.push("release has no associated deploys"); + } else if (!allDeploys.some((deploy) => deployMatches(deploy, config))) { + failures.push( + `release deploys do not include ${config.expectedEnvironment}/${config.expectedDeployName ?? "any"}`, + ); + } + } + + let releaseFiles = []; + if (config.requireReleaseFiles) { + releaseFiles = asArray( + await sentryJson( + config, + ["projects", config.org, config.project, "releases", config.release, "files"], + fetchImpl, + ), + ); + if (releaseFiles.length === 0) { + failures.push("release has no release files"); + } + } + + if (failures.length > 0) { + throw new SentryReleaseValidationError("Sentry release validation failed", failures); + } + + return { + release: config.release, + project: config.project, + finalized: isFinalized(release), + commitCount: typeof release?.commitCount === "number" ? release.commitCount : commits.length, + deployCount: typeof release?.deployCount === "number" ? release.deployCount : deploys.length, + releaseFileCount: releaseFiles.length, + }; +} + +async function main() { + try { + const result = await validateSentryRelease(); + log("sentry_release_validation_complete", result); + } catch (error) { + const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; + logError("sentry_release_validation_failed", { + release: nonBlank(process.env.SENTRY_RELEASE), + failures, + }); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/review-enrichment/src/upload-sourcemaps.ts b/review-enrichment/src/upload-sourcemaps.ts index 18e3dfc019..c715652cb7 100644 --- a/review-enrichment/src/upload-sourcemaps.ts +++ b/review-enrichment/src/upload-sourcemaps.ts @@ -98,6 +98,34 @@ function runSentry(args: string[], options: RunOptions = {}): void { throw new Error(`sentry-cli ${args.join(" ")} failed (${result.status}): ${output.slice(0, 500)}`); } +function shouldValidateRelease(): boolean { + return !/^(0|false|no|off)$/i.test(process.env.REES_SENTRY_VALIDATE_RELEASE ?? ""); +} + +function runReleaseValidation(release: string, fields: { sha?: string; deployName: string; environment: string }): void { + if (!shouldValidateRelease()) return; + const result = spawnSync(process.execPath, ["scripts/validate-sentry-release.mjs"], { + cwd: appDir, + env: { + ...process.env, + SENTRY_RELEASE: release, + SENTRY_COMMIT_SHA: fields.sha ?? "", + SENTRY_DEPLOY_NAME: fields.deployName, + SENTRY_ENVIRONMENT: fields.environment, + SENTRY_REQUIRE_COMMITS: "true", + SENTRY_REQUIRE_DEPLOY: "true", + SENTRY_REQUIRE_FINALIZED: "true", + }, + encoding: "utf8", + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (result.status === 0) { + if (output) log("rees_sentry_release_validation", { output: output.slice(0, 500) }); + return; + } + throw new Error(`Sentry release validation failed (${result.status}): ${output.slice(0, 500)}`); +} + async function main(): Promise { const release = resolveReesSentryRelease(process.env); const required = { @@ -156,6 +184,11 @@ async function main(): Promise { nonBlank(process.env.RAILWAY_DEPLOYMENT_ID) ?? "railway", ]); runSentry(["releases", ...projectArgs, "finalize", release!]); + runReleaseValidation(release!, { + sha, + deployName: nonBlank(process.env.RAILWAY_DEPLOYMENT_ID) ?? "railway", + environment: resolveSentryEnvironment(process.env), + }); log("rees_sentry_sourcemap_upload_complete", { release }); return 0; } catch (error) { diff --git a/review-enrichment/test/sentry-release-validation.test.ts b/review-enrichment/test/sentry-release-validation.test.ts new file mode 100644 index 0000000000..1f898293b3 --- /dev/null +++ b/review-enrichment/test/sentry-release-validation.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + loadSentryReleaseValidationConfig, + SentryReleaseValidationError, + validateSentryRelease, +} from "../scripts/validate-sentry-release.mjs"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function validationEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { + SENTRY_AUTH_TOKEN: "test-token", + SENTRY_ORG: "jsonbored", + SENTRY_PROJECT: "gittensory", + SENTRY_RELEASE: "gittensory-rees@abc123", + SENTRY_COMMIT_SHA: "abc123", + SENTRY_DEPLOY_NAME: "deploy-1", + SENTRY_ENVIRONMENT: "production", + SENTRY_REQUIRE_DEPLOY: "true", + ...overrides, + }; +} + +test("loadSentryReleaseValidationConfig resolves exact release validation defaults", () => { + assert.deepEqual( + loadSentryReleaseValidationConfig({ + SENTRY_AUTH_TOKEN: "token", + SENTRY_ORG: "jsonbored", + SENTRY_PROJECT: "gittensory", + SENTRY_RELEASE: "gittensory-rees@abc123", + RAILWAY_GIT_COMMIT_SHA: "abc123", + RAILWAY_DEPLOYMENT_ID: "deploy-1", + RAILWAY_ENVIRONMENT_NAME: "production", + }), + { + authToken: "token", + org: "jsonbored", + project: "gittensory", + release: "gittensory-rees@abc123", + baseUrl: "https://sentry.io", + expectedCommitSha: "abc123", + expectedDeployName: "deploy-1", + expectedEnvironment: "production", + requireCommits: true, + requireDeploy: false, + requireFinalized: true, + requireReleaseFiles: false, + }, + ); +}); + +test("validateSentryRelease verifies finalized release, commits, and deploy", async () => { + const calls: string[] = []; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + assert.equal((init?.headers as Record).authorization, "Bearer test-token"); + const path = new URL(String(input)).pathname; + calls.push(path); + if (path === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/") { + return response({ + version: "gittensory-rees@abc123", + dateReleased: "2026-06-29T00:00:00Z", + commitCount: 1, + deployCount: 1, + projects: [{ slug: "gittensory" }], + lastDeploy: { name: "deploy-1", environment: "production" }, + }); + } + if (path === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/commits/") { + return response([{ id: "abc123" }]); + } + if (path === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/deploys/") { + return response([{ name: "deploy-1", environment: "production" }]); + } + return response({ detail: "not found" }, 404); + }; + + const result = await validateSentryRelease(validationEnv(), fetchImpl); + + assert.equal(result.release, "gittensory-rees@abc123"); + assert.equal(result.finalized, true); + assert.equal(result.commitCount, 1); + assert.equal(result.deployCount, 1); + assert.deepEqual(calls, [ + "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/", + "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/commits/", + "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/deploys/", + ]); +}); + +test("validateSentryRelease rejects a release missing the expected commit", async () => { + const fetchImpl = async (input: string | URL | Request): Promise => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/commits/")) return response([{ id: "def456" }]); + if (path.endsWith("/deploys/")) return response([{ name: "deploy-1", environment: "production" }]); + return response({ + version: "gittensory-rees@abc123", + dateReleased: "2026-06-29T00:00:00Z", + commitCount: 1, + deployCount: 1, + projects: [{ slug: "gittensory" }], + }); + }; + + await assert.rejects( + () => validateSentryRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof SentryReleaseValidationError); + assert.deepEqual(error.failures, ["release commits do not include expected commit abc123"]); + assert.equal(JSON.stringify(error.failures).includes("test-token"), false); + return true; + }, + ); +}); + +test("validateSentryRelease rejects a release missing the required deploy", async () => { + const fetchImpl = async (input: string | URL | Request): Promise => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/commits/")) return response([{ id: "abc123" }]); + if (path.endsWith("/deploys/")) return response([]); + return response({ + version: "gittensory-rees@abc123", + dateReleased: "2026-06-29T00:00:00Z", + commitCount: 1, + deployCount: 0, + projects: [{ slug: "gittensory" }], + }); + }; + + await assert.rejects( + () => validateSentryRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof SentryReleaseValidationError); + assert.deepEqual(error.failures, ["release has no associated deploys"]); + return true; + }, + ); +}); diff --git a/review-enrichment/test/sentry-upload.test.ts b/review-enrichment/test/sentry-upload.test.ts index af99869b69..1e9ff06e2a 100644 --- a/review-enrichment/test/sentry-upload.test.ts +++ b/review-enrichment/test/sentry-upload.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; @@ -27,7 +28,71 @@ test("resolveTracesSampleRate clamps malformed or out-of-range config", () => { assert.equal(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "2" }), 1); }); -test("upload-sourcemaps calls Sentry CLI with release association on upload", () => { +async function sentryApiServer() { + const seen: string[] = []; + const server = createServer((req, res) => { + seen.push(req.url ?? ""); + res.setHeader("content-type", "application/json"); + if (req.url === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/") { + res.end( + JSON.stringify({ + version: "gittensory-rees@abc123", + dateReleased: "2026-06-29T00:00:00Z", + commitCount: 1, + deployCount: 1, + projects: [{ slug: "rees" }], + lastDeploy: { name: "deploy-1", environment: "production" }, + }), + ); + return; + } + if (req.url === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/commits/") { + res.end(JSON.stringify([{ id: "abc123" }])); + return; + } + if (req.url === "/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/deploys/") { + res.end(JSON.stringify([{ name: "deploy-1", environment: "production" }])); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ detail: "not found" })); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + assert(address && typeof address === "object"); + return { + url: `http://127.0.0.1:${address.port}`, + seen, + close: () => new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())); + }), + }; +} + +async function runUploadSourcemaps(env: NodeJS.ProcessEnv) { + return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveRun, reject) => { + const child = spawn(process.execPath, ["dist/upload-sourcemaps.js"], { + cwd: resolve(import.meta.dirname, ".."), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (status) => resolveRun({ status, stdout, stderr })); + }); +} + +test("upload-sourcemaps calls Sentry CLI with release association on upload", async () => { + const api = await sentryApiServer(); const dir = mkdtempSync(resolve(tmpdir(), "rees-sentry-cli-")); const logPath = resolve(dir, "calls.jsonl"); const cliPath = resolve(dir, "sentry-cli"); @@ -37,50 +102,54 @@ test("upload-sourcemaps calls Sentry CLI with release association on upload", () ); chmodSync(cliPath, 0o755); - const result = spawnSync(process.execPath, ["dist/upload-sourcemaps.js"], { - cwd: resolve(import.meta.dirname, ".."), - env: { + try { + const result = await runUploadSourcemaps({ ...process.env, SENTRY_AUTH_TOKEN: "test-token", SENTRY_ORG: "jsonbored", SENTRY_PROJECT: "rees", SENTRY_CLI_PATH: cliPath, + SENTRY_URL: api.url, RAILWAY_GIT_COMMIT_SHA: "abc123", RAILWAY_DEPLOYMENT_ID: "deploy-1", RAILWAY_ENVIRONMENT_NAME: "production", REES_SENTRY_UPLOAD_STRICT: "true", - }, - encoding: "utf8", - }); + }); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - const calls = readFileSync(logPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as string[]); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); - assert.deepEqual(calls[0], [ - "releases", - "--org", - "jsonbored", - "--project", - "rees", - "new", - "gittensory-rees@abc123", - ]); - assert.deepEqual(calls[2], ["sourcemaps", "--org", "jsonbored", "--project", "rees", "inject", "dist"]); - assert.deepEqual(calls[3], [ - "sourcemaps", - "--org", - "jsonbored", - "--project", - "rees", - "upload", - "--release", - "gittensory-rees@abc123", - "--validate", - "--wait", - "--strict", - "dist", - ]); + assert.deepEqual(calls[0], [ + "releases", + "--org", + "jsonbored", + "--project", + "rees", + "new", + "gittensory-rees@abc123", + ]); + assert.deepEqual(calls[2], ["sourcemaps", "--org", "jsonbored", "--project", "rees", "inject", "dist"]); + assert.deepEqual(calls[3], [ + "sourcemaps", + "--org", + "jsonbored", + "--project", + "rees", + "upload", + "--release", + "gittensory-rees@abc123", + "--validate", + "--wait", + "--strict", + "dist", + ]); + assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/"), true); + assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/commits/"), true); + assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/gittensory-rees%40abc123/deploys/"), true); + } finally { + await api.close(); + } }); diff --git a/test/unit/selfhost-sentry-release.test.ts b/test/unit/selfhost-sentry-release.test.ts index a291ef5a4e..da7d9c48c2 100644 --- a/test/unit/selfhost-sentry-release.test.ts +++ b/test/unit/selfhost-sentry-release.test.ts @@ -8,11 +8,14 @@ describe("self-host Sentry release wiring", () => { const releaseWorkflow = read(".github/workflows/release-selfhost.yml"); expect(releaseWorkflow).toContain('sourcemaps inject dist'); expect(releaseWorkflow).toContain( - 'sourcemaps upload --release="$SENTRY_RELEASE" dist', + 'sourcemaps upload --release="$SENTRY_RELEASE" --validate --wait --strict dist', ); expect(releaseWorkflow).toContain( - 'releases set-commits "$SENTRY_RELEASE" --auto', + 'releases set-commits "$SENTRY_RELEASE" --commit "$SENTRY_REPOSITORY@$SENTRY_COMMIT_SHA" --ignore-missing', ); + expect(releaseWorkflow).toContain("npx -y @sentry/cli@3.6.0"); + expect(releaseWorkflow).toContain("Validate Sentry release"); + expect(releaseWorkflow).toContain("SENTRY_REQUIRE_FINALIZED: \"true\""); expect(releaseWorkflow).toContain("target: runtime-prebuilt"); expect(releaseWorkflow).toContain( "GITTENSORY_VERSION=${{ steps.version.outputs.release }}",