Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .github/workflows/preview-share-link.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: Preview Share Link

# When Vercel finishes a *preview* deployment, post (or update) a single sticky
# comment on the associated PR with a shareable link that lets contractors open
# the protection-gated preview in their browser.
#
# The Vercel "Protection Bypass for Automation" secret is NEVER placed in the
# comment. Instead we post a link to our own public redirect endpoint
# (`/api/preview-share` on docs.sentry.io), signing it with an HMAC so the
# public endpoint cannot be driven by bots that merely discover a preview URL.
#
# Runs on `deployment_status`, which executes in the base-repo context and has
# access to secrets even for pull requests opened from forks.

on:
deployment_status:

# Avoid duplicate work when Vercel emits multiple status events for the same
# deployment; the comment upsert is idempotent regardless.
concurrency:
group: preview-share-${{ github.event.deployment.sha }}
cancel-in-progress: false

permissions:
contents: read
pull-requests: write

jobs:
comment:
# Only act on successful, non-production deployments.
if: >-
github.event.deployment_status.state == 'success' &&
!contains(github.event.deployment_status.environment, 'Production')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SHARE_LINK_SIGNING_KEY: ${{ secrets.SHARE_LINK_SIGNING_KEY }}
SHARE_BASE_URL: ${{ vars.SHARE_BASE_URL }}
LINK_TTL_DAYS: '30'
with:
script: |
const crypto = require('node:crypto');

const {owner, repo} = context.repo;
const ds = context.payload.deployment_status;
const deployment = context.payload.deployment;

const signingKey = process.env.SHARE_LINK_SIGNING_KEY;
const baseUrl = process.env.SHARE_BASE_URL;
if (!signingKey || !baseUrl) {
core.setFailed('Missing SHARE_LINK_SIGNING_KEY secret or SHARE_BASE_URL variable.');
return;
}

// Resolve the preview URL and reduce it to its origin.
const targetUrl = ds.target_url || ds.environment_url;
if (!targetUrl) {
core.info('No target_url on deployment_status; nothing to do.');
return;
}
let origin, host;
try {
const parsed = new URL(targetUrl);
origin = parsed.origin;
host = parsed.host;
} catch {
core.info(`Unparseable target_url: ${targetUrl}`);
return;
}

// Only handle our docs preview hosts (user docs + developer docs).
const ALLOWED = /^(sentry-docs|develop-docs)[a-z0-9-]*\.(sentry\.dev|vercel\.app)$/;
if (!ALLOWED.test(host)) {
core.info(`Host is not a docs preview host, skipping: ${host}`);
return;
}
const isDevelop = host.startsWith('develop-docs');
const projectLabel = isDevelop ? 'Developer docs' : 'User docs';

// Map the deployed commit back to its PR.
const sha = deployment.sha;
const {data: prs} =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: sha,
});
const pr = prs.find(p => p.state === 'open') || prs[0];
if (!pr) {
core.info(`No PR associated with ${sha}; nothing to comment on.`);
return;
}

// Build the signed, expiring share link.
const ttlDays = Number(process.env.LINK_TTL_DAYS || '30');
const exp = Math.floor(Date.now() / 1000) + ttlDays * 24 * 60 * 60;
const sig = crypto
.createHmac('sha256', signingKey)
.update(`${origin}|${exp}`)
.digest('hex');
const shareUrl =
`${baseUrl.replace(/\/$/, '')}/api/preview-share` +
`?u=${encodeURIComponent(origin)}&exp=${exp}&sig=${sig}`;
const expDate = new Date(exp * 1000).toISOString().slice(0, 10);

const marker = '<!-- preview-share-link -->';
const body = [
marker,
`### 🔓 Shareable preview link`,
``,
`This preview is behind Vercel Deployment Protection. Open it in your ` +
`browser with the link below — no Vercel login required:`,
``,
`**[Open ${projectLabel} preview →](${shareUrl})**`,
``,
`- Bare preview URL (login required): <${origin}>`,
`- Link expires: **${expDate}** — push a new commit for a fresh link.`,
``,
`<sub>Generated automatically. The link sets a one-time bypass cookie so ` +
`you can browse the whole preview normally.</sub>`,
].join('\n');

