Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/actions/build-docs/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Build docs
description: Install the python environment and build the MkDocs site into ./site

runs:
using: composite
steps:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

- name: Install python deps
shell: bash
run: uv sync --locked

- name: Build
shell: bash
run: uv run mkdocs build --strict
45 changes: 30 additions & 15 deletions .github/workflows/build-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ on:
required: false
type: boolean
default: false
description: 'Whether to upload the build as an artifact'
description: 'Whether to upload the build as a GitHub Pages artifact'
upload-preview-artifact:
required: false
type: boolean
default: false
description: 'Whether to upload the build for the PR preview pipeline'

jobs:
build:
Expand All @@ -17,23 +22,33 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build docs
uses: ./.github/actions/build-docs

- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Upload Pages artifact
if: inputs.upload-pages-artifact
uses: actions/upload-pages-artifact@v4
with:
enable-cache: true

- name: Install python deps
run: uv sync --locked
path: site

- name: Build
run: uv run mkdocs build --strict
# Handed to preview.yaml, which runs with privileges this job does not
# have. See SECURITY.md.
- name: Save PR number
if: inputs.upload-preview-artifact
run: echo "${{ github.event.number }}" > pr-number.txt

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
- name: Upload built site for preview
if: inputs.upload-preview-artifact
uses: actions/upload-artifact@v4
with:
name: pr-preview-site
path: site
retention-days: 7

- name: Upload PR number for preview
if: inputs.upload-preview-artifact
uses: actions/upload-artifact@v4
with:
name: pr-preview-number
path: pr-number.txt
retention-days: 7
10 changes: 9 additions & 1 deletion .github/workflows/check-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ on:
pull_request:
workflow_dispatch:

# This builds pull request code, including code from forks, so it is
# deliberately read-only and holds no secrets. The privileged half of the
# preview pipeline lives in preview.yaml. See SECURITY.md.
permissions:
contents: read

jobs:
build:
uses: ./.github/workflows/build-docs.yaml
uses: ./.github/workflows/build-docs.yaml
with:
upload-preview-artifact: ${{ github.event_name == 'pull_request' }}
78 changes: 78 additions & 0 deletions .github/workflows/preview.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Deploy PR preview

# Publishes the preview into a SEPARATE previews repository, so that this
# repository's GitHub Pages setup (Source: GitHub Actions, via deploy-pages.yaml)
# is left completely alone, and so untrusted preview content is served from a
# different origin than the production docs.
#
# Triggered by workflow_run so that it executes in the context of THIS
# repository and can read secrets, which a fork's pull_request run cannot.
#
# SECURITY: this workflow can read the preview deploy token and consumes an
# artifact built from untrusted code. Read SECURITY.md before changing it.
on:
workflow_run:
workflows:
- Check docs
types:
- completed

concurrency: preview-${{ github.event.workflow_run.head_branch }}

permissions:
contents: read
pull-requests: write
actions: read

jobs:
deploy-preview:
name: Deploy preview
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
vars.PREVIEW_REPOSITORY != ''
steps:
# Checks out the default branch, NOT the pull request head.
- name: Checkout code
uses: actions/checkout@v4

- name: Download built site
uses: actions/download-artifact@v4
with:
name: pr-preview-site
path: site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Download PR number
uses: actions/download-artifact@v4
with:
name: pr-preview-number
path: pr-number
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

# The PR number arrives from an untrusted run and is interpolated into a
# deploy path, so reject anything that is not a plain positive integer.
- name: Read PR number
id: pr
run: |
number="$(tr -d '[:space:]' < pr-number/pr-number.txt)"
case "$number" in
'' | *[!0-9]*)
echo "Refusing to deploy: PR number '$number' is not numeric" >&2
exit 1
;;
esac
echo "number=$number" >> "$GITHUB_OUTPUT"

- name: Deploy preview
uses: rossjrw/pr-preview-action@v1
with:
source-dir: ./site
pr-number: ${{ steps.pr.outputs.number }}
action: deploy
deploy-repository: ${{ vars.PREVIEW_REPOSITORY }}
token: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}
pages-base-url: ${{ vars.PREVIEW_BASE_URL }}
66 changes: 66 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,69 @@ The following describes the prerequisites and one-time manual steps that were ne
7. Enable HTTPS
- In this repository, go to `Settings` -> `Pages`
- Check the box next to `Enforce HTTPS`

## Pull request previews

Every pull request gets a live preview of the built site, **including pull
requests from forks**, deployed with
[`rossjrw/pr-preview-action`](https://github.com/rossjrw/pr-preview-action).

Previews are published to a **separate previews repository**, not to this one.
That keeps this repository's GitHub Pages setup (Source: `GitHub Actions`, via
`deploy-pages.yaml`) completely untouched, and keeps unreviewed contributor HTML
off the `docs.emberarchive.org` origin. See [SECURITY.md](SECURITY.md).

Contributors do not need to configure anything.

### One-time setup

1. Create a public repository to hold the previews, e.g.
`aplbrain/bbqs-ember-docs-previews`.
2. Create a **fine-grained** personal access token (or a GitHub App
installation token) with `Contents: Read and write` on **only** that
repository. Do not grant it access to this repository.
3. In this repository, go to `Settings` -> `Secrets and variables` -> `Actions`
and add:
- Secret `PREVIEW_DEPLOY_TOKEN` — the token from step 2
- Variable `PREVIEW_REPOSITORY` — e.g. `aplbrain/bbqs-ember-docs-previews`
- Variable `PREVIEW_BASE_URL` — the URL the previews repo's Pages is served
from, e.g. `https://aplbrain.github.io/bbqs-ember-docs-previews`
4. Open a pull request. The first run creates the `gh-pages` branch in the
previews repository.
5. In the **previews** repository, go to `Settings` -> `Pages` and set the source
to `Deploy from a branch`, selecting `gh-pages` and `/ (root)`.

