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…
npx skills add https://github.com/posthog/posthog --skill authoring-ci-workflowsAuthoring 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.
Before you write
- Copy from a canonical file rather than from memory.
ci-paths-filter.ymlis the smallest complete example (triggers, concurrency, timeout, app token, Depot runner);ci-backend.ymlis the reference for the heavy patterns (bounded-depth checkout, per-SHA concurrency, draft/ready, sharding). - Related skills — reach for these instead of duplicating them here:
/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.
What the linters already enforce
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, and generic GHA correctness (bad secrets.* / needs: refs, deprecated ::set-output, unknown runner labels).
Third-party action digests are bumped by Renovate.
The dispatch budget (500 runs / 10s / repo)
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.
-
A reusable-workflow call counts as one run. Small always-fire PR workflows should be jobs under a single
workflow_callparent, not their own dispatches (seepr-updated.yml/pr-opened.ymlfolded behind their parent — fold pr housekeeping into one dispatch). Event-type scoping moves to job-levelif:guards:jobs: turbo: if: contains(fromJSON('["opened", "synchronize", "reopened"]'), github.event.action) uses: ./.github/workflows/ci-turbo.yml -
Prefer a trigger-level
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: -
Judgment call — trigger
paths:vs a runtimedorny/paths-filterjob. Use triggerpaths:for a workflow that is skippable as a whole. Never put a triggerpaths: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 achangesjob (also the right call when several jobs branch on different path sets). Heavy matrices (ci-backend,ci-nodejs) do exactly this — deliberate. -
Delete dead dispatchers outright. A disabled-but-still-triggered workflow keeps dispatching no-op runs against the cap — remove the trigger, don't just disable it.
Concurrency
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' }}
-
Cancel superseded PR runs; never cancel across master pushes.
-
Use
github.refas the fallback, nevergithub.run_id—run_idis unique per run, so it silently gives every push its own group and dedup is lost. -
Publish-on-push workflows must not let two master pushes race
:latest/ a deploy dispatch. Key the push arm per-SHA (seeci-backend.yml):group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.head_ref || github.ref }}
Checkout / clone — shallow by default
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.
-
Default: plain
actions/checkout(depth 1). Add nothing. -
Diffing against the PR base: bounded depth + blobless, then an explicit, scoped fetch (the sanctioned pattern, from
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" -
One file (e.g.
.nvmrcbeforesetup-node):sparse-checkoutit instead of cloning the repo. -
Foot-gun:
git fetch --deepen=Nwith no refspec falls back to the wildcardrefs/heads/*and pulls every branch. Always pass an explicit,--no-tags,--filter=blob:nonerefspec scoped to the base ref. (Bumpingactions/checkout's ownfetch-depthis safe — it uses a scopedrefs/pull/N/mergerefspec.) -
The linter rejects
fetch-depth: 0unless you addfilter: blob:none, usesparse-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 bounded1000 + blob:none.
Pinning and tool versions
- Pin every third-party action to a full 40-char commit SHA with a
# vX.Y.Zcomment. 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). - Node version comes from
.nvmrc—node-version-file: .nvmrc, never a hardcodednode-version:. Sparse-checkout.nvmrcif the job has no checkout. - Pin
setup-uv'sversion:— an unpinnedsetup-uvcalls the GitHub API on every job and burns the rate limit.
Tokens — dedicated App tokens for high-volume calls
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 }}
- Right-size, don't over-isolate. One heavy consumer (change detection on a hot matrix) deserves its own app; a long tail of light workflows can share
GITHUB_TOKEN. Convention:GH_APP_<PURPOSE>_APP_ID+GH_APP_<PURPOSE>_PRIVATE_KEY. - Cross-repo tokens set explicit
owner:+repositories:(least privilege). - Creating the app + secret is out of scope here — use
/managing-github-actions-secrets.
Forks and untrusted PRs (public repo)
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.
- Guard secret-needing steps with
if: github.event.pull_request.head.repo.full_name == github.repository, and degrade rather than fail (|| github.token, or the raw test outcome). - Secret-injecting builds (BuildKit
--secret, registry login) must skip forks — gate both thechangesjob and anyalways()build job (block fork PRs from rust image build). - Comment or label only on same-repo PRs — the fork token can't write.
- To act on a fork PR with secrets/write (reviewer or label bots), use
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 apull_requestparent. - First-time contributors need maintainer approval before workflows run (
action_required) — expected.
Timeouts
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.
Caching
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.
Runners
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.
Draft vs ready-for-review
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.
Backwards-compat with unrebased PRs
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.
New-workflow checklist
- Triggers scoped: trigger
paths:where the whole workflow is skippable; a required check must still fire on every PR (never paths-gate it into never dispatching). - Canonical
concurrency:block (per-SHA push arm if it publishes on push). -
timeout-minuteson every job (except reusable-caller jobs). - Checkout is shallow, or bounded
1000 + blob:nonefor base diffing. - Third-party actions SHA-pinned; Node from
.nvmrc;setup-uvversion pinned. - High-volume API calls on a dedicated App token with
|| github.tokenfork fallback. - Fork PRs handled: secret-needing steps guarded with the same-repo
if:; no secret-injecting build runs on forks. - Caching through the shared composites; writes gated to master.
- Prod image push / deploy dispatch gated per
/gating-production-deploys. -
bin/hogli lint:workflowsandactionlintpass locally.