// Upsert a single sticky comment (paginate to avoid duplicates on
// long PR threads).
const comments = await github.paginate(
github.rest.issues.listComments,
{owner, repo, issue_number: pr.number, per_page: 100}
);
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
core.info(`Updated share-link comment on PR #${pr.number}.`);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body,
});
core.info(`Created share-link comment on PR #${pr.number}.`);
}
87 changes: 87 additions & 0 deletions app/api/preview-share/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Preview share links

Auto-generates a **shareable link** for protection-gated Vercel preview
deployments and posts it as a sticky comment on the PR, so external
contractors can view previews without a Vercel login — while the bare preview
URLs stay locked down against bots and crawlers.

## How it works

1. **`.github/workflows/preview-share-link.yml`** runs on `deployment_status`.
When Vercel reports a successful **preview** deployment (production is
skipped), it:
- resolves the PR from the deployed commit SHA,
- builds an HMAC-signed, 30-day-expiring token for the preview origin,
- upserts one sticky PR comment linking to this endpoint.
2. **`app/api/preview-share/route.ts`** (this endpoint, served publicly from
`docs.sentry.io`) verifies the signature + expiry + host allowlist, then
`302`-redirects to the preview origin with Vercel's bypass params
(`x-vercel-protection-bypass` + `x-vercel-set-bypass-cookie=true`). Vercel
sets a bypass cookie and the contractor can browse the whole preview.

The Vercel bypass secret lives **only** in this endpoint's server-side env
vars. It is never written into the (public) PR comment. Links are signed, so a
bot that merely discovers a preview URL cannot forge a working share link.

## One-time setup

### 1. Create the Vercel bypass secrets (both docs projects)

For **user-docs** and **develop-docs**:
Vercel → Project → Settings → Deployment Protection → _Protection Bypass for
Automation_ → **Create** (label e.g. "preview share links"). Copy each value.

Keep **Standard Protection** enabled (previews protected, production public).

### 2. Env vars — only on the `sentry-docs` (user-docs) project

The endpoint runs only where the share link points (`docs.sentry.io`), which is
the `sentry-docs` project. Set these there, for the **Production** environment.
One endpoint holds both projects' bypass secrets:

| Name | Value |
| ---------------------------- | ------------------------------------------------------ |
| `SHARE_LINK_SIGNING_KEY` | a fresh random 32-byte secret (`openssl rand -hex 32`) |
| `BYPASS_SECRET_USER_DOCS` | bypass secret from the `sentry-docs` project |
| `BYPASS_SECRET_DEVELOP_DOCS` | bypass secret from the `develop-docs` project |

The **`develop-docs`** project needs **no** endpoint env vars — you only create
its Protection Bypass secret (step 1) to copy the value above. The route code
also ships in the develop-docs deployment, but with no env vars it fails closed
there (harmless, unused).

### 3. GitHub repo config (getsentry/sentry-docs)

| Kind | Name | Value |
| -------------------- | ------------------------ | ------------------------ |
| Actions **secret** | `SHARE_LINK_SIGNING_KEY` | same value as above |
| Actions **variable** | `SHARE_BASE_URL` | `https://docs.sentry.io` |

### 4. Ship it