Until `PREVIEW_REPOSITORY` is set, the preview workflow skips itself, so this can
be merged before the setup above is done.

### How it works

A workflow triggered by `pull_request` runs in the contributor's fork and cannot
read secrets, so it cannot publish anything. The work is therefore split across
two workflows:

| Workflow | Trigger | Privileges | Role |
| --- | --- | --- | --- |
| `check-docs.yaml` | `pull_request` | read-only, no secrets | Builds the site and uploads it as an artifact |
| `preview.yaml` | `workflow_run` | reads the deploy token | Downloads that artifact and publishes the preview |

> [!IMPORTANT]
> `preview.yaml` can read the preview deploy token on a trigger an untrusted
> party controls. The invariants that keep this safe are in
> [SECURITY.md](SECURITY.md). Read it before changing that workflow.

Because `workflow_run` only fires for workflow files on the default branch,
changes to the preview workflow cannot be fully tested in a pull request — they
take effect once merged to `main`.

### Cleaning up old previews

There is no teardown workflow, so a preview stays published after its pull
request is merged or closed. Prune the previews repository periodically by
deleting the stale directories under `pr-preview/` on its `gh-pages` branch:

```bash
git clone --branch gh-pages <previews-repo-url> previews && cd previews
git rm -r pr-preview/pr-<number>
git commit -m "Remove preview for PR <number>" && git push
```
99 changes: 99 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Security

## Reporting a vulnerability

Please report security issues privately using GitHub's
[private vulnerability reporting](https://github.com/aplbrain/BBQS-EMBER-docs/security/advisories/new)
rather than opening a public issue.

## Pull request preview pipeline

This repository publishes a live preview of the built documentation for every
pull request, including pull requests from forks. Building unreviewed code and
publishing the result is inherently a privileged operation, so the workflows
that do it are split along a deliberate trust boundary. **Anyone modifying
`.github/workflows/preview.yaml` should read this section first.**

### Why the split exists

A workflow triggered by `pull_request` runs in the context of the contributor's
fork, receives a read-only token, and cannot read repository secrets. It
therefore cannot publish a preview. Publishing has to happen in a workflow that
runs in *this* repository's context, which is what `workflow_run` provides.

That is the whole reason `preview.yaml` exists, and it is also exactly what
makes it sensitive: it can read a credential with write access to the previews
repository, and it is triggered by an event an untrusted party controls.

No workflow here uses `pull_request_target`. That trigger is the usual source of
Actions privilege-escalation bugs, so avoiding it entirely removes a class of
mistakes a future change could otherwise make.

### Trust boundary

| Workflow | Trigger | Privileges | Runs untrusted code? |
| --- | --- | --- | --- |
| `check-docs.yaml` | `pull_request` | read-only, no secrets | **Yes** — builds pull request code |
| `preview.yaml` | `workflow_run` | reads `PREVIEW_DEPLOY_TOKEN` | No — only unpacks a prebuilt artifact |

Untrusted code is built exactly once, in the one workflow that has nothing worth
stealing. The privileged workflow consumes only the *output* of that build.

### Isolation of preview content

Previews are published to a **separate previews repository** and served from a
**different origin** than the production documentation. This is deliberate: the
preview is HTML built from unreviewed contributor code, and serving it from
`docs.emberarchive.org` would place attacker-controlled content on the same
origin as the real docs.

The production site is untouched by this pipeline. It continues to deploy from
`deploy-pages.yaml` using the GitHub Pages Actions source, and no workflow in the
preview path can write to it.

### Previews are not removed automatically

There is no teardown workflow. A preview stays published after its pull request
is merged or closed, until someone deletes the directory from the previews
repository's `gh-pages` branch.

This means content from a **closed or rejected** pull request remains publicly
served indefinitely. Because previews live on their own origin, this cannot
affect the production docs, but it does mean the previews site should be pruned
periodically — and that a preview from a pull request closed *for being
malicious* needs deleting by hand.

### Invariants

These must hold. Breaking any of them exposes the preview deploy credential to
untrusted code.

1. **`preview.yaml` must never check out or execute pull request code.** The
artifact it downloads was produced from untrusted code; it may only be
unpacked and published, never run. Its `actions/checkout` step deliberately
checks out the default branch.
2. **`check-docs.yaml` and `build-docs.yaml` must stay read-only and
secret-free.** They are the only place untrusted code runs, and that is safe
solely because they have no privileges to abuse. Do not add secrets or
elevated `permissions` to them.
3. **Data crossing the boundary must be validated.** The pull request number is
passed from the untrusted build via an artifact and is interpolated into a
deploy path, so `preview.yaml` rejects anything that is not a plain positive
integer. Any new value crossing this boundary needs comparable treatment.

A useful test when editing this file: *if a contributor opened a pull request
whose only change was a malicious `mkdocs.yml` hook, would this workflow run it
with access to `PREVIEW_DEPLOY_TOKEN`?* If the answer is anything other than a
confident no, the change is wrong.

### The preview deploy token

`PREVIEW_DEPLOY_TOKEN` is a long-lived credential and should be scoped as
tightly as GitHub allows:

- Use a **fine-grained** personal access token, or a GitHub App installation
token, not a classic PAT.
- Grant it **only** `Contents: Read and write`, and **only** on the previews
repository. It must have no access to this repository or to any other.
- Set an expiry and rotate it. If it leaks, the blast radius is limited to
defacing the previews site.
Loading