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
35 changes: 35 additions & 0 deletions .github/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,41 @@ Every workflow declares `permissions:` explicitly, because the repo default is *
that omits the block gets a write-capable token whether it needs one or not; only `deploy-stage`
needs `contents: write`, and only because semantic-release pushes the tag.

## PR previews

Each PR into `stage` from a branch of this repository gets its own Studio at
`https://<branch>.app.<cluster domain>/`, deployed with
[`HarperFast/deploy-action`](https://github.com/HarperFast/deploy-action) and dropped when the PR
closes, provided a user listed in the `HARPER_PREVIEW_AUTHORS` repository variable (a JSON array of
GitHub logins) opened it and caused the event. Fork PRs get none, and with the variable unset nobody
does: a preview's JavaScript runs with its visitor's dev CM session.

| Workflow | Runs on | Holds credentials |
| -------------------- | --------------------- | ---------------------------------------------- |
| `preview-build.yaml` | `pull_request` | **no**: read-only token, runs the PR's code |
| `preview.yaml` | `pull_request_target` | an OIDC identity token; never runs a PR's code |

`preview-build.yaml` builds the PR's head commit with `--mode dev`, so the bundle talks to the dev
central manager, and without `VITE_TELEMETRY_ENABLED`, so it reports nothing to Datadog or GTM. It
uploads `web/` as an artifact. `preview.yaml` runs from `stage`, waits for that build, writes its
own `config.yaml` serving `web/` with Harper's static plugin, and deploys the result as `hdbms-pr-<N>`.
The deploy template is not used, so a template change is not exercised by its PR's preview, and an
unknown path gets Harper's plain 404 rather than Studio's page.

That a preview runs no server code is what makes it safe to host beside the central manager: a
preview is a worker thread in the cluster's own Harper process, not a sandbox. It is also why
previews share the cluster's data (`branched_databases: false`, so setup takes `dataMode=shared`)
rather than branching it, which would fork every database on the node but `system`, the central
manager's included.

A preview is a client of the dev CM from another origin, so it works only while the dev CM's
`http.corsAccessList` allows the preview domain. Sign in with email and password there: OAuth
sign-in belongs to the CM's origin (see AGENTS.md) and finishes on dev Studio, not on the preview.

The cluster trusts `preview.yaml` by path, branch, event and environment, not by content, and
`harper preview setup` checks its content only once. `src/lib/workflowPrivilege.test.ts` keeps
checking that every `pull_request_target` job has the fork guard and checks out nothing.

## The merge queue, and why no deploy workflow accepts `merge_group`

The merge queue is in use on `stage`. **`verify-stage.yaml` owns its check** β€” `Verify Stage` is
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/preview-build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Build PR Preview

# Runs the PR's code, so it gets a read-only token and no identity token; preview.yaml deploys its
# artifact. Keep `branches` the same as there: its deploy job fails when no run of this one starts.

# preview.yaml finds this run by its title: keep the two the same.
run-name: 'Build PR Preview #${{ github.event.pull_request.number }}'

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

permissions:
contents: read

# A newer push cancels the build of the commit it replaces; that commit's preview run then stops
# waiting and deploys nothing.
concurrency:
group: preview-build-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
build:
name: Build PR Preview
# The same users as preview.yaml's deploy job: nobody else's build would be used.
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& contains(fromJSON(vars.HARPER_PREVIEW_AUTHORS || '[]'), github.event.pull_request.user.login)
&& contains(fromJSON(vars.HARPER_PREVIEW_AUTHORS || '[]'), github.actor)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout PR code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The head commit, not the merge commit: the preview records this SHA.
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
cache: 'pnpm'
cache-dependency-path: 'pnpm-lock.yaml'
node-version-file: '.nvmrc'
- name: Disable Husky
run: echo "HUSKY=0" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install
# Dev mode points the bundle at the dev central manager. `VITE_TELEMETRY_ENABLED` stays unset so
# `isDeployedBuild` is false: a preview must not report to Datadog RUM or GTM.
- name: Build
env:
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
run: VITE_STUDIO_VERSION="pr-${PR}_${SHA:0:7}" pnpm build --mode dev
# Dev mode writes hidden sourcemaps for Datadog, which a preview never uploads.
- name: Drop sourcemaps
run: find web -name '*.js.map' -delete
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-${{ github.event.pull_request.number }}-build
path: web/
if-no-files-found: error
retention-days: 7
141 changes: 141 additions & 0 deletions .github/workflows/preview.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Preview

# Runs from stage with the cluster's preview identity, so nothing here may check out or run a PR's
# code: preview-build.yaml builds it unprivileged, and this deploys only its `web/`. The cluster
# trusts this file by path, branch, event and environment (`harper preview setup app=hdbms
# dataMode=shared`, which `branched_databases: false` below needs). See
# .github/deploying.md and https://github.com/HarperFast/deploy-action#pull-request-previews

on:
pull_request_target:
types: [opened, reopened, synchronize, closed]
# Keep the same as in preview-build.yaml.
branches: [stage]

permissions: {}

jobs:
deploy:
# A preview's JavaScript runs with its visitor's dev CM session, so it deploys only for a PR a
# listed user opened, on an event a listed user caused. HARPER_PREVIEW_AUTHORS is a JSON array of
# GitHub logins; unset, nobody gets a preview.
if: >-
github.event.action != 'closed'
&& github.event.pull_request.head.repo.full_name == github.repository
&& contains(fromJSON(vars.HARPER_PREVIEW_AUTHORS || '[]'), github.event.pull_request.user.login)
&& contains(fromJSON(vars.HARPER_PREVIEW_AUTHORS || '[]'), github.actor)
runs-on: ubuntu-latest
timeout-minutes: 60
environment: preview
permissions:
id-token: write
pull-requests: write
deployments: write
# Reading preview-build.yaml's runs and downloading their artifacts.
actions: read
concurrency:
group: harper-preview-${{ github.event.pull_request.number }}
steps:
# GitHub starts both workflows at once, and the trust policy matches only pull_request_target
# runs, so a workflow_run trigger cannot deploy: this job waits for the build itself. Another
# PR can have a run at the same commit, so the run's title has to name this one.
- name: Wait for the build of this commit
id: build
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
# preview-build.yaml's run-name.
BUILD_TITLE: 'Build PR Preview #${{ github.event.pull_request.number }}'
BUILD_WORKFLOW: preview-build.yaml
run: |
short=${SHA:0:7}
superseded() {
local pr state head
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.state + " " + .head.sha') || return 1
read -r state head <<<"$pr"
[ "$state" = open ] && [ "$head" = "$SHA" ] && return 1
echo "::notice::Not deploying $short: PR #$PR is $state at ${head:0:7}."
}
start=$SECONDS
while :; do
if superseded; then exit 0; fi
listed=1
run=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$BUILD_WORKFLOW/runs?event=pull_request&head_sha=$SHA" \
--jq '[.workflow_runs[] | select(.head_repository.full_name == env.GITHUB_REPOSITORY and .display_title == env.BUILD_TITLE)] | max_by(.run_number) | select(.) | "\(.id) \(.status) \(.conclusion)"') || { listed=0; run=; }
elapsed=$((SECONDS - start))
if [ -n "$run" ]; then
read -r id status conclusion <<<"$run"
if [ "$status" = completed ]; then
if [ "$conclusion" = success ]; then
echo "Deploying the build from $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id"
echo "run-id=$id" >>"$GITHUB_OUTPUT"
exit 0
fi
if superseded; then exit 0; fi
echo "::error::The build of $short ended $conclusion: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id"
exit 1
fi
# A failed listing is not evidence that no run started.
elif [ "$listed" = 1 ] && [ "$elapsed" -ge 600 ]; then
echo "::error::No $BUILD_WORKFLOW run for $short started in 10 minutes. GitHub starts no pull_request run while a PR has merge conflicts."
exit 1
fi
if [ "$elapsed" -ge 1800 ]; then
echo "::error::The build of $short did not finish in 30 minutes."
exit 1
fi
sleep 20
done

- name: Download the build
if: steps.build.outputs.run-id != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: pr-${{ github.event.pull_request.number }}-build
path: site/web
run-id: ${{ steps.build.outputs.run-id }}
github-token: ${{ github.token }}

# Written here, outside the artifact, so the PR controls only files the static plugin serves.
- name: Serve the build as a Harper application
if: steps.build.outputs.run-id != ''
run: |
cat > site/config.yaml << 'EOF'
static:
files: 'web/**'
urlPath: '/'
index: true
EOF

- if: steps.build.outputs.run-id != ''
uses: HarperFast/deploy-action@45fa59b6157eabb72b7a74922309238671f2a37d # v1.0.0
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: hdbms
preview: true
by_ref: false
path: site
# Branching forks every database on the node but `system`, CM's own included. The payload
# runs no server code, so it touches no data.
branched_databases: false

cleanup:
if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview-cleanup
permissions:
id-token: write
pull-requests: write
deployments: write
steps:
# Gated here, not on the job: `harper preview setup` can only verify that cleanup runs on close
# when the job's `if:` has nothing else in it. The author alone, so whoever merges, cleans up.
- if: >-
vars.HARPER_PREVIEW_TARGET != ''
&& contains(fromJSON(vars.HARPER_PREVIEW_AUTHORS || '[]'), github.event.pull_request.user.login)
uses: HarperFast/deploy-action@45fa59b6157eabb72b7a74922309238671f2a37d # v1.0.0
with:
target: ${{ vars.HARPER_PREVIEW_TARGET }}
app: hdbms
preview: true
129 changes: 129 additions & 0 deletions src/lib/workflowPrivilege.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,135 @@ describe('workflow privilege boundary', () => {
});
});

