feat(desktop): launch team skills from pull request review #157436
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Hogbox Preview Environment (per-PR PostHog on a hogland hogbox) | |
| # | |
| # Restores the pre-baked "preview golden" snapshot (PostHog already migrated + | |
| # seeded on the :master image), mounts the PR's backend source over the image's | |
| # /code, builds the PR's frontend, runs the delta migrations, and posts the | |
| # reachable URL on the PR (sticky comment + a GitHub Deployment). | |
| # | |
| # This drives tools/hogbox-preview — the layer (hogland) is swappable; the stack | |
| # recipe is not. See tools/hogbox-preview/README.md. | |
| # | |
| # --- WHY MOUNT (not build / not a per-PR image) --------------------------------- | |
| # The golden is baked ONCE (a hogland-side setup script: docker pull | |
| # ghcr.io/posthog/posthog:master, migrate pg+CH, seed demo data), so its DB is | |
| # already migrated AND seeded on the master image. A per-PR run restores that, | |
| # bind-mounts the PR's checkout (posthog/ee/products) over the image's /code so | |
| # the BACKEND runs the PR code with NO per-PR build or pull, and runs `migrate` — | |
| # applying only the PR's *unapplied* migrations on top of the seeded DB. That | |
| # keeps each preview off both the cold 20-min build and the heavy full-migrate | |
| # path. The PR's FRONTEND is compiled on the runner (the untrusted build-frontend | |
| # job — never in credential scope), shipped in as a dist tarball through | |
| # hogplane's files API (chunked past the 64 MiB write cap), laid over the image's | |
| # baked dist in-box and followed by a collectstatic re-run. Label-only backend | |
| # PRs that don't touch the frontend skip all of that and serve the image's | |
| # :master SPA. If a PR's migrations are incompatible with the baseline, fall back | |
| # to --reset-db. | |
| # | |
| # --- TRIGGER (who gets a preview) ----------------------------------------------- | |
| # The `decide` job (cheap, credential-free) is the gate. A same-repo, human PR | |
| # gets a preview when it carries the `hogbox-preview` label (manual opt-in, ANY | |
| # paths, draft or ready) OR it is ready (non-draft) and its diff touches the | |
| # frontend (frontend/**, products/*/frontend/**, common/esbuilder/**). A draft | |
| # only ever previews through the label. The `no-preview` label opts out. | |
| # Auto-previews for frontend PRs are org-visible default behavior; the opt-out + | |
| # hibernation (below) keep them cheap. See the trigger notes on `on.pull_request`. | |
| # | |
| # --- LIFECYCLE ------------------------------------------------------------------ | |
| # - build/update: once `decide` says build, `announce` posts the in-progress | |
| # Deployment + comment, then `build-frontend` (untrusted, no credentials — | |
| # see the permissions note below) and `deploy` (trusted, brings the box up) | |
| # run IN PARALLEL. deploy no longer waits on the dist: it restores + mounts + | |
| # migrates immediately, and only blocks on build-frontend right at the end, | |
| # to swap the finished dist in before it posts "ready". Cuts wall-clock: | |
| # the FE build hides under the longer bring-up. | |
| # - teardown on PR close + the daily stale-sweep: pr-closed.yml (box + pen) + | |
| # hogbox-preview-cleanup.yml's cron. The GitHub Deployment/environment | |
| # (preview-pr-<n>) is reaped by pr-cleanup.yml's existing deployment cleanup. | |
| # - teardown fast path: the `teardown` job below fires when `hogbox-preview` is | |
| # removed OR `no-preview` is added (the cron is the backstop); it also marks | |
| # the Deployment inactive and flips the sticky comment. | |
| # | |
| # --- PREREQS (tracked out of band) ---------------------------------------------- | |
| # - vars.TS_HOGLAND_CI_CLIENT_ID / TS_HOGLAND_CI_AUDIENCE (tailnet join) | |
| # - the golden is referenced by its global alias `posthog-preview-golden` | |
| # - target hogland deploy has HOG_GITHUB_OIDC_AUDIENCE + a github_oidc | |
| # TrustMapping for PostHog/posthog on prod-us | |
| # - ready frontend PRs auto-preview; `hogbox-preview` forces it for any paths | |
| # and any draft state, | |
| # `no-preview` opts out (see the TRIGGER section above) | |
| # The box is driven entirely by the posthog-hogland SDK (pip-installable, keyless | |
| # over hogplane's HTTP API) — no `hogland` CLI binary and no box SSH key needed. | |
| # --- OPTIONAL BY DESIGN --------------------------------------------------------- | |
| # A preview never gates a PR: all preview infra (tailnet, hogland, GitHub API, the | |
| # reporters) fails open and reports via the Deployment + sticky comment. Only | |
| # `build-frontend` (PR code — deploy reads its conclusion) and the fork guards still | |
| # red. There is no "yellow" to use instead, and job-level `continue-on-error` still | |
| # reds the PR, so fail-open means step-level. The name prefix is on the JOBS because | |
| # a check run is named after the job, not the workflow. | |
| name: '[Optional] Hogbox Preview Environment' | |
| on: | |
| pull_request: | |
| # Broad on purpose — the eligibility decision CANNOT live in an | |
| # `on.pull_request.paths` filter: that would kill the label-only path for | |
| # backend PRs (a labeled PR gets a preview for ANY paths). So the trigger | |
| # fires for every PR event of these types and the `decide` job below is the | |
| # real gate (see 2a in README). `ready_for_review` is included so a | |
| # draft→ready flip creates the preview; `labeled`/`unlabeled` drive both | |
| # the manual `hogbox-preview` opt-in and the `no-preview` opt-out. | |
| types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: PR number to (re)build a preview for | |
| required: true | |
| # NOTE: there is deliberately NO workflow-level `concurrency` here. The workflow | |
| # fires on `unlabeled` (a no-op teardown-irrelevant label removal still starts a | |
| # run), and a workflow-level `cancel-in-progress` would let such an unrelated run | |
| # cancel an in-flight preview BUILD for the same PR. Instead the cancelable | |
| # concurrency lives on the `preview`/`teardown` jobs (keyed on PR number), which | |
| # only run once eligibility is decided — so a no-op run never enters the group | |
| # and never cancels anything. See 2b in README. | |
| # Least privilege at the workflow level; each job asks for exactly what it | |
| # needs. Crucially `id-token: write` lives ONLY on jobs that never execute | |
| # PR-controlled code (deploy/teardown, which check out the DEFAULT branch) — | |
| # with it on a job, $ACTIONS_ID_TOKEN_REQUEST_TOKEN is ambient in EVERY step, | |
| # so a job that runs the PR's pnpm lifecycle/build scripts could mint a | |
| # hogland-audience token no matter how late our own mint step runs. | |
| permissions: | |
| contents: read | |
| env: | |
| # prod-us: the golden lives here and the box edge is live here. | |
| HOG_HOST: https://hogland.hedgehog-kitefin.ts.net | |
| HOG_OIDC_AUDIENCE: hogland.prod-us.posthog.dev | |
| PR: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| jobs: | |
| # ---------------------------------------------------------------------------- | |
| # Cheap, credential-free gate. Runs on EVERY event (github-script only, no | |
| # checkout, no tailnet, no token) and decides — from the PR's labels, author, | |
| # draft state and diff — whether to build a preview, tear one down, or do | |
| # nothing. Keeping it separate + non-cancelable is what dodges the | |
| # unlabeled-cancel trap: a no-op event runs only this job, never touches the | |
| # `preview`/`teardown` concurrency group, so it can't cancel an active build. | |
| decide: | |
| # A preview for the queue's branch would outlive it by minutes, so the | |
| # answer is always "do nothing" — skip before spending the API calls. | |
| # Downstream jobs key off `needs.decide.outputs.build`, which is empty | |
| # when this skips, so they stay skipped too. | |
| if: ${{ !startsWith(github.head_ref, 'trunk-merge/') }} | |
| name: '[Optional] decide eligibility' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: read # read labels + list changed files; no write, no token | |
| outputs: | |
| build: ${{ steps.decide.outputs.build }} | |
| teardown: ${{ steps.decide.outputs.teardown }} | |
| steps: | |
| # A blip empties the outputs, so every downstream job skips: no preview, still green. | |
| - name: Decide build vs teardown vs skip | |
| id: decide | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const base = `${context.repo.owner}/${context.repo.repo}`; | |
| const event = context.eventName; | |
| const action = context.payload.action; | |
| const decide = (build, teardown, why) => { | |
| core.info(why); | |
| core.setOutput('build', build ? 'true' : 'false'); | |
| core.setOutput('teardown', teardown ? 'true' : 'false'); | |
| }; | |
| // Manual dispatch is an explicit act by someone with Actions | |
| // write — always attempt a build. The preview job's fork guard | |
| // is the security gate for a dispatched fork number. | |
| if (event === 'workflow_dispatch') { | |
| return decide(true, false, 'workflow_dispatch → build'); | |
| } | |
| const pr = context.payload.pull_request; | |
| const label = context.payload.label && context.payload.label.name; | |
| // Teardown fast paths (idempotent — a no-op if nothing is up): | |
| // removing `hogbox-preview` keeps its existing teardown | |
| // semantics; adding the `no-preview` opt-out tears a live | |
| // preview down the same way. | |
| if (action === 'unlabeled' && label === 'hogbox-preview') { | |
| return decide(false, true, 'hogbox-preview removed → tear down'); | |
| } | |
| if (action === 'labeled' && label === 'no-preview') { | |
| return decide(false, true, 'no-preview opt-out added → tear down'); | |
| } | |
| // Most label toggles don't change build intent — only two do: | |
| // `hogbox-preview` ADDED (manual opt-in) and `no-preview` | |
| // REMOVED (opt-out lifted). Any other labeled/unlabeled is a | |
| // no-op, skipped WITHOUT an API call so a busy PR's unrelated | |
| // label churn never queues a ~10-min rebuild. Code pushes | |
| // (synchronize) are what rebuild on change. | |
| const buildRelevantLabel = | |
| (action === 'labeled' && label === 'hogbox-preview') || | |
| (action === 'unlabeled' && label === 'no-preview'); | |
| if ((action === 'labeled' || action === 'unlabeled') && !buildRelevantLabel) { | |
| return decide(false, false, `label ${action} '${label}' — not preview-relevant → skip`); | |
| } | |
| // Build eligibility: same-repo AND human AND (ready OR | |
| // labeled) AND not opted-out AND (labeled OR | |
| // frontend-touching). | |
| const labels = (pr.labels || []).map(l => l.name); | |
| const hasPreviewLabel = labels.includes('hogbox-preview'); | |
| const optedOut = labels.includes('no-preview'); | |
| const sameRepo = (pr.head && pr.head.repo && pr.head.repo.full_name) === base; | |
| const login = (pr.user && pr.user.login) || ''; | |
| const isBot = (pr.user && pr.user.type) === 'Bot' | |
| || /\[bot\]$/i.test(login) | |
| || /^(dependabot|renovate|github-actions|snyk-bot|posthog-bot|mendral-app|greptileai|coderabbitai|sentry-io)\b/i.test(login); | |
| const ready = pr.draft === false; | |
| if (!sameRepo) return decide(false, false, `fork PR (${pr.head && pr.head.repo && pr.head.repo.full_name}) → skip`); | |
| if (isBot) return decide(false, false, `bot author (${login}) → skip`); | |
| // A draft never AUTO-previews, but `hogbox-preview` opts one | |
| // in: self-review wants a clickable box before the PR is | |
| // ready. An unlabeled draft still waits for the draft→ready | |
| // flip. | |
| if (!ready && !hasPreviewLabel) return decide(false, false, 'draft PR without hogbox-preview → skip'); | |
| if (optedOut) return decide(false, false, 'no-preview label present → skip'); | |
| // A labeled draft already has its preview (pushes keep it | |
| // current), so the draft→ready flip has nothing to build — | |
| // skip the identical rebuild at the same SHA. | |
| if (action === 'ready_for_review' && hasPreviewLabel) { | |
| return decide(false, false, 'ready_for_review on an already-previewed draft → skip'); | |
| } | |
| // The manual label opts a PR in for ANY paths; without it we | |
| // require a frontend-touching diff. Only pay for the files | |
| // list when there's no label (short-circuit the API call). | |
| let frontend = false; | |
| if (!hasPreviewLabel) { | |
| const files = await github.paginate(github.rest.pulls.listFiles, | |
| { ...context.repo, pull_number: pr.number, per_page: 100 }); | |
| const fe = /^frontend\/|^products\/[^/]+\/frontend\/|^common\/esbuilder\//; | |
| frontend = files.some(f => fe.test(f.filename)); | |
| core.info(`frontend-touching: ${frontend}`); | |
| } | |
| const build = hasPreviewLabel || frontend; | |
| return decide(build, false, build | |
| ? `eligible (label=${hasPreviewLabel}, frontend=${frontend}) → build` | |
| : 'no hogbox-preview label and no frontend changes → skip'); | |
| # ---------------------------------------------------------------------------- | |
| # Trusted, tiny, no-checkout: open a GitHub Deployment (in_progress) and the | |
| # sticky "building…" comment immediately, so the PR shows activity before the | |
| # multi-minute build + bring-up finishes. Runs github-script only — no PR | |
| # code — which is why it may hold write perms. Deliberately OUTSIDE the | |
| # per-PR concurrency group: announcing must never cancel an in-flight | |
| # deploy; build-frontend/deploy below do the coalescing. | |
| announce: | |
| name: '[Optional] announce build' | |
| needs: decide | |
| if: needs.decide.outputs.build == 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: write # sticky preview comment | |
| deployments: write # GitHub Deployment for the PR's Environments UI | |
| outputs: | |
| sha: ${{ steps.start.outputs.sha }} | |
| deployment_id: ${{ steps.start.outputs.deployment_id }} | |
| steps: | |
| # Cosmetic: on a blip sha/deployment_id go empty, which deploy already tolerates. | |
| - name: Announce (deployment + building comment) | |
| id: start | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| let sha = context.payload.pull_request?.head?.sha; | |
| if (!sha) { | |
| const { data } = await github.rest.pulls.get({ ...context.repo, pull_number: pr }); | |
| sha = data.head.sha; | |
| } | |
| core.setOutput('sha', sha); | |
| const environment = `preview-pr-${pr}`; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const dep = await github.rest.repos.createDeployment({ | |
| ...context.repo, ref: sha, environment, auto_merge: false, | |
| required_contexts: [], transient_environment: true, description: 'Hogbox preview', | |
| }); | |
| core.setOutput('deployment_id', String(dep.data.id)); | |
| await github.rest.repos.createDeploymentStatus({ | |
| ...context.repo, deployment_id: dep.data.id, state: 'in_progress', | |
| environment, log_url: runUrl, description: 'Building preview…', | |
| }); | |
| const marker = '<!-- hogbox-preview-comment -->'; | |
| const body = | |
| `${marker}\n### 🦔 Hogbox preview · 🔧 building…\n\n` + | |
| `Spinning up PostHog for this PR on a hogland hogbox — this comment updates in place when it's ready (usually a few minutes).\n\n` + | |
| `<sub>commit \`${sha.slice(0, 7)}\` · <a href="${runUrl}">build log</a></sub>`; | |
| const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: pr }); | |
| const ex = comments.find(c => c.body.includes(marker)); | |
| if (ex) await github.rest.issues.updateComment({ ...context.repo, comment_id: ex.id, body }); | |
| else await github.rest.issues.createComment({ ...context.repo, issue_number: pr, body }); | |
| # ---------------------------------------------------------------------------- | |
| # UNTRUSTED build: checks out the PR and runs its pnpm lifecycle + turbo | |
| # build scripts. Deliberately powerless — read-only GITHUB_TOKEN, NO | |
| # id-token, no tailnet — so PR-controlled code can't mint a hogland-audience | |
| # OIDC token or write anywhere. Its only product is the dist artifact the | |
| # trusted `deploy` job consumes as data. | |
| # | |
| # DELIBERATELY NOT in the per-PR concurrency group. build-frontend and | |
| # deploy now run in PARALLEL within one run (deploy waits on it only at the | |
| # end, to swap the dist in), and GitHub won't run two jobs of the same group | |
| # at once — so sharing the group would deadlock them (one sits queued behind | |
| # the other forever). The group therefore lives on `deploy`/`teardown` ONLY. | |
| # ACCEPTED COST: a superseded push cancels the newer run's deploy (via the | |
| # group), but this run's build-frontend is NOT cancelable, so it may run to | |
| # completion wasted (runner time only). No correctness impact — its | |
| # artifact is namespaced to its own run_id, so a stale build can't leak into | |
| # the winning run's swap. | |
| # Hard-fails on purpose: it runs PR code, and deploy reads its conclusion to tell | |
| # a broken FE build from a backend-only PR. deploy matches this `name` literally. | |
| build-frontend: | |
| name: '[Optional] build PR frontend' | |
| needs: decide | |
| if: needs.decide.outputs.build == 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| pull-requests: read # list changed files (FE detection) + resolve dispatched PR numbers | |
| # No job-level outputs: deploy no longer `needs` this job (they run in | |
| # parallel). It discovers whether a dist exists by checking THIS run for | |
| # the `hogbox-preview-frontend-dist` artifact — present ⇒ swap, absent ⇒ | |
| # the PR didn't touch the frontend, keep :master. | |
| steps: | |
| # SECURITY GATE — manual dispatch takes a bare pr_number and the tool | |
| # later checks out `pull/<n>/head` INSIDE the box and EXECUTES it with | |
| # the tailnet + a minted hogland token in scope. The `pull_request` | |
| # path is same-repo-gated in the `decide` job, but a dispatched number | |
| # is not — dispatching a FORK PR's number would run fork code against | |
| # real credentials. So resolve the PR via the API and hard-fail unless | |
| # its head lives in this repo (never a fork). Runs first, before any | |
| # checkout / credential mint, so a fork number dies before anything | |
| # sensitive comes online. | |
| - name: Guard dispatch against fork PRs | |
| if: github.event_name == 'workflow_dispatch' | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const { data } = await github.rest.pulls.get({ ...context.repo, pull_number: pr }); | |
| const head = data.head?.repo?.full_name; | |
| const base = `${context.repo.owner}/${context.repo.repo}`; | |
| if (head !== base) { | |
| core.setFailed( | |
| `Refusing to build a preview for PR #${pr}: its head is ${head || '(deleted fork)'}, ` + | |
| `not ${base}. Manual dispatch only runs same-repo PRs — fork code must never run ` + | |
| `with the hogland token in scope.`); | |
| } | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| # A dispatched run would otherwise check out master (the dispatch ref) | |
| # and ship master's SPA as the "PR frontend" — dispatch is the only | |
| # preview path for bot-authored PRs. The PR head is safe here: | |
| # the fork guard above has already hard-failed non-same-repo numbers, | |
| # and this job runs without credentials. pull_request events keep the | |
| # default merge-ref behavior (empty ref). | |
| ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', env.PR) || '' }} | |
| # --- Frontend: only build it when the PR actually touches it -------- | |
| # The golden serves the :master SPA; mounting the backend doesn't | |
| # change the frontend. So for FE-touching PRs, build the PR's | |
| # frontend here (the canonical Dockerfile build) and hand the dist to | |
| # the tool, which lays it in + re-runs collectstatic in-box. Non-FE | |
| # PRs skip all of this and keep the fast preview. | |
| - name: Detect frontend changes | |
| id: fe | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: pr, per_page: 100 }); | |
| // Rebuild the FE for anything that feeds the frontend build: | |
| // app code, shared workspace packages (common/*, packages/*), | |
| // and the root manifests/lockfile that pin the build graph. | |
| const changed = files.some(f => | |
| f.filename.startsWith('frontend/') || | |
| f.filename.includes('/frontend/') || | |
| f.filename.startsWith('common/') || | |
| f.filename.startsWith('packages/') || | |
| f.filename === 'package.json' || | |
| f.filename === 'pnpm-lock.yaml' || | |
| f.filename === 'pnpm-workspace.yaml'); | |
| core.info(`frontend changed in PR #${pr}: ${changed}`); | |
| core.setOutput('changed', changed ? 'true' : 'false'); | |
| # The repo's composite handles pnpm + Node (.nvmrc) + cache, and keeps | |
| # the cache keyed to the default branch (the lint-workflows rule bans a | |
| # raw setup-node `cache: pnpm` here). | |
| - name: Install frontend deps | |
| if: steps.fe.outputs.changed == 'true' | |
| uses: ./.github/actions/pnpm-install | |
| with: | |
| install-command: pnpm --filter=@posthog/frontend... install --frozen-lockfile | |
| - name: Build the PR frontend | |
| if: steps.fe.outputs.changed == 'true' | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=6144 | |
| run: | | |
| bin/turbo --filter=@posthog/frontend build | |
| pnpm build:products | |
| tar czf "$RUNNER_TEMP/frontend-dist.tgz" -C frontend dist | |
| # The artifact is the ONLY thing that crosses the trust boundary into | |
| # the deploy job — a tarball of built assets, consumed as data. | |
| - name: Upload frontend dist | |
| if: steps.fe.outputs.changed == 'true' | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: hogbox-preview-frontend-dist | |
| path: ${{ runner.temp }}/frontend-dist.tgz | |
| compression-level: 0 | |
| retention-days: 1 | |
| # ---------------------------------------------------------------------------- | |
| # TRUSTED deploy: the only job that holds hogland credentials (id-token → | |
| # tailnet + HOG_TOKEN). It checks out the DEFAULT branch — never the PR — so | |
| # no PR-controlled code executes in credential scope (same reasoning as the | |
| # teardown job below and pr-closed.yml). The PR's contribution enters only | |
| # as data: the dist artifact from build-frontend, and the in-box | |
| # `pull/<n>/head` checkout the tool performs on the guest. | |
| # | |
| # PARALLEL with build-frontend: deploy deliberately does NOT `need` | |
| # build-frontend, so it starts bringing the box up the moment `announce` | |
| # opens the Deployment — restore + backend mount + migrate run WHILE the | |
| # runner compiles the PR frontend on its own runner. Only once the box is | |
| # healthy does deploy block on build-frontend (the "Wait for the PR frontend" | |
| # step polls this run's jobs), then swap the finished dist in BEFORE posting | |
| # "ready" — a reviewer must never open a green link and get :master's | |
| # frontend for their frontend PR. The FE build hides entirely under the | |
| # longer bring-up, so the pipeline is bring-up-bound, not their sum. | |
| # | |
| # FAIL-OPEN, so infra failures leave this job green: success()/failure() are | |
| # meaningless here, the `outcome` step is the verdict. The fork guard is exempt — | |
| # it must hard-stop before the tailnet and token come online. | |
| deploy: | |
| name: '[Optional] deploy preview' | |
| needs: [decide, announce] | |
| # Runs whenever an eligible build was attempted — even if announce | |
| # failed, so the failure reporter below still flips the Deployment + | |
| # sticky comment to failed. Skips only on cancellation (a newer push's | |
| # run took over the concurrency group). | |
| if: ${{ !cancelled() && needs.decide.outputs.build == 'true' }} | |
| concurrency: | |
| group: hogbox-preview-${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 40 | |
| permissions: | |
| contents: read | |
| id-token: write # mint OIDC for the tailnet join AND the hogplane token | |
| actions: read # poll this run's jobs + artifacts to wait on build-frontend | |
| pull-requests: write # sticky preview comment | |
| deployments: write # flip the Deployment to success/failure | |
| env: | |
| SHA: ${{ needs.announce.outputs.sha }} | |
| DEP: ${{ needs.announce.outputs.deployment_id }} | |
| steps: | |
| # SECURITY GATE — same fork check as build-frontend, duplicated here | |
| # because deploy no longer `needs` that job: without its own guard, a | |
| # dispatched FORK PR number would reach the tailnet join + token mint | |
| # below (and stand up a box running fork code) before build-frontend's | |
| # guard eventually failed the wait step. Runs first, before anything | |
| # sensitive comes online. | |
| - name: Guard dispatch against fork PRs | |
| if: github.event_name == 'workflow_dispatch' | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const { data } = await github.rest.pulls.get({ ...context.repo, pull_number: pr }); | |
| const head = data.head?.repo?.full_name; | |
| const base = `${context.repo.owner}/${context.repo.repo}`; | |
| if (head !== base) { | |
| core.setFailed( | |
| `Refusing to build a preview for PR #${pr}: its head is ${head || '(deleted fork)'}, ` + | |
| `not ${base}. Manual dispatch only runs same-repo PRs — fork code must never run ` + | |
| `with the hogland token in scope.`); | |
| } | |
| # Shared t0 for the stage breadcrumbs. Bring-up and the frontend swap | |
| # are separate tool processes, so each timed from its own start and | |
| # their `+NNNs` lines couldn't be read as one timeline. Anchoring both | |
| # here makes the whole job one clock. Computed in a step rather than | |
| # a job-level env because no `github` context field is reliably epoch | |
| # seconds, and a non-numeric value silently falls back to per-process. | |
| - name: Anchor preview stage timings | |
| run: echo "HOGBOX_PREVIEW_T0=$(date +%s)" >> "$GITHUB_ENV" | |
| # Trusted checkout: the tool code that runs with HOG_TOKEN comes from | |
| # the default branch, never the PR. | |
| # A blip is infra: bring-up then fails into the failure reporter, not a red X. | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| continue-on-error: true | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| - name: Connect to Tailscale (tag:hogland-ci) | |
| id: tailnet | |
| continue-on-error: true | |
| uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2 | |
| with: | |
| oauth-client-id: ${{ vars.TS_HOGLAND_CI_CLIENT_ID }} | |
| audience: ${{ vars.TS_HOGLAND_CI_AUDIENCE }} | |
| tags: tag:hogland-ci | |
| - name: Mint hogland OIDC token | |
| id: token | |
| if: steps.tailnet.outcome == 'success' | |
| continue-on-error: true | |
| # Retry the mint across transient GitHub OIDC hiccups (a ~12-min | |
| # outage on 2026-07-09 leaked two preview pens). curl -sf makes an | |
| # HTTP error a non-zero exit, but a `curl | jq` pipe takes jq's exit | |
| # — and jq succeeds on empty input — so the empty-token guard is the | |
| # load-bearing backstop AFTER the retries, never dropped. | |
| run: | | |
| token="" | |
| for attempt in 1 2 3; do | |
| token=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${HOG_OIDC_AUDIENCE}" | jq -r '.value') || token="" | |
| [ -n "$token" ] && [ "$token" != "null" ] && break | |
| echo "::warning::OIDC mint attempt ${attempt} failed; retrying" | |
| [ "$attempt" -lt 3 ] && sleep 5 | |
| done | |
| [ -n "$token" ] && [ "$token" != "null" ] || { echo "::error::OIDC mint failed after 3 attempts"; exit 1; } | |
| echo "::add-mask::$token" | |
| echo "HOG_TOKEN=$token" >> "$GITHUB_ENV" | |
| # uv (repo convention) instead of pip — no separate install step. | |
| # --no-project skips the posthog monorepo (don't sync it); --with pins | |
| # the SDK into an ephemeral env. posthog-hogland is first-party and | |
| # exempted from the 7-day soak in [tool.uv]. | |
| - uses: ./.github/actions/setup-uv | |
| with: | |
| enable-cache: false | |
| id: uv | |
| if: steps.token.outcome == 'success' | |
| continue-on-error: true | |
| - name: Bring up preview | |
| id: build | |
| if: steps.uv.outcome == 'success' | |
| continue-on-error: true | |
| run: | | |
| # Restore the golden, check out the PR, mount its backend source | |
| # (posthog/ee/products) over the :master image's /code, | |
| # delta-migrate. No per-PR image, and NO frontend dist here — the | |
| # frontend is swapped in afterwards (once build-frontend finishes, | |
| # below) so this step runs in parallel with the FE build. The tool | |
| # streams `[hogbox-preview +NNNs] <stage>` lines to STDERR for | |
| # per-stage visibility; stdout stays the url=/box_id=/pen_id= contract. | |
| out=$(uv run --no-project --with posthog-hogland==0.3.0 --python 3.12 \ | |
| python -m hogbox_preview \ | |
| --host "$HOG_HOST" \ | |
| --name "preview-pr-${PR}" \ | |
| --snapshot "alias:posthog-preview-golden" \ | |
| --cpus 8 --memory-mib 16384 --disk-gib 100 \ | |
| --ttl-seconds 1800 \ | |
| up --branch "pull/${PR}/head" --no-seed) | |
| echo "$out" | |
| { | |
| echo "url=$(echo "$out" | sed -n 's/^url=//p')" | |
| echo "box_id=$(echo "$out" | sed -n 's/^box_id=//p')" | |
| echo "pen_id=$(echo "$out" | sed -n 's/^pen_id=//p')" | |
| } >> "$GITHUB_OUTPUT" | |
| working-directory: tools/hogbox-preview | |
| # The box is healthy on the :master SPA. NOW block on build-frontend: | |
| # poll THIS run's jobs for the `build PR frontend` job until it has a | |
| # conclusion. failure/cancelled/timed_out → fail (the failure reporter | |
| # flips the comment + Deployment). success WITH the dist artifact → | |
| # swap the PR frontend in. success WITHOUT the artifact, or skipped → | |
| # the PR didn't touch the frontend, keep :master (no swap). By now the | |
| # FE build has usually long finished, so this returns near-instantly. | |
| - name: Wait for the PR frontend build | |
| id: fe | |
| if: steps.build.outcome == 'success' | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| // Must match the build-frontend job's `name:` exactly. | |
| const jobName = '[Optional] build PR frontend'; | |
| // Ceiling must cover build-frontend's own 30m timeout PLUS | |
| // queue time, minus the ~6m of bring-up already elapsed; | |
| // 32m keeps us inside deploy's 40m job timeout with margin. | |
| const deadline = Date.now() + 32 * 60 * 1000; | |
| let conclusion = null; | |
| while (Date.now() < deadline) { | |
| // No pagination: a preview run has a handful of jobs, so | |
| // one per_page:100 call always sees them all. | |
| const { data } = await github.rest.actions.listJobsForWorkflowRun( | |
| { ...context.repo, run_id: context.runId, per_page: 100 }); | |
| const job = data.jobs.find(j => j.name === jobName); | |
| if (job && job.status === 'completed') { conclusion = job.conclusion; break; } | |
| await new Promise(r => setTimeout(r, 10000)); | |
| } | |
| if (conclusion === null) { | |
| core.setFailed(`Timed out waiting for '${jobName}' to finish`); | |
| return; | |
| } | |
| core.info(`'${jobName}' concluded: ${conclusion}`); | |
| if (conclusion !== 'success' && conclusion !== 'skipped') { | |
| // failure / cancelled / timed_out — don't post a green preview. | |
| core.setFailed(`Frontend build ${conclusion} — failing deploy so the failure reporter runs`); | |
| return; | |
| } | |
| // Only a success can have produced a dist artifact; skipped never does. | |
| let swap = false; | |
| if (conclusion === 'success') { | |
| const { data: artData } = await github.rest.actions.listWorkflowRunArtifacts( | |
| { ...context.repo, run_id: context.runId, per_page: 100 }); | |
| swap = artData.artifacts.some(a => a.name === 'hogbox-preview-frontend-dist'); | |
| } | |
| core.info(`frontend swap needed: ${swap}`); | |
| core.setOutput('swap', swap ? 'true' : 'false'); | |
| - name: Download frontend dist | |
| id: dist | |
| if: steps.fe.outputs.swap == 'true' | |
| continue-on-error: true | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | |
| with: | |
| name: hogbox-preview-frontend-dist | |
| path: ${{ runner.temp }}/fe-dist | |
| - name: Swap in the PR frontend | |
| id: swap | |
| if: steps.dist.outcome == 'success' | |
| continue-on-error: true | |
| working-directory: tools/hogbox-preview | |
| run: | | |
| # Lay the freshly-built dist over the running box + re-collectstatic. | |
| # Resolves the live box by pen name (preview-pr-<n>) — no restore. | |
| uv run --no-project --with posthog-hogland==0.3.0 --python 3.12 \ | |
| python -m hogbox_preview \ | |
| --host "$HOG_HOST" \ | |
| --name "preview-pr-${PR}" \ | |
| swap-frontend --frontend-dist "${RUNNER_TEMP}/fe-dist/frontend-dist.tgz" | |
| # dist/swap are 'skipped' on a backend-only PR, which passes; 'failure' | |
| # would serve :master under a comment claiming the PR's frontend. | |
| - name: Decide preview outcome | |
| id: outcome | |
| if: ${{ !cancelled() }} | |
| env: | |
| BRING_UP: ${{ steps.build.outcome }} | |
| FE_WAIT: ${{ steps.fe.outcome }} | |
| DIST: ${{ steps.dist.outcome }} | |
| SWAP: ${{ steps.swap.outcome }} | |
| run: | | |
| ok=false | |
| if [ "$BRING_UP" = success ] && [ "$FE_WAIT" = success ] \ | |
| && [ "$DIST" != failure ] && [ "$SWAP" != failure ]; then | |
| ok=true | |
| fi | |
| echo "bring-up=$BRING_UP fe-wait=$FE_WAIT dist=$DIST swap=$SWAP -> ok=$ok" | |
| echo "ok=$ok" >> "$GITHUB_OUTPUT" | |
| # Reporting is auxiliary: an API blip must not red a preview that came up. | |
| - name: Report ready (deployment + comment) | |
| if: steps.outcome.outputs.ok == 'true' | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| # SHA + DEP come from the job env (announce job outputs). | |
| env: | |
| URL: ${{ steps.build.outputs.url }} | |
| BOX: ${{ steps.build.outputs.box_id }} | |
| PEN: ${{ steps.build.outputs.pen_id }} | |
| SWAP: ${{ steps.fe.outputs.swap }} | |
| # The hogland admin/console host (same value as the OIDC audience). | |
| CONSOLE_HOST: ${{ env.HOG_OIDC_AUDIENCE }} | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const url = process.env.URL; | |
| const environment = `preview-pr-${pr}`; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| // Honest wall-clock: run start (≈ the push) → now (usable). | |
| // The full pipeline time a reviewer actually waited, not just | |
| // the bring-up seconds — the FE build runs under it in parallel. | |
| const run = await github.rest.actions.getWorkflowRun({ ...context.repo, run_id: context.runId }); | |
| const startedAt = run.data.run_started_at || run.data.created_at; | |
| const secs = Math.max(0, Math.round((Date.now() - new Date(startedAt).getTime()) / 1000)); | |
| if (process.env.DEP) { | |
| await github.rest.repos.createDeploymentStatus({ | |
| ...context.repo, deployment_id: Number(process.env.DEP), state: 'success', | |
| environment, environment_url: url, log_url: runUrl, description: 'Preview ready', | |
| }); | |
| } | |
| // Only claim the PR's frontend when it was actually swapped | |
| // in; a backend-only preview serves the :master SPA, and | |
| // saying otherwise would be a correctness lie to the reviewer. | |
| const running = process.env.SWAP === 'true' | |
| ? "this PR's backend **and** frontend, on the PostHog `:master` base" | |
| : "this PR's backend on the PostHog `:master` base (frontend unchanged by this PR)"; | |
| // Admin/console link for the pen — lets folks inspect and | |
| // debug box state in hogland. Only when both the host and pen | |
| // id are known. | |
| // Guard the literal 'None' too — the CLI parser can surface | |
| // it as a string, and it would otherwise be truthy here. | |
| const pen = process.env.PEN; | |
| const consoleHost = process.env.CONSOLE_HOST; | |
| const adminUrl = (consoleHost && pen && pen !== 'None') | |
| ? `https://${consoleHost}/console/fleet/pens/${pen}` | |
| : null; | |
| const marker = '<!-- hogbox-preview-comment -->'; | |
| const body = | |
| `${marker}\n### 🦔 Hogbox preview · ✅ ready\n\n` + | |
| `### [▶ Open the preview](${url})\n\n` + | |
| `| | |\n|--|--|\n` + | |
| `| 🔑 **Login** | \`test@posthog.com\` / \`12345678\` (demo data) |\n` + | |
| `| 🧩 **Running** | ${running} |\n` + | |
| `| 🔗 **Link** | **stable across rebuilds** — a re-push swaps the box underneath, the URL stays |\n` + | |
| `| 🔒 **Access** | tailnet only (PostHog VPN) |\n` + | |
| (adminUrl ? `| 🛠️ **Admin** | [inspect & debug state in hogland](${adminUrl}) |\n` : ``) + | |
| `| 💤 **Idle** | sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen |\n\n` + | |
| `<sub>commit \`${(process.env.SHA || '').slice(0, 7)}\` · box \`${process.env.BOX}\` · ready in ${secs}s (push → usable) · ` + | |
| `<a href="${runUrl}">build log</a> · rebuilds on every push, torn down on close</sub>`; | |
| const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: pr }); | |
| const ex = comments.find(c => c.body.includes(marker)); | |
| if (ex) await github.rest.issues.updateComment({ ...context.repo, comment_id: ex.id, body }); | |
| else await github.rest.issues.createComment({ ...context.repo, issue_number: pr, body }); | |
| # Load-bearing: runs on the very failures the chain above swallows, so an | |
| # unguarded API call would hand the red X straight back. | |
| - name: Report failure (deployment + comment) | |
| if: steps.outcome.outputs.ok != 'true' | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const environment = `preview-pr-${pr}`; | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| if (process.env.DEP) { | |
| await github.rest.repos.createDeploymentStatus({ | |
| ...context.repo, deployment_id: Number(process.env.DEP), state: 'failure', | |
| environment, log_url: runUrl, description: 'Preview build failed', | |
| }); | |
| } | |
| const marker = '<!-- hogbox-preview-comment -->'; | |
| const body = | |
| `${marker}\n### 🦔 Hogbox preview · ❌ build failed\n\n` + | |
| `The preview didn't come up for commit \`${(process.env.SHA || '').slice(0, 7)}\`. ` + | |
| `See the **[build log](${runUrl})** for the failing step. It'll retry on the next push.\n\n` + | |
| `<sub>Previews are optional and never block merging. A failure here is often a hogland or tailnet ` + | |
| `hiccup rather than anything in your PR, so the check stays green and this comment is the status.</sub>`; | |
| const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: pr }); | |
| const ex = comments.find(c => c.body.includes(marker)); | |
| if (ex) await github.rest.issues.updateComment({ ...context.repo, comment_id: ex.id, body }); | |
| else await github.rest.issues.createComment({ ...context.repo, issue_number: pr, body }); | |
| # ---------------------------------------------------------------------------- | |
| # Fast-path teardown, decided by the `decide` job: either the `hogbox-preview` | |
| # label was removed (existing semantics) or the `no-preview` opt-out was added | |
| # to a PR with a live preview. Both destroy box + pen (idempotent — a no-op if | |
| # nothing is up). The daily stale-sweep in hogbox-preview-cleanup.yml is the | |
| # backstop; PR *close* teardown lives in pr-closed.yml. | |
| teardown: | |
| name: '[Optional] tear down preview' | |
| needs: decide | |
| if: needs.decide.outputs.teardown == 'true' | |
| # Share the preview job's per-PR group so the latest intent wins: a | |
| # teardown cancels an in-flight build for the same PR (and vice-versa), | |
| # instead of a build finishing and re-posting a preview the user just | |
| # opted out of. | |
| concurrency: | |
| group: hogbox-preview-${{ github.event.pull_request.number || github.event.inputs.pr_number }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| id-token: write # tailnet join + hogland token — no PR code in this job | |
| pull-requests: write # flip the sticky comment to torn-down | |
| deployments: write # mark the preview Deployment inactive | |
| steps: | |
| # Teardown runs the tool with the hogland token, so check it out from | |
| # the trusted default branch, not the PR's (possibly modified) code — | |
| # same reasoning as pr-closed.yml's cleanup. Only a destroy call is | |
| # needed, so master's copy is correct. | |
| # Fails open throughout: a missed destroy is reaped by the daily sweep in | |
| # hogbox-preview-cleanup.yml, so hogland being down never reds the PR. | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| continue-on-error: true | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| - name: Connect to Tailscale (tag:hogland-ci) | |
| continue-on-error: true | |
| uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4.1.2 | |
| with: | |
| oauth-client-id: ${{ vars.TS_HOGLAND_CI_CLIENT_ID }} | |
| audience: ${{ vars.TS_HOGLAND_CI_AUDIENCE }} | |
| tags: tag:hogland-ci | |
| - name: Mint hogland OIDC token | |
| continue-on-error: true | |
| # Same retry + empty-token guard as the deploy job: a mint that | |
| # silently yields an empty token here would make the destroy fail | |
| # with "no API token provided" and leak the box + pen. | |
| run: | | |
| token="" | |
| for attempt in 1 2 3; do | |
| token=$(curl -sf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${HOG_OIDC_AUDIENCE}" | jq -r '.value') || token="" | |
| [ -n "$token" ] && [ "$token" != "null" ] && break | |
| echo "::warning::OIDC mint attempt ${attempt} failed; retrying" | |
| [ "$attempt" -lt 3 ] && sleep 5 | |
| done | |
| [ -n "$token" ] && [ "$token" != "null" ] || { echo "::error::OIDC mint failed after 3 attempts"; exit 1; } | |
| echo "::add-mask::$token" | |
| echo "HOG_TOKEN=$token" >> "$GITHUB_ENV" | |
| - uses: ./.github/actions/setup-uv | |
| with: | |
| enable-cache: false | |
| continue-on-error: true | |
| - name: Destroy preview box + pen | |
| continue-on-error: true | |
| working-directory: tools/hogbox-preview | |
| run: uv run --no-project --with posthog-hogland==0.3.0 --python 3.12 python -m hogbox_preview --host "$HOG_HOST" --name "preview-pr-${PR}" destroy | |
| # Don't leave a green "ready" Deployment + comment pointing at a box | |
| # that no longer exists: mark every deployment of this PR's | |
| # environment inactive and flip the sticky comment. (The close path | |
| # gets the same treatment from pr-cleanup.yml's deployment reaper.) | |
| - name: Deactivate deployment + update comment | |
| if: always() | |
| continue-on-error: true | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| script: | | |
| const pr = Number(process.env.PR); | |
| const environment = `preview-pr-${pr}`; | |
| const deployments = await github.paginate(github.rest.repos.listDeployments, | |
| { ...context.repo, environment, per_page: 100 }); | |
| for (const d of deployments) { | |
| await github.rest.repos.createDeploymentStatus({ | |
| ...context.repo, deployment_id: d.id, state: 'inactive', | |
| environment, description: 'Preview torn down', | |
| }); | |
| } | |
| const marker = '<!-- hogbox-preview-comment -->'; | |
| const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: pr }); | |
| const ex = comments.find(c => c.body.includes(marker)); | |
| if (ex) { | |
| const body = | |
| `${marker}\n### 🦔 Hogbox preview · 💤 torn down\n\n` + | |
| `The preview for this PR was torn down (label removed or \`no-preview\` added). ` + | |
| `Re-add the \`hogbox-preview\` label to bring it back.`; | |
| await github.rest.issues.updateComment({ ...context.repo, comment_id: ex.id, body }); | |
| } |