write-github-actions
Skill2 starsChanged 4 days agoMITView on GitHub
Write or review GitHub Actions workflows, composite actions, CI jobs, required checks, and rulesets across both organizations. Load before changing CI or governance configuration.
- Installs packages
In this repository40 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .agents/skills/AGENTS.mdAGENTS.md
- attribute-ai-commitsSkill
- check-accessSkill
- choose-dependencySkill
- coordinate-landingSkill
- develop-featureSkill
- document-codeSkill
- git-conventionsSkill
- implement-featureSkill
- manage-versionsSkill
- monitor-ciSkill
- open-pull-requestSkill
- plan-client-server-boundarySkill
- plan-featureSkill
- plan-ui-designSkill
- prefer-small-solutionsSkill
- prepare-releaseSkill
- register-ai-agentSkill
- resolve-pr-feedbackSkill
- triage-issuesSkill
- use-a-novel-cliSkill
- use-discordSkill
- write-bash-scriptsSkill
- write-design-systemSkill
- write-dockerfilesSkill
- write-frontendSkill
- write-frontend-testsSkill
- write-go-kitSkill
- write-go-serviceSkill
- write-goSkill
- write-go-testsSkill
- write-infraSkill
- write-js-packageSkill
- write-openapiSkill
- write-platformSkill
- write-project-docsSkill
- write-protoSkill
- write-sqlSkill
- write-svelteSkill
The file
We split this skill into the rules it gives an agent
12 rules. About 0 are common, 1 is distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 21
Before editing a Renovate preset or a repo's renovate.json, read Renovate presets.
- Very specific: line 111
Every run: step declares shell: bash explicitly — a composite action has no default shell.
- Very specific: line 115
Start each script with set -euo pipefail.
- Very specific: line 117
Check every tool an action installs against a pin its release cannot rewrite: whoever replaces a release's files replaces its checksums.txt …
- Very specific: line 139
Update the README catalog in the same change whenever a name or description moves.
- Very specific: line 219
Add a concurrency group with cancel-in-progress: true only where a superseded run's result is worthless, such as a lint-only workflow.
- Very specific: line 259
Keep the graph to three stages: cheap checks with no needs:, the gated heavy jobs, and the report-* jobs that read their outputs.
- Very specific: line 374
Every tag, master's builds since the last minor release and open pull requests' images stay, together with the manifests and attestations …
- Very specific: line 393
Write access is split by tier.
- Distinctive: line 396
Write also opens the dispatcher repo's branches, issues and other workflows to every member, which is settled: the gates below hold there …
- Very specific: line 522
Run the repository's prettier check (pnpm lint:stylecheck, or pnpm lint in the workflows repo) — workflow YAML is prettier-formatted like …
- Very specific: line 538
Never trigger release.yaml to test a change — it cuts a real release.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Writing GitHub Actions
CI is written across two surfaces. The shared building blocks are composite actions in
a-novel-kit/workflows, each at <group>/<name>/action.yaml under build-actions,
generic-actions, github-pages-actions, go-actions, node-actions, or publish-actions. Every
repo's .github/workflows/*.yaml then calls them, pinned to a release tag. Read the neighbours
before writing either — the patterns below are already in every file.
Scope. This skill owns authoring: workflow files, action manifests, and the repo CI config that
turns a job into a required check. monitor-ci owns watching a run and diagnosing a failure.
coordinate-landing owns the cross-repo landing saga and merge-queue semantics. manage-versions
owns releasing the workflows repo and re-pinning consumers. Point at them; do not restate them.
Before editing a Renovate preset or a repo's renovate.json, read
Renovate presets.
Choosing a surface
A composite action packages a step sequence that runs inside someone else's job. It cannot
declare permissions, cannot fan out across jobs, and has no early return — a guard that must stop
the work sets an output and every later step carries an if: on it (generic-actions/derive-status
threads a halted output that way).
A reusable workflow (.github/workflows/<name>-run.yaml, triggered by workflow_call) packages
whole jobs with their own permissions and secrets: block. auto-approve-dependabot-run.yaml is one: each
governed repo ships a thin caller so the engine lives in one place. Inside a reusable workflow, ./
resolves against the caller's checkout, so its nested actions must be remote-pinned.
A repo-local composite action (.github/actions/<name>/action.yml) holds a step sequence used by
several jobs in one repo and by no other repo — service-json-keys' run-migrations is the model.
A job calls it as uses: $/.github/actions/<name>, the self-repository form, which resolves at the
running commit without actions/checkout, on github.com with runner 2.336.0 or newer. A job still
checks out when a later step reads repository files.
Composite actions cannot reference vars, secrets, needs, or matrix
GitHub expression-evaluates the entire manifest when it loads a composite action: input
description fields, run: strings, bash comments inside them. Those contexts do not exist for a
composite action, so a literal ${{ vars.X }}, ${{ secrets.X }}, ${{ needs.* }}, ${{ matrix.* }}
or ${{ strategy.* }} anywhere in the file makes it fail to load with
Unrecognized named-value: 'vars' — including where it appears purely as documentation of the
caller's syntax. The available contexts are inputs, github, steps, runner, env,
job.container, plus toJSON and always() / success() / failure().
To document caller syntax inside an action, write the context name as plain text. merge-gate and
board-write describe their halt input as "threaded from the caller as
kill_switch: vars.AGENT_KILL_SWITCH" with no ${{ }} around it, and load fine.
The failure is invisible on the PR that introduces it. The workflows repo's own governance callers
stay pinned to the previous release tag while the PR is open, and its main.yaml exercises only the
node lane through ./node-actions/lint-node, so nothing loads the edited manifest. The breakage
surfaces the moment a consumer pins the new tag — which is how a vars reference in an input
description took merge-gate offline across both orgs until the next patch. The workflows repo's
pnpm lint now parses every composite manifest for these contexts
(.github/scripts/lint-action-manifests.mjs), and the required lint-js job runs it; keep it
passing rather than working around it.
Caller workflows carry ${{ vars.* }} and ${{ secrets.* }} legally. Thread the value into the
action as an ordinary input.
Writing a composite action
name: board-write
description: >
One-line purpose, then the rationale a caller needs. This text is the README catalog entry.
inputs:
client_id:
required: true
description: What it is and why the action needs it.
dry_run:
required: false
default: "false"
description: When "true", log the change that would be made without writing it.
outputs:
changed:
description: '"true" if the field was mutated.'
value: ${{ steps.write.outputs.changed }}
runs:
using: composite
steps:
- name: Write the field
id: write
shell: bash
env:
PROJECT_ID: ${{ inputs.project_id }}
run: |
set -euo pipefail
...- Input names are
snake_case, and every input carries adescription— GitHub prints it and the README catalog is transcribed from it by hand, so an inaccurate one propagates. - Booleans travel as the strings
"true"/"false"; there is no boolean input type for actions. - Every
run:step declaresshell: bashexplicitly — a composite action has no default shell. - Pass values into a script through
env:and read them as shell variables, rather than interpolating${{ inputs.x }}into the script body, so a value containing shell metacharacters is data instead of code. - Start each script with
set -euo pipefail. Report failures with::error::so they surface as annotations, and write operator-facing results to$GITHUB_STEP_SUMMARY. - Check every tool an action installs against a pin its release cannot rewrite: whoever replaces a
release's files replaces its
checksums.txttoo. Install a Go tool withgo install <module>@v<version>, which builds the source Go's checksum database recorded, asscan-secretsandrefresh-apko-locksdo. The step setsGOPROXY: "https://proxy.golang.org|direct":go installnever retries, and the pipe sends any proxy error to the origin, which the checksum database still checks. Every Go action step that fetches modules sets it too. A downloaded binary takes a callersha256input on the line afterversionand passessha256sum --checkbefore it runs, and a datasource in the base Renovate preset moves the hash with the version (release files pinned by hash). A container image runs by digest: the caller pinsversion: "<tag>@sha256:<digest>", as forlint-semgrepandlint-workflows, and the base preset moves the digest with the tag. - Reference a sibling action in the same repo by its pinned tag
(
a-novel-kit/workflows/generic-actions/pull-bot@v1.20.1). A relative./path would bind a released action to whatever sits onmaster, so a release could not be internally consistent. - Mint the narrowest App token the step needs:
actions/create-github-app-tokentakespermission-*inputs (permission-organization-projects: write), which bounds a leak of that token to the one operation. When reads span the organization but a write lands in one repository, mint the write as its own token withrepositories:, asmerge-gatedoes for its check. lint-workflowsaudits every manifest with zizmor. Fix a finding, or record an exception as# zizmor: ignore[<audit>] <reason>on the line the finding points to, such as theowner:line of an org-wide token. Arun: |line takes the comment for its whole block. The config'signorelist matches file names, and every manifest is calledaction.yaml.- Update the README catalog in the same change whenever a
nameordescriptionmoves. - Non-trivial bash in an action belongs under
tests/. The suites there extract the functions verbatim out of the manifest and run them against stubbed network leaves, so the shipped code is what executes and there is no fixture to drift.
Consumer-visible changes (a renamed input, a new preferred path) need a migration guide under
docs/migrations/; prepare-release owns sizing the release and writing it.
Writing a caller workflow
main.yaml is a repo's CI. Libraries, the stack and the workflows repo run it on every branch
push, ignoring tags:
on:
push:
tags-ignore: ["**"]
branches: ["**"]Services, platforms and infra run it on pull requests instead, so adding a label starts a run:
with-feature-env and the head-bound approval labels need one.
on:
push:
tags-ignore: ["**"]
branches: [master]
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
merge_group:On a pull request, github.sha is GitHub's temporary merge commit (refs/pull/N/merge), not the
pull request's head. Anything bound to a head uses github.event.pull_request.head.sha: a commit
status, a label approval, a proof. A job that must test exactly the approved commit checks it out
by that sha. pull-bot's default, github.head_ref, is the branch tip, which a push can move
mid-run.
Each job declares its own permissions block. Read-only is the floor (contents: read); image and
npm publishing add packages: write, attestations: write, id-token: write; a job whose action
mints its own App token declares permissions: {}, because the default token is never used. A
packages: write token writes the repository's packages from any ref, so no environment or ruleset
guards a registry; the attestation is what marks a release's output.
Pin every a-novel-kit/workflows reference to a release tag. Renovate groups the whole workflows
repo under one a-novel-kit workflows update, so a repo's references move together and must sit at
one version. Each release is immutable, so GitHub never lets its tag move, and a release stamps its
actions' references to their siblings with its own tag, which no commit can name.
Pin every third-party action, GitHub's own actions/* included, to its release commit, with the
version beside it: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. Many run
beside an App's private key. release-core hands the release App's key, which bypasses every
ruleset, to create-github-app-token, and its checkout keeps the minted token for every later step,
so a moved upstream tag would run new code there on the next release.
- Resolve a new action's commit with
gh api repos/<owner>/<repo>/commits/<tag> --jq .sha, and check that it is an immutable release or lies on the project's default branch. - The base Renovate preset keeps the pins current. It pins an action still referenced by tag to the commit that tag names and merges it on its own, since the code that runs stays the same. It moves the commit and the version together three days after a release, and holds a digest update, the same version on another commit, for review: that is a moved tag.
- The workflows repo's
pnpm lintrejects any other form in an action manifest. zizmor'sunpinned-usesaudit rejects it in workflows and composite actions, with"a-novel-kit/*": ref-pinand"*": hash-pin. - The org setting
sha_pinning_requiredstays off: it also checks the nesteda-novel-kit/workflowsreferences, which stay on tags.
vars.* and secrets.* belong here. The bot client ids are org variables: AGENT_BOT_CLIENT_ID
for governance work, DEPENDENCY_BOT_CLIENT_ID for the node lane and the audit fix. Their private
keys and BWS_READ_TOKEN are secrets of each repo's governance environment, which a-novel/infra's
GitHub root fills from Secret Manager. A job that reads one names
environment: {name: governance, deployment: false}; a job calling a reusable workflow passes the
secret by name, and the called job names the environment. The release App's key lives only in the
release environment. A platform's tag build reads its source-map upload key, set by hand, from a
sourcemaps environment. The GitHub root opens it on the same refs as
governance, with no reviewer, so a release still waits for one approval: the cut's.
Add a concurrency group with cancel-in-progress: true only where a superseded run's result is
worthless, such as a lint-only workflow. Anything that releases or deploys must run to completion.
platform-studio's main.yaml groups by
${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}. A pull request's
newer run cancels the older one, which could otherwise post a stale screenshot verdict last, while
every other run gets a group of its own and finishes. Cancel only where a newer head supersedes the
run, since a push moves the merge box to the new head. A cancelled job on the current head stays red
there, because the merge box shows each workflow's newest run. A gate that events on one head
re-trigger, such as gates.yaml on labels and reviews, queues with cancel-in-progress: false: its
runs never overlap, so the last one reads the latest state, and a newer event replaces the waiting run
before it starts. A cancelling group on a workflow that also triggers on labeled gives each label
event's run a group of its own, as infra's main.yaml does by appending
${{ endsWith(github.event.action, 'labeled') && format('-{0}', github.run_id) || '' }} to its group.
A pull request takes its labels in the second it opens, and GitHub can admit those runs to a shared
group out of order, which cancels the newest run and strands the required checks; monitor-ci covers
the diagnosis and recovery.
Each workflow an event matches starts a run of its own, and during a GitHub incident every run
waits in the same queue. Governance runs already far outnumber CI runs, so a new job joins the
workflow that answers its event: check-dependencies.yaml carries the with-feature-env
revocation. Checks that share a trigger run as steps of one job, as in gates.yaml, each with
if: ${{ !cancelled() && … }} so a failing gate leaves the others posting. A workflow_run
trigger that only some branches need lists them under branches:, since a job's if: still
starts the run. A required check that Actions posts keeps a workflow whose runs never skip it:
a newer run on the same head that skips the job reports skipped, which passes.
needs: carries data, plus one build gate
A needs: edge has two uses. The first is data flow: the downstream job consumes something
the upstream one produces, such as an image digest read as needs.build-database.outputs.digest, a
coverage artifact ID read as needs.test-go.outputs.artifact-id, or a file left on disk.
The second is a build gate in front of the expensive jobs. Every repo in both orgs draws on one
pool of runners, so a test or image job started on code that does not compile holds a runner that
another run is queued for. Gate the long jobs (tests, browser suites, container builds) on the cheap
jobs whose failure makes their result worthless: the build and the type check. platform-studio
gates its test-* jobs and build-platform on [build-node, lint-types].
Keep the graph to three stages: cheap checks with no needs:, the gated heavy jobs, and the
report-* jobs that read their outputs. Each stage adds its slowest job to every run, green runs
included, so chain no further. A test-go → build-* edge to keep untested code out of an image is
one stage too many: those images are untagged dev artifacts, and merge-gate keeps master
green.
Pick gates that finish within the slowest cheap job, so gating adds no wall time. Leave style, lint, audit and scan jobs out of the gate: their failures leave a test run valid, and gating on them would hide test failures until the next push.
A gate must be a required check that always runs. A job whose needs: failed or was skipped is
itself skipped, and GitHub counts skipped as passing. The tests block a merge only because their
gate is required and red. Never gate on a report-* job, a master-only job, or one with an if:
that can skip it, such as lint-translations: its skip would pass every job behind it.
Rewiring needs: is safe against the ruleset: required checks derive from the job list in
main.yaml (see below), not from the graph, so cutting an edge never changes a check context.
Adding or removing a job does.
Sequencing decisions are the skill's job, not the workflow's — do not restate this rationale as a
comment in a main.yaml. Comment what is specific to that file: why this postgres needs a longer
health timeout, why this job runs its binary twice. Reviewers get the general rule from here.
Job names are check contexts
A job's ID is its required-check context, so name it by lane: test-go, lint-go, lint-js,
test-js, lint-proto, generated-go, and the build-* / report-* families. A lane is named
after what it checks, so the JS lane is js, as the Go lane is go. A bare verb like test breaks
down in a repo holding both Go and JS, where two jobs would claim the name. Some repos still
emit a legacy bare test; new jobs use the lane form. Renaming a job changes its required check,
which the next section covers.
A job must also not take the name of a check-run the [Agent] App posts. gates.yaml names its job
evaluate for that reason — the job is only the runner, and a same-named job would collide with the
App's check.
Required checks and rulesets
a-novel/infra's GitHub root decides which jobs gate the default branch, as its
Repository config
describes. The required set is merge-gate, epic-freeze, review-gate and check-dependencies,
plus every job declared in the repo's .github/workflows/main.yaml, minus two exclusions: IDs
starting with report- (reporting and post-merge jobs), and jobs whose if: restricts them to
master, since a job that never runs on a PR cannot gate one.
Adding a job to main.yaml therefore adds a required check once it reaches the default branch: the
root's sweep reads changed workflows every 10 minutes. Renaming or removing one leaves its old
context required, and the pull request making that change never reports it. An admin merges that
pull request past the stale context, and the next sweep requires the new set. Coverage gates
nothing: no ruleset requires a Codecov status.
The retired-lines ruleset freezes the v<N> branches of past majors. Discovery reads the major
from the default branch's root package.json, and the ruleset covers v0 to v<major-1>: only
admins create, update or delete them, and nobody force-pushes. A v<N> branch at or above that major
develops a coming major and stays open, and a repository at major 0 carries no retired-lines.
The release-lines ruleset requires the same set on release/vX.Y branches. A backport runs those
checks from its line's own workflow files, so a check added after the line's tag never reports there
and an admin merges past it (manage-versions).
The rulesets themselves are code in the GitHub root, which fills in each repository's required
checks and bypass actors. The root is the whole configuration: its next apply reverts a setting
changed in the UI, and a pull request carrying allow-resource-deletion prunes a ruleset made there
(write-infra).
commit-messages checks the subject of every commit on every branch, except the merge queue's
gh-readonly-queue/**, against Conventional Commits. A squash merge lands with the PR title as its
subject and the branch's commit messages as its body, so both halves are checked. The rule is active,
so automation that commits must write Conventional subjects too (git-conventions). Commit metadata
rules like this one need GitHub Enterprise.
Two GitHub constraints shape the bypass lists:
- Merge-queue commits match no bypass actor. The queue writes them, not a person or an App, so every PR into a protected branch must satisfy the rules itself, a bot's PR included.
- A bypass actor must be part of the repo. Dependabot (App 29110) is
commit-messages' only bypass actor, and only on classes withsecurity.dependabot: true. On a repo Dependabot never ran in, GitHub rejects it with HTTP 422, "Actor Dependabot integration must be part of the ruleset source or owner organization". The release App, a bypass actor on every repo whose workflows name thereleaseenvironment, is installed on selected repositories, so a new releasing repo joins its installation in the org settings before a workflow namingreleasereaches its default branch.
A bypass label approves one PR head. A label that waives a check (allow-screenshot-change,
allow-incomplete-translations, allow-translation-drift, append-only-override, allow-dependency-change, infra's
allow-resource-deletion and allow-downtime) approves the commit it was reviewed on, so a push removes it and a reviewer
adds it again. The removal runs in a job holding pull-requests: write, and the label change re-runs
the gate. A gate reads labels live through the API, or ignores them on a synchronize payload, since
a rerun replays its original event. buf skip breaking is the exception: bufbuild/buf-action reads
it from the event payload, so it stays across pushes, and review-gate's approval of the head covers
a later push. check-append-only removes its label only when the step gets
github_token.
with-feature-env follows the same rule to publish rather than waive. build-actions/docker tags
an image only on a push to the default branch, a release, or a pull request carrying the label,
which gets the moving pr-<N> tag; every other build is pushed by digest alone for the jobs of its
run. The managed check-dependencies.yaml removes the label on the next push, so the tag keeps naming the
last head someone labeled. A repo's main.yaml must trigger on labeled for the label to build.
Services and platforms (clean_images: true) also get the managed clean-images.yaml, which deletes
images once the branch or pull request that built them is gone.
- Closing a pull request deletes its
pr-<N>image at once. - A daily sweep, which a-novel/infra's Cloud Scheduler dispatches, takes the rest: untagged builds and tags of closed pull requests, merge-queue builds, and old branch tags. It spares anything less than a day old, which a running job may still pull.
- The sweep also takes
master's untagged builds from before the last minor release, the newestvX.Y.0tag, whose image supersedes them. A patch release leaves that boundary in place. The boundary is the release commit's date, since a build is pushed after its commit lands: the comparison can keep a build too many, never drop one from after the release. Reading that commit is why the sweep job holdscontents: read.
Every tag, master's builds since the last minor release and open pull requests' images stay,
together with the manifests and attestations they reference. build-actions/clean-images reads a build's origin from its
org.opencontainers.image.version label. A build pushed without a tag has no version, so its
provenance attestation supplies the ref instead (refs/pull/<N>/merge). Infra does not set the flag,
since it pins its tool images by digest. A deleted version can be restored for 30 days.
The GitHub root also owns the Actions security settings: a read-only workflow token that never
approves a pull request, maintainer approval for every outside contributor's fork run, and access
to the org's scoped_secrets. A repo can read a scoped secret only once one of its default-branch
workflows names it and the sweep has run, so a pull request adding a secrets.X reference reads it
empty.
Pull requests are for collaborators: every class sets features.pull_requests: collaborators_only,
and outsiders use issues and discussions. GitHub Apps count as collaborators there, so Renovate and
the bots still open PRs. lock-closed.yaml locks an issue's or PR's conversation once it closes and
unlocks it on reopen. Open conversations stay unlocked: a locked conversation refuses a GitHub App's
review ("Issue is locked"), which would block the approval bot. An outsider's comment on an open
conversation is moderated by hand, and a bot that must comment on a closed one reopens it first.
Write access is split by tier. Developers hold Write, maintainers Maintain and owners Admin, each
through a <tier>-<class> area team that the GitHub root grants. Each tier team
also holds Write on its org's bot-comment dispatcher, so every member can post bot comments. That
Write also opens the dispatcher repo's branches, issues and other workflows to every member, which
is settled: the gates below hold there too. Anyone with Write can approve, label and dispatch, so
the tiers rest on gates rather than roles.
review-gate, required
everywhere, needs an approval of the head from a maintainer, an admin or the [Agent] App, and counts
a privileged label only when one of them applied it last; the list of privileged labels lives in the
gates.yaml template, mirrored by the Dependabot job's step. A dispatch-only workflow that
must stay with admins or maintainers runs generic-actions/require-role first: it checks
github.triggering_actor, because github.actor still names the original dispatcher on a re-run,
and the role name, because the legacy permission field reports Maintain as write. Another workflow
starts a gated one through workflow_run, whose run inherits the upstream run's triggering actor. A
dispatch with the workflow token runs as github-actions[bot], which the gate refuses. Never exempt
that bot: any writer's branch workflow can dispatch as it. approve-pr admits an admin, core
included through its org role, and only for a pull request that same triggering actor opened;
anyone else's pull request gets an ordinary review.
A workflow that reads the Agent key runs from the default branch's copy, which no pull request can
rewrite. pull_request, pull_request_review and merge_group run the pull request's own copy, so
the gates take pull_request_target, and pr-relay.yaml, which holds no permission and no secret,
carries reviews and merge groups to them through workflow_run. The privileged side treats a relay
run only as a wake-up signal: generic-actions/relay-context admits a real review or merge group and
reads the pull request's current head through the API. A job on these triggers never checks out or
runs pull request code.
From November 2, 2026, GitHub blocks pull_request_target on a public repository that no Actions
event policy covers. Each org therefore holds one org-level policy, allow-all-events, which
targets every repository and allows every event the policies API lists. It only lifts that default
block. An event policy is an allowlist, so never narrow it to the events in use: the next new
trigger would stay blocked until someone edits the org's settings. The GitHub root does not manage
it; an org admin recreates it with POST /orgs/{org}/actions/policies.
The governance environment is what makes that hold for any copy of a workflow. It admits the
default branch, plus release/v* lines and v* tags where the tags rulesets guard them, and
deployment: false keeps that check: a feature branch's job naming the environment is refused
before its first step, so a branch that brings back an old workflow reads no key.
The enterprise subscription is the only spend. Standard runners on public repos and the free security
features cost nothing; GitHub Code Quality bills per active committer and per AI credit on every
default-branch push, so the GitHub root keeps it off on every repository, with CodeQL's default
setup, and no repository carries a CodeQL workflow. The
scan-secrets and lint-workflows jobs do the static analysis. The services add lint-semgrep,
whose service ruleset checks their layered layout. A repository that no semgrep ruleset fits carries
no lint-semgrep job.
The governance workflows (gates.yaml, release-train.yaml, hotfix.yaml, approve-pr.yaml,
epic-rollback.yaml, recover-prs.yaml, check-dependencies.yaml, clean-images.yaml,
pr-relay.yaml, auto-approve-dependabot.yaml, lock-closed.yaml, notify.yaml,
notify-post.yaml, auto-assign-author.yaml) live in each repository, which owns its copies, its
.github/CODEOWNERS too. A new repository gets them from its class's template repository, or copies
them from a sibling when it starts empty. A change that concerns every repository lands in each copy,
one pull request per repository. A copy may still open with a "Managed by" banner naming the retired
command: delete it when you edit the file. What those workflows mean is coordinate-landing's
subject.
Two of them keep dependency PRs moving without a person. auto-approve-dependabot.yaml approves the
bots' PRs, withholding approval while renovate/stability-days is pending; its status trigger
approves once Renovate marks the update stable. It approves a PR on a renovate/ branch only when
Renovate itself put it on auto-merge, so an update a preset sets to automerge: false, such as a
major, waits for a maintainer or an admin. The first to enable auto-merge decides, so a re-armed PR
stays approved and a person taking it off auto-merge holds it. The bot's own chore/deps/ and
backport/ PRs need no such choice. recover-prs.yaml re-arms auto-merge on a PR the
merge queue dropped for checks_timed_out, and dispatches Renovate when a merge leaves a dependency
PR conflicting.
The auto-approver also withholds a first-party version without its release proof, in every mode.
generic-actions/verify-release-proof reads each first-party npm package a pnpm-lock.yaml adds and
each first-party image pinned by a release tag. It requires an attestation over the exact bytes from
the producer's release.yaml at the version's tag, on a GitHub-hosted runner, and an immutable Release
of that tag from the org's release App. Renovate therefore pins first-party images by digest. The
first-party age exemptions stay, since the proof makes waiting pointless. The job token makes the
reads the App cannot: statuses, contents, attestations and packages, all read. The approval
and its auto-merge name the commit the job checked, so a later push needs its own.
On a-novel/infra the auto-approver runs strict, because a merge there deploys production. A bot's
PR merges on its own only as a release pin, as a version update Renovate itself put on auto-merge
whose every changed line moves only a dependency reference's version, an OpenTofu version
argument included, or a digest with its version, or as a refresh of google-service-ranges.json alone. The refresh passes only when the file at the
approved commit equals the list generic-actions/google-service-ranges computes from Google's files
at that moment, so a bot can refresh the SMTP egress ranges but never widen them. Anything else,
Renovate's automerge: false updates included, waits for a maintainer's or an admin's review.
A person's PR posts to the Discord reviews feed alone, through notify-pr-run: a review request
pings its reviewers, a review or a drop from the merge queue pings its owner, and its merge posts
without a ping. Nothing posts its draft, assignee or reopen changes. A post that asks for a reviewer
ends with two commands: one requests the reader's own review, the other dispatches approve-pr for
an admin who opened the pull request.
Each repository has its own reviews and issues channels, whose webhooks are its own
REVIEWS_WEBHOOK and ISSUES_WEBHOOK secrets, so changing a channel sets one secret. A repository
secret wins over the org's of the same name, so a repository without its own channel posts to the
org's, which is the .github repo's own. The feeds that run in the repository read its secret
directly. The issues feed and the stuck feed's review post run in each org's .github, which cannot
read another repository's secrets: discord-dispatch hands each repository its posts in one dispatch
of its notify-post.yaml, which posts them in order and resolves their pings itself. A repository
without that workflow loses those posts, with a warning in the .github run.
A bot's PR notifies nobody on GitHub: it has no assignee, and CODEOWNERS leaves dependency manifests
and lockfiles unowned, so a version bump requests no review. A failing one reaches the Discord
dependencies feed instead, and so does one still green and out of the merge queue 2 hours after its
last commit (notify-stuck-run); when that one waits on a person's review, it posts to the reviews
feed too, pinging its reviewers. A person's manifest change (go.mod, a tool's .mod, go.work,
package.json, pnpm-workspace.yaml) is held by check-dependencies.yaml until a reviewer adds
allow-dependency-change; lockfiles pass. The always list requires that check everywhere, and a
ruleset cannot exempt the merge queue's actorless merges, so the check itself passes merge-queue groups
and a PR whose author and every commit are bots.
CI warnings reach the CI channel once a day. notify-warnings-run reads the warning annotations of
each org's default-branch runs and posts one line per distinct warning, whatever the number of
repositories raising it, and nothing on a clean day. A pull request run's warnings stay on the pull
request. An expected warning gets an ignore rule in the org's .github notify-warnings.yaml: a
reason, plus regular expressions for repo, job or message. The run's step summary counts what
each rule hid; delete a rule that keeps matching nothing. A test that runs code printing
::warning:: leaves a real annotation on its job, as the workflows repo's test-actions does.
Security-relevant audit-log entries reach the private security channel within half an hour:
notify-audit-run polls each org's log every 30 minutes, and each .github repo's
notify-audit.yaml holds the org's ignore rules for its routine shapes. An admin's own action can
alert, such as a member added in the browser. Before changing the feed or an ignore rule, or when
an alert needs an answer, read audit-log alerts.
Verifying a change
There is no local runner, so verification is reading plus CI.
- Run the repository's prettier check (
pnpm lint:stylecheck, orpnpm lintin the workflows repo) — workflow YAML is prettier-formatted like any other file, and an unformatted one fails the node lane. - Prove a refactor of an untested step against the old one. Extract both
run:scripts, stub the network CLIs (gh,curl,gcloud) as scripts that log their arguments, and diff stdout, the step summary, the outputs and the call logs across scenarios. For a deadline-bound loop, stubsleepand shorten the deadline in both copies. - For a composite action, grep the manifest for
${{ vars.,${{ secrets.,${{ needs.,${{ matrix.and${{ strategy.before pushing. The workflows repo'spnpm lintchecks this; a repo-local action under.github/actions/does not. lint-shell'srun_stepsinput shellchecks the Bashrun:steps of the workflows and composite actions it names, each as its own script. actionlint never reads a composite action, so a repo with them passes.github/actions/*/action.y*mlthere; the workflows repo passes all of its own. Its CONTRIBUTING.md reproduces the extraction locally.- A change to a shared action is not exercised by the workflows repo's own PR CI. It is proven when a
consumer re-pins to the released tag, which
manage-versionssequences. - Never trigger
release.yamlto test a change — it cuts a real release. Use itsdry_runinput. - Hand off to
monitor-cionce the branch is pushed.
Common pitfalls
- A
${{ vars.* }}or${{ secrets.* }}expression insideaction.yaml. The action fails to load for every consumer, and the PR that introduces it is green. Write the context as plain text and thread the value in as an input. uses: ./<group>/<action>inside a composite action. It resolves against whatever the consumer checked out. Reference the sibling by its pinned tag.- A bare-verb job ID.
testcollides across lanes and cannot be mapped to one; usetest-goortest-js. - A job named after an App-posted check.
merge-gateandepic-freezeare check-run names; a job of that name duplicates the context. - A missing
permissionsblock. The job then inherits the repository default, which is wider than it needs. Declare the block on every job,{}included. - One
gh apicall per item. Both organizations share the token's rate limit, and every call adds latency. Read the list once with--paginateandper_page=100, or one GraphQL query, then filter it with a singlejq. The REST default page holds 30 items. - A gate that can be skipped. Gating on a
report-*, master-only, orif:-guarded job skips every job behind it whenever the gate skips, and each skipped job passes its required check. @master, a floating ref, or a tag-pinned third-party action inuses:. CI stops being reproducible, a workflows release reaches consumers unannounced, and a moved upstream tag runs new code beside an App key.- Mixed workflows-repo tags in one repo. The actions ship as a unit; move every reference in the repo to the same tag.
- A
run:step in a composite action withoutshell: bash. The step fails to load; composite actions have no default shell. - Fixing one copy of a governance workflow. Each repository owns its copy, so a fix every repository needs lands in each one.
- Expecting a new
main.yamljob to gate its own pull request. It gates nothing until the GitHub root's sweep reads it from the default branch. - Another bot's branch under
renovate/. Renovate prunes the branches under its prefix that its configuration does not produce, and closes their PRs. The Dependencies App's own PRs usechore/deps/, whichnotify.yamlwatches like Renovate's branches. - A bot commit made with
git commitand pushed. Themasterandrelease-linesrulesets require signed commits, and GitHub signs only what an App creates through its API with no custom author. A pushed commit is unsigned, so its PR staysBLOCKEDwith every check green. Build it with the Git Data API (git/trees,git/commits, then the ref), asrefresh-apko-locksdoes. When only git can make the change, push it, then recreate the commit from its pushed tree, asbackport-run.yamldoes with a cherry-pick. - A
schedule:trigger on anything that must run on time. GitHub starts scheduled workflows 4 to 7 hours late (a-novel-kit/.github#288). A.githubrepo's timed workflow takesworkflow_dispatchonly, and its cron moves togithub_dispatch_schedulesin a-novel/infra's bootstrap, where a Cloud Scheduler job dispatches it in each org. A poll reads the window since its caller's last successful run, asnotify-stuck-rundoes, so a missed tick is caught up instead of lost.token-expirywarns before a dispatch token lapses, the likeliest way the clock stops. lfs: trueon a checkout whose job reads a few LFS files. The checkout downloads every LFS object at the ref. Limit it in the step'senv, which actions/checkout passes to git:GIT_CONFIG_COUNT: 1,GIT_CONFIG_KEY_0: lfs.fetchinclude, andGIT_CONFIG_VALUE_0: <pattern>. A second checkout over an existing tree leaves its pointer files in place; rungit lfs checkoutafter it. uikit'smain.yamllint-jsdoes both.- An artifact another job downloads, uploaded without
overwrite: true. A re-run uploads it again under the same name.download-artifactkeeps the highest-ID artifact of each name, and a re-run's upload can carry a lower ID than an earlier attempt's, so the download misses the re-run's copy. The coverage uploads intest-go,test-nodeandtest-playwrightset it. - Interpolating an input straight into a
run:script. Bind it throughenv:so the value cannot be read as shell syntax. - A startup requirement added to one smoke run.
build-actions/dockerstarts the image it built withrun_argsand pushes it only once its healthcheck passes. A service runs that smoke run inmain.yamland again inrelease.yaml, which pull-request CI never runs. When a binary starts to require a setting, grep every workflow for its Dockerfile and update eachrun_args: json-keys v2.15.1 and genai v0.8.5 were tagged without a gRPC image, since onlymain.yamlpassed theSERVICE_ISSUER_OPTIONALopt-out of the newSERVICE_TOKEN_ISSUERcheck. - A cached image build that a secret should change.
build-actions/dockerrestores the layer cache that default-branch pushes write, and BuildKit leaves secrets out of cache keys. The cut pushes a release commit tomasterwith its tag, so the tag's build can reuse master's layer of the same tree, built without the secret, and skip the step the secret drives without failing. A release build whose secret changes what a step does, such as a platform's source-map upload, setscache: "false". - A
pnpm <script>step in an action without reconciling the lockfile first. pnpm 11's verify-deps pre-run hook spawns a frozenpnpm installahead of the script and abortsERR_PNPM_LOCKFILE_CONFIG_MISMATCHwhenever an earlier step (e.g.pnpm audit --fix=override) rewrotepnpm-workspace.yamlbut left the lockfile stale.npm_config_frozen_lockfile: "false"does not rescue this path — the pre-run install runs frozen regardless. Reconcile withpnpm i --no-frozen-lockfilebefore anypnpm <script>. - Interpolating an unvalidated value into a
search(...)/gh searchquery. GitHub answers a malformedmerged:/closed:qualifier — a bad instant, or a relative word likeyesterday— with zero rows and noerrorsarray, indistinguishable from a genuinely empty result, so a well-formed-response guard waves it through. In the landing-saga actions an emptymergedbucket reads as "nothing landed" and lifts a standing freeze. These qualifiers take a full ISO-8601 instant (second granularity); validate every interpolated value to an exact canonical form (strict regex plus a calendar parse for dates) and drop an unusable one for the unscoped query rather than pass it through — over-detecting is the safe direction, a silent all-clear is not.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