/**
* The cluster's trust policy pins `preview.yaml`'s path, not its content, and `harper preview setup`
* checks the content only once. A `run:` step that executes the downloaded artifact is not caught.
*/
const FORK_GUARD = 'github.event.pull_request.head.repo.full_name == github.repository';

function isMapping(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* Whether the guard is a term of the top-level `&&` chain, outside every parenthesis, so nothing
* negates, quotes, wraps or bypasses it. A top-level `||`, an index expression or a second `${{ }}`
* is refused outright.
*/
function hasForkGuard(condition: unknown): boolean {
if (typeof condition !== 'string') { return false; }
const expression = condition.trim().replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
// String literals can hold `&&`, parentheses and the guard's own text.
.replace(/'(?:[^']|'')*'/g, "''");
if (/[{}[\]]/.test(expression)) { return false; }
const terms: string[] = [];
let depth = 0;
let start = 0;
for (let index = 0; index < expression.length; index++) {
const char = expression[index];
if (char === '(') {
depth++;
} else if (char === ')') {
if (--depth < 0) { return false; }
} else if (depth === 0 && expression.startsWith('||', index)) {
return false;
} else if (depth === 0 && expression.startsWith('&&', index)) {
terms.push(expression.slice(start, index));
start = index + 2;
index++;
}
}
terms.push(expression.slice(start));
return depth === 0 && terms.some((term) => term.replace(/\s+/g, ' ').trim() === FORK_GUARD);
}
Comment thread
dawsontoth marked this conversation as resolved.
Resolved

// A shape this can't read is reported, not skipped, so it fails the test instead of passing it.
function pullRequestTargetProblems(doc: Record<string, unknown>): string[] {
const problems: string[] = [];
if (doc.jobs !== undefined && !isMapping(doc.jobs)) { return ['jobs: not a mapping']; }
for (const [name, job] of Object.entries(doc.jobs ?? {})) {
if (!isMapping(job)) {
problems.push(`${name}: not a mapping`);
continue;
}
if (!hasForkGuard(job.if)) { problems.push(`${name}: no fork guard`); }
if (job.uses !== undefined) { problems.push(`${name}: calls a reusable workflow`); }
const steps = job.steps ?? [];
if (!Array.isArray(steps)) {
problems.push(`${name}: steps is not a list`);
continue;
}
for (const step of steps) {
if (!isMapping(step)) {
problems.push(`${name}: a step is not a mapping`);
continue;
}
const uses = typeof step.uses === 'string' ? step.uses : '';
if (uses.startsWith('actions/checkout@') || uses.startsWith('./')) {
problems.push(`${name}: uses ${uses}`);
}
}
}
return problems;
}
Comment thread
dawsontoth marked this conversation as resolved.
Resolved

describe('pull_request_target workflows run no PR code', () => {
const targetWorkflows = workflowFiles().filter((name) =>
triggers(parse(readFileSync(join(WORKFLOWS_DIR, name), 'utf8'))).includes('pull_request_target')
);

it('finds preview.yaml, so the scan cannot pass by finding nothing', () => {
expect(targetWorkflows).toContain('preview.yaml');
});

it.each(targetWorkflows)('%s guards every job and checks out nothing', (name) => {
expect(pullRequestTargetProblems(parse(readFileSync(join(WORKFLOWS_DIR, name), 'utf8')))).toEqual([]);
});

const job = (body: string) => `on: [pull_request_target]\njobs:\n j:\n${body}`;
const guarded = ` if: \${{ ${FORK_GUARD} }}\n`;

it.each([
['a job with no fork guard', job(' steps:\n - run: echo hi\n')],
// `&&` binds tighter, so this is `true || (… && guard)`, and its last term is the guard itself.
['a guard that `||` can bypass', job(` if: \${{ true || github.event.action != 'closed' && ${FORK_GUARD} }}\n`)],
['a negated guard', job(` if: \${{ !(${FORK_GUARD}) }}\n`)],
['a guard inside a negated group', job(` if: \${{ !(true && ${FORK_GUARD} && true) }}\n`)],
['a guard inside a call', job(` if: \${{ format('{0}', true && ${FORK_GUARD}) }}\n`)],
['a guard inside an index', job(` if: \${{ github.event[true && ${FORK_GUARD} && true] }}\n`)],
['unbalanced parentheses', job(` if: \${{ ${FORK_GUARD} && (true }}\n`)],
['a parenthesis closed before it opens', job(` if: \${{ true) && (true && ${FORK_GUARD} }}\n`)],
['a guard between two interpolations', job(` if: \${{ false }} && ${FORK_GUARD} && \${{ false }}\n`)],
['a guard inside a string', job(` if: contains('x && ${FORK_GUARD} && y', 'z')\n`)],
['a guard compared again', job(` if: \${{ ${FORK_GUARD} == false }}\n`)],
['a checkout', job(`${guarded} steps:\n - uses: actions/checkout@v7\n`)],
['a local action', job(`${guarded} steps:\n - uses: ./.github/actions/studio-verify\n`)],
['a reusable workflow', job(`${guarded} uses: ./.github/workflows/x.yaml\n`)],
['jobs that are not a mapping', 'on: [pull_request_target]\njobs: 1\n'],
['a job that is not a mapping', job('')],
['steps that are not a list', job(`${guarded} steps:\n uses: actions/checkout@v7\n`)],
['a step that is not a mapping', job(`${guarded} steps:\n - actions/checkout@v7\n`)],
])('flags %s', (_label, raw) => {
expect(pullRequestTargetProblems(parse(raw))).not.toEqual([]);
});

it.each([
['a bare guard', guarded],
['a guard after `!=`', ` if: github.event.action != 'closed' && ${FORK_GUARD}\n`],
[
'a guard beside an `||` inside a call',
` if: ${FORK_GUARD} && contains(fromJSON(vars.LIST || '[]'), github.actor)\n`,
],
[
'a guard split across lines',
` if: |-\n true &&\n ${FORK_GUARD.replace(' == ', '\n == ')}\n`,
],
])('passes %s on a job that only calls a pinned action', (_label, condition) => {
const raw = job(`${condition} steps:\n - uses: HarperFast/deploy-action@0000000\n`);
expect(pullRequestTargetProblems(parse(raw))).toEqual([]);
});
});

describe('isPrivileged recognises every privileged form', () => {
const job = (body: string) => `on: [merge_group]\njobs:\n j:\n${body}`;

Expand Down
Loading