Merge to `master` so the endpoint goes live on production. From then on every
new PR gets an automatic share-link comment. (The PR that introduces this
feature won't have a working link until it merges — one-time only.)

## Rotating the secret

Regenerate the bypass secret in Vercel, update `BYPASS_SECRET_*`, and redeploy.
To rotate signing, replace `SHARE_LINK_SIGNING_KEY` in both the Vercel project
and the GitHub Actions secret (existing links stop working immediately).

## Notes

- Allowed preview hosts: `sentry-docs*` / `develop-docs*` on `.sentry.dev` or
`.vercel.app`. Anything else is rejected by the endpoint.
- The endpoint only ever redirects to a **preview** host. Each link's signature
is bound to a specific host, and the workflow only signs non-production
deployments, so no valid link to a production build URL can be minted. As an
extra guard, the endpoint also hard-rejects the production `git-master` build
alias.
- The endpoint is a redirector only — no preview content is served from
`docs.sentry.io`. Hitting it without a valid signed link returns a harmless
`400`.
- The endpoint is `noindex`/`no-store` on every response and is also
`Disallow`ed in `robots.txt`. It is not linked from any page, nav, or the
sitemap — it only appears in the PR comment.
- Tests: `pnpm test app/api/preview-share`.
133 changes: 133 additions & 0 deletions app/api/preview-share/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {createHmac} from 'node:crypto';

import {NextRequest} from 'next/server';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

import {GET} from './route';

const SIGNING_KEY = 'test-signing-key';
const USER_SECRET = 'user-bypass-secret';
const DEVELOP_SECRET = 'develop-bypass-secret';

const ENDPOINT = 'https://docs.sentry.io/api/preview-share';

function sign(url: string, exp: number): string {
return createHmac('sha256', SIGNING_KEY).update(`${url}|${exp}`).digest('hex');
}

function buildRequest({
u,
exp,
sig,
}: {
exp?: number | string;
sig?: string;
u?: string;
}): NextRequest {
const params = new URLSearchParams();
if (u !== undefined) params.set('u', u);
if (exp !== undefined) params.set('exp', String(exp));
if (sig !== undefined) params.set('sig', sig);
return new NextRequest(`${ENDPOINT}?${params.toString()}`);
}

const FUTURE = () => Math.floor(Date.now() / 1000) + 60 * 60;
const PAST = () => Math.floor(Date.now() / 1000) - 60;

describe('preview-share route', () => {
beforeEach(() => {
vi.stubEnv('SHARE_LINK_SIGNING_KEY', SIGNING_KEY);
vi.stubEnv('BYPASS_SECRET_USER_DOCS', USER_SECRET);
vi.stubEnv('BYPASS_SECRET_DEVELOP_DOCS', DEVELOP_SECRET);
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('redirects a valid user-docs link with the user bypass secret', () => {
const u = 'https://sentry-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));

expect(res.status).toBe(302);
const location = new URL(res.headers.get('location')!);
expect(location.host).toBe('sentry-docs-git-my-branch.sentry.dev');
expect(location.searchParams.get('x-vercel-protection-bypass')).toBe(USER_SECRET);
expect(location.searchParams.get('x-vercel-set-bypass-cookie')).toBe('true');
expect(res.headers.get('cache-control')).toBe('no-store');
});

it('redirects a valid develop-docs link with the develop bypass secret', () => {
const u = 'https://develop-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));

expect(res.status).toBe(302);
const location = new URL(res.headers.get('location')!);
expect(location.searchParams.get('x-vercel-protection-bypass')).toBe(DEVELOP_SECRET);
});

it('accepts vercel.app generated preview hosts', () => {
const u = 'https://sentry-docs-abc123-getsentry.vercel.app';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(302);
});

it('returns 400 when params are missing', () => {
const res = GET(buildRequest({u: 'https://sentry-docs-git-x.sentry.dev'}));
expect(res.status).toBe(400);
});

it('returns 403 for a bad signature', () => {
const u = 'https://sentry-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: 'deadbeef'}));
expect(res.status).toBe(403);
});

it('returns 403 if the url is swapped after signing', () => {
const signed = 'https://sentry-docs-git-my-branch.sentry.dev';
const evil = 'https://develop-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u: evil, exp, sig: sign(signed, exp)}));
expect(res.status).toBe(403);
});

it('returns 410 for an expired link', () => {
const u = 'https://sentry-docs-git-my-branch.sentry.dev';
const exp = PAST();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(410);
});

it('returns 400 for a disallowed host', () => {
const u = 'https://evil.example.com';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(400);
});

it('refuses the production (master) build alias even with a valid signature', () => {
const u = 'https://sentry-docs-git-master-getsentry.vercel.app';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(400);
});

it('returns 400 for a non-https target', () => {
const u = 'http://sentry-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(400);
});

it('fails closed when the signing key is not configured', () => {
vi.stubEnv('SHARE_LINK_SIGNING_KEY', '');
const u = 'https://sentry-docs-git-my-branch.sentry.dev';
const exp = FUTURE();
const res = GET(buildRequest({u, exp, sig: sign(u, exp)}));
expect(res.status).toBe(500);
});
});
Loading
Loading