Skip to content
Open
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
156 changes: 156 additions & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Preview deploy

# Trusted counterpart to "Preview trigger". Runs in the base-repository context
# with Netlify secrets, but ONLY ever handles the pre-built artifact produced by
# the build workflow. It never checks out or executes pull request code, so a
# malicious fork cannot reach the credentials used here.
#
# The PR number, commit SHA and labels come from the workflow_run event and the
# GitHub API, never from the artifact. No fork-controlled bytes ever reach a
# shell command or the labelling decision, so there is no injection path back to
# the token.
#
# NOTE for maintainers: the "preview" label is sticky. Once applied to a fork
# PR it authorises every subsequent push on that PR to auto-deploy arbitrary
# static content (including a fork-supplied _redirects proxy) to a QuestDB
# Netlify preview alias. It cannot reach secrets, but remove the label if you
# no longer trust the source.
#
# NOTE on rollout: workflow_run workflows only run from the copy on the default
# branch, so this deploy half cannot be exercised from the PR that introduces
# it. It takes effect once merged to main; verify with one throwaway PR after.

on:
workflow_run:
workflows: ["Preview trigger"]
types: [completed]

permissions:
actions: read # download the artifact from the triggering run
pull-requests: write # post the preview link as a comment
contents: read

# One in-flight deploy per source branch, so two quick pushes can't race two
# deploys onto the same preview alias. The PR number isn't known until the job
# runs, so key on the (fork) repo + branch that produced the build.
concurrency:
group: preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true

jobs:
deploy:
name: "PR preview deploy"
# Only deploy for successful pull_request builds.
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest

steps:
- name: Resolve PR and deploy gate
id: gate
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const run = context.payload.workflow_run;
const headSha = run.head_sha;
const baseRepo = context.payload.repository.full_name;

// Find the PR whose head is exactly this commit. Labels are read
// from the base-repo PR object (maintainer-controlled), so a fork
// cannot self-apply the "preview" label, and the PR number cannot
// be spoofed via the artifact.
const { data: prs } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: headSha,
});
const pr =
prs.find((p) => p.state === "open" && p.head.sha === headSha) ||
prs.find((p) => p.head.sha === headSha);

if (!pr) {
core.info(`No PR found for commit ${headSha}; skipping deploy.`);
core.setOutput("enabled", "false");
return;
}

// Guard the deleted-fork edge: pr.head.repo can be null.
const headRepoName = pr.head.repo && pr.head.repo.full_name;
const isCrossRepo = headRepoName !== baseRepo;
const hasPreviewLabel = pr.labels.some((l) => l.name === "preview");
// Same-repo PRs always preview; fork PRs require the label.
let enabled = !isCrossRepo || hasPreviewLabel;
if (!enabled) {
core.info('Fork PR without the "preview" label; skipping deploy.');
}

// The build job is skipped for unrelated label events, which still
// completes the "Preview trigger" run and fires workflow_run with no
// artifact. Only deploy if this run actually produced the preview
// artifact, so we never attempt a download that would red-X this run.
if (enabled) {
const { data: arts } =
await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
});
if (!arts.artifacts.some((a) => a.name === "preview-build")) {
core.info("No preview-build artifact for this run; skipping.");
enabled = false;
}
}

core.setOutput("enabled", enabled ? "true" : "false");
core.setOutput("number", String(pr.number));
core.setOutput("sha", headSha);

- name: Download preview artifact
if: steps.gate.outputs.enabled == 'true'
uses: actions/download-artifact@v4
with:
name: preview-build
path: build
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Netlify CLI
if: steps.gate.outputs.enabled == 'true'
run: npm install -g netlify-cli

- name: Deploy to Netlify
if: steps.gate.outputs.enabled == 'true'
id: netlify
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
# Trusted integer from the API. Passed via env and quoted so it is
# never interpolated into the script by the Actions template engine.
PR_NUMBER: ${{ steps.gate.outputs.number }}
run: |
PREVIEW_URL=$(netlify deploy \
--dir=build \
--no-build \
--auth="$NETLIFY_AUTH_TOKEN" \
--site="$NETLIFY_SITE_ID" \
--alias="preview-$PR_NUMBER" \
--message="Preview Deploy from GitHub Actions" \
--json | jq -r '.deploy_url')
echo "url=$PREVIEW_URL" >> "$GITHUB_OUTPUT"

- name: Comment preview URL on PR
if: steps.gate.outputs.enabled == 'true' && steps.netlify.outputs.url
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
pr_number: ${{ steps.gate.outputs.number }}
comment_tag: preview
message: |
:rocket: **Build success!**

Latest successful preview: ${{ steps.netlify.outputs.url }}/docs/

Commit SHA: ${{ steps.gate.outputs.sha }}

> :package: Build generates a preview & updates the link on each commit.
98 changes: 35 additions & 63 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -1,92 +1,64 @@
name: Preview trigger

# This workflow uses NO secrets. It builds the site from pull request code
# (which, for forks, is untrusted) and uploads the result as an artifact.
# The Netlify deploy runs separately in preview-deploy.yml via `workflow_run`,
# so fork-supplied build code never executes with credentials in scope.
#
# Nothing this build writes is trusted by the deploy workflow: the deploy
# derives the PR number, commit SHA and labels from the workflow_run event and
# the GitHub API, never from files inside the artifact.

on:
pull_request:
types: ["opened", "edited", "synchronize"]
pull_request_target:
types: [labeled]
types: [opened, synchronize, reopened, labeled]

# Untrusted fork code runs here, so grant it the minimum.
permissions:
contents: read

concurrency:
group: preview-build-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
pr-preview:
if: |
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'pull_request_target' && github.event.label.name == 'preview')
name: "PR preview"
# Rebuild on code changes. For label events, only the "preview" label
# (which authorises fork previews) should trigger a rebuild, so unrelated
# label changes don't kick off a full build.
if: >-
github.event.action != 'labeled' ||
github.event.label.name == 'preview'
runs-on: ubuntu-latest

steps:
- name: Checkout questdb.com repository
uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v4
with:
# Build the exact PR head, not the ephemeral merge commit, so the
# preview matches what the author pushed and the reported commit SHA
# is meaningful. Also avoids checkout failures on conflicted PRs.
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Install Netlify CLI
run: npm install -g netlify-cli

- name: Build site
env:
CONTEXT: deploy-preview
NETLIFY: true
run: yarn build --out-dir build/docs

- name: Deploy to Netlify
id: netlify
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
PREVIEW_URL=$(netlify deploy \
--dir=build \
--no-build \
--auth=$NETLIFY_AUTH_TOKEN \
--site=$NETLIFY_SITE_ID \
--alias=preview-${{ github.event.pull_request.number }} \
--message="Preview Deploy from GitHub Actions" \
--json | jq -r '.deploy_url')
echo "NETLIFY_PREVIEW_URL=$PREVIEW_URL" >> $GITHUB_ENV

- name: "Update PR"
if: env.NETLIFY_PREVIEW_URL
uses: thollander/actions-comment-pull-request@v2
with:
message: |
:rocket: **Build success!**

Latest successful preview: ${{ env.NETLIFY_PREVIEW_URL }}/docs/

Commit SHA: ${{ github.event.pull_request.head.sha }}

> :package: Build generates a preview & updates link on each commit.
comment_tag: preview

validate-links:
name: "Validate broken links"
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup Node.js
uses: actions/setup-node@v3
- name: Upload preview artifact
uses: actions/upload-artifact@v4
with:
node-version: "22"
cache: "yarn"

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Build site for broken link validation
run: |
yarn build
name: preview-build
path: build
retention-days: 1
40 changes: 40 additions & 0 deletions .github/workflows/validate-links.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Validate broken links

# Kept separate from the preview build/deploy on purpose. The deploy workflow
# keys off the "Preview trigger" workflow's conclusion, so bundling link
# validation with the build would let a single broken link block the preview.
# Here it runs independently and only gates the PR via its own required check.

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read

concurrency:
group: validate-links-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
validate-links:
name: "Validate broken links"
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Build site for broken link validation
run: yarn build
Loading