posthog/authoring-ci-workflows
> Use when adding or editing a GitHub Actions workflow, composite action, or reusable workflow under `.github/` — new CI jobs, triggers, matrices, checkout/clone tuning, action pinning, GitHub App token auth, concurrency groups, `timeout-minutes`, `paths` filters, caching, or runner choice. Not for debugging red CI (use debugging-ci-failures) or wiring a new secret end to end (use managing-github-actions-secrets).
npx skills add https://github.com/PostHog/posthog --skill authoring-ci-workflows
Conventions for .github/workflows/ and .github/actions/.
The linters own the mechanical rules (below); this skill is the judgment calls they can't enforce.
ci-paths-filter.yml is the smallest complete example (triggers, concurrency, timeout, app token, Depot runner);
ci-backend.yml is the reference for the heavy patterns (bounded-depth checkout, per-SHA concurrency, draft/ready, sharding).
/gating-production-deploys — any job that pushes a prod image or dispatches a Charts deploy./managing-github-actions-secrets — creating the GitHub App / secret a workflow reads./depot-github-runners — Depot runner labels and sizing./debugging-ci-failures — CI is red and you need to know why.Run bin/hogli lint:workflows and actionlint before pushing — they gate CI, and they (not this list) are the source of truth for what's enforced.
Today that's: timeout-minutes on every job, the canonical PR concurrency block, dorny/paths-filter negation safety, justification for full-depth checkouts, cache-write gating, semgrep service coverage, required-check gate hygiene, and generic GHA correctness (bad secrets.* / needs: refs, deprecated ::set-output, unknown runner labels).
Third-party action digests are bumped by Renovate.
GitHub caps _workflow-run dispatch_ at 500 runs per 10s per repo; overflow fails as startup_failure and takes unrelated runs in the same window down with it (a stack restack pushing many branches is the usual trigger).
Minimize runs dispatched, not just work done — draft status doesn't help, runs dispatch before skip logic applies.
Small always-fire PR workflows should be jobs under a single workflow_call parent, not their own dispatches (see pr-updated.yml / pr-opened.yml folded behind their parent — fold pr housekeeping into one dispatch).
Event-type scoping moves to job-level if: guards:
jobs:
turbo:
if: contains(fromJSON('["opened", "synchronize", "reopened"]'), github.event.action)
uses: ./.github/workflows/ci-turbo.yml
paths: filter over dispatch-then-skip: a run that only starts to no-op still spends a dispatch (gate container workflows on trigger paths). on:
pull_request:
paths:
- '.github/workflows/ci-x.yml'
- 'path/to/product/**'
workflow_dispatch:
paths: vs a runtime dorny/paths-filter job.Use trigger paths: for a workflow that is _skippable as a whole_.
Never put a trigger paths: on a workflow whose check is required by branch protection: a required check that doesn't dispatch on a PR leaves it stuck "waiting for status" and unmergeable.
Keep those firing on every PR and gate internally with a changes job (also the right call when several jobs branch on different path sets).
Heavy matrices (ci-backend, ci-nodejs) do exactly this — deliberate.
A disabled-but-still-triggered workflow keeps dispatching no-op runs against the cap — remove the trigger, don't just disable it.
Every PR-triggered workflow gets the canonical block:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
github.ref as the fallback, never github.run_id — run_id is unique per run, so it silently gives every push its own group and dedup is lost.:latest / a deploy dispatch.Key the push arm per-SHA (see ci-backend.yml):
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.head_ref || github.ref }}
The "gate" is the collate job that emits the required status check by reading needs.*.result.
By convention its display name ends in Pass (Django Tests Pass, Visual regression tests pass), but WF007 also finds gates structurally when a step reads needs.<dep>.result, because the convention is not universally followed.
A job that inspects results without gating anything opts out with # hogli-lint: not-a-required-gate — <reason> above the job key.
Gates and the workers they inspect need opposite conditions:
| Job | Condition | Why |
| ------- | ------------------ | ----------------------------------------------------------------------------- |
| Gate | if: always() | It must run and emit an explicit verdict, even when everything upstream died. |
| Workers | if: !cancelled() | So a superseded run actually stops instead of holding the concurrency slot. |
The gate condition must be exactly always(), with optional ${{ }} wrapping.
Adding another predicate can skip the required check, so always() && <condition> is rejected.
!cancelled() is identical to always() on any run that is not cancelled, so failure-path reporting still works; only cancelled runs skip.
Measured on a live superseded run (evidence): an always() worker dispatched and ran to completion _after_ the cancel, while the !cancelled() worker never started and reported cancelled (not skipped), so the gate still fails closed.
Four rules for the gate body:
success/skipped and fail everything else.A dependency tested only against == 'failure' lets cancelled through, and one bad dependency is enough — a gate that clears four correctly and one with a bare failure test is still wrong.
The trap is the changes detector: clearing it with == 'failure' and then reading needs.changes.outputs.* reports green on cancellation, because those outputs are empty and the gate takes its "nothing to test" exit.
needs every job that produces coverage.If a job's failure would only cascade into a downstream job being _skipped_, the gate reads that as a pass and you get a green check with zero tests run.
Name the upstream job explicitly.
One inline if per dependency is the clearest form, but a shared shell helper or an env: block is equally fine: WF007 traces each result through assignments, ${!var} indirection, and helper argument positions within that step.
The guard must compare with !=, join multiple allowed values with &&, and unconditionally exit 1 when entered.
Comparisons in another step, comments, logs, or branches that do not exit nonzero prove nothing and are rejected.
A result whose guard WF007 cannot follow is reported rather than assumed safe, so an unusual routing may need the checks moved inline.
WF007 enforces 1, 4, and the always() condition, and it takes the dependency list from needs: as well as the step body, so a job you wired into needs: and then forgot to test is reported rather than silently trusted.
The half of rule 2 it cannot check is whether you named the right jobs in needs: to begin with: "reporting job" and "coverage job" look identical to a linter, so that one is on you and the reviewer.
Full clones are slow and hang on degraded runners; blobs dominate clone size and are lazily fetchable.
Default to shallow; go deep only for real merge-base or version math, and even then bound the depth and filter blobs.
actions/checkout (depth 1). Add nothing.ci-backend.yml): - uses: actions/checkout@<sha> # v6
with:
fetch-depth: 1000
filter: blob:none
- name: Fetch PR base for affected diff
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
.nvmrc before setup-node): sparse-checkout it instead of cloning the repo.git fetch --deepen=N with no refspec falls back to the wildcard refs/heads/* and pulls _every branch_.Always pass an explicit, --no-tags, --filter=blob:none refspec scoped to the base ref.
(Bumping actions/checkout's own fetch-depth is safe — it uses a scoped refs/pull/N/merge refspec.)
fetch-depth: 0 unless you add filter: blob:none, use sparse-checkout, or justify it with # hogli-lint: allow-full-depth-checkout -- <reason>.Genuinely full-history jobs: repo mirroring (foss-sync.yml), tag/submodule version math (release-cli.yml).
Most base-diff jobs should use bounded 1000 + blob:none.
# vX.Y.Z comment.A moved tag can ship malicious code; pinning is also reproducible and skips a per-run GitHub-API version lookup.
The only sanctioned exception is a debug-only action.
In-repo composites use a local path with no ref (uses: ./.github/actions/pnpm-install).
.nvmrc — node-version-file: .nvmrc, never a hardcoded node-version:.Sparse-checkout .nvmrc if the job has no checkout.
setup-uv's version: — an unpinned setup-uv calls the GitHub API on every job and burns the rate limit.GITHUB_TOKEN shares one ~15k req/hr bucket across every job of every run in the repo; it goes hot at merge peaks and change-detection jobs fail before real work starts.
A dedicated GitHub App installation is its own bucket — rate-limit headroom plus blast-radius isolation.
- uses: actions/create-github-app-token@<sha> # v3.1.1
id: app-token
# forks can't read org secrets — fall back to github.token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
with:
client-id: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }}
# a later step consumes the token (falling back to github.token on forks):
- uses: some-action@<sha>
with:
token: ${{ steps.app-token.outputs.token || github.token }}
GITHUB_TOKEN.Convention: GH_APP_<PURPOSE>_APP_ID + GH_APP_<PURPOSE>_PRIVATE_KEY.
owner: + repositories: (least privilege)./managing-github-actions-secrets.Fork pull_request runs (and Dependabot) get a read-only GITHUB_TOKEN and no secrets.
Make those runs pass, and never let untrusted code reach a secret.
if: github.event.pull_request.head.repo.full_name == github.repository, and degrade rather than fail (|| github.token, or the raw test outcome).--secret, registry login) must skip forks — gate both the changes job and any always() build job (block fork PRs from rust image build).pull_request_target: base-repo permissions, but it must never check out and run fork code. That's why those workflows can't fold into a pull_request parent.action_required) — expected.Every job sets timeout-minutes, sized ~2-3x observed max; gate/aggregation jobs get ~5m.
The default is 6 hours — a hung job burns paid minutes silently.
Caveat: timeout-minutes is invalid on a job that only uses: a reusable workflow — put the timeout inside the called workflow instead.
Route through the shared composites rather than hand-rolling actions/cache: ./.github/actions/pnpm-install (single pnpm-<os>-<lockhash> key, save gated to master), astral-sh/setup-uv with enable-cache: true, Depot cache via ./.github/actions/build-n-cache-image.
One canonical key per artifact; gate saves to master or key deliberately per-ref.
PR-scoped cache writes nobody else can read just fragment the 10 GB LRU cap.
depot-ubuntu-<version>[-<vCPU>] for build/compute-heavy jobs (the -4/-8 suffix bumps CPU from the 2-vCPU default); GitHub-hosted for light jobs.
New Depot labels must be added to the allow-list in .github/actionlint.yaml or actionlint fails.
Details: /depot-github-runners.
Most commits land before a PR is marked ready, and drafts can't merge — so heavy suites should run a narrowed subset on drafts and the full matrix on ready_for_review (the merge gate).
Add ready_for_review to the pull_request types, and make aggregator "... Tests Pass" jobs treat skipped as success so drafts still report.
Foot-gun: if a select-tests job is cancelled mid-flight, its mode output is empty — normalize empty-mode on a draft to skip, or the draft grabs the full matrix and serializes the ready run behind it.
A workflow edit hits every open PR the instant it merges (it runs against PR-merged-with-master), but companion changes — a new dependency, file, or config — only reach a branch when it rebases.
If the workflow starts _requiring_ something unrebased branches lack, every in-flight PR fails before its tests run.
Make new behavior degrade gracefully when the prerequisite is absent, or gate it.
Roll out a new blocking lint the same way: ship continue-on-error, clear the inbox, promote to blocking.
paths: where the whole workflow is skippable; a required check must still fire on every PR (never paths-gate it into never dispatching).concurrency: block (per-SHA push arm if it publishes on push).timeout-minutes on every job (except reusable-caller jobs).1000 + blob:none for base diffing..nvmrc; setup-uv version pinned.|| github.token fork fallback.if:; no secret-injecting build runs on forks./gating-production-deploys.bin/hogli lint:workflows and actionlint pass locally.Take posthog/authoring-ci-workflows from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.