A hub of small GitHub Actions that enforce a contribution policy. Process gates, not CI: they read a pull request's metadata and its linked issues, and they never check out or run the code under review.
| Action | What it does | Adopt with |
|---|---|---|
| Branch validation | Requires the head branch to read <author>/<type>/<description> and to be owned by the PR author |
ailuracollective/actions/branch-validation@v2 |
| Pull request policy | Linked issue (GitHub #N or Linear TEAMKEY-N), type label, title shape and length, description structure |
ailuracollective/actions/pull-request@v2 |
| Issue triage | Applies a triage label to newly opened issues | ailuracollective/actions/triage@v2 |
| Pull request template resolver | Resolves a type's PR template and reports it through outputs, for use in a job of your own | ailuracollective/actions/pull-request-template@v2 |
The root action.yml is not in that table because it is not an action. It is the index the four are
listed under, and adopting it by mistake fails on purpose: a consumer who writes
ailuracollective/actions@v2 gets a failed run naming all four paths above.
They are separate because they answer different questions and need different permissions. Branch
naming needs no token at all, which is why its status comment is off by
default there. The PR policy needs contents: read and pull-requests: read — both are reads, and
publishing its status comment is done with a token of its own rather than by widening these. Issue
triage needs issues: write — the only one that writes, and the only one that cannot work on a
pull request from a fork, because a fork event carries no secrets. A consumer that only wants branch
naming should not have to grant the others.
GitHub builds at most one listing per repository, and only from an action.yml at the repository
root. The root manifest here is that listing: its name, Contribution policy, is the title the
repository appears under, and the four actions keep their own paths underneath it. The listing is an
index, not a fifth action — it declares no inputs, runs no check, and needs no token.
Adopting it by mistake fails loudly, on purpose. ailuracollective/actions@v2 resolves to the
index, the single step errors, and the job fails naming branch-validation, pull-request,
triage and pull-request-template. A green run there would have claimed a validation that never
happened.
This repository holds several actions, and a git tag versions the whole repository, so every action in it moves version together. There is no per-action version.
v2 is the moving major line. It carries the root index and all four directory actions, and every
one of them resolves at @v2 today. A change that breaks a consumer — the move of branch validation
out of the repository root was one, and the status comment's pull-requests: write requirement was
another — moves the whole repository to a new major rather than shipping under the current one.
Two refs, and they do different jobs. This repository publishes the moving major line, not patch-level tags:
| Ref | What it is | Use it when |
|---|---|---|
v2 |
A floating alias meaning "the latest 2.*" | You want fixes and new features without touching your workflow |
v1 |
The previous major, frozen at the commit before v2 |
You are not ready to grant pull-requests: write yet |
644733f… |
A commit SHA | You want the only truly immutable ref GitHub offers |
This follows GitHub's own guidance for actions: binding to a major version receives fixes while staying compatible, and a major version must guarantee compatibility. A change that breaks a consumer bumps the whole repository to the next major, never silently under a tag consumers already hold.
v2 is not a rename and not a repackaging. Everything on v1 still resolves and still behaves the
same, but the PR policy now publishes its report into the pull request conversation by default, and
that needs a permission v1 never asked for:
v1 |
v2 |
|
|---|---|---|
| Where the report is published | job summary only | job summary and one sticky comment per action |
| Permission to publish | none beyond what the checks already need | pull-requests: write on the calling job, or a comment-token that carries it |
| Token that writes it | — | comment-token, falling back to github-token |
The comment is on by default because the job summary is only where somebody is already looking at the
run, and reading a gate's verdict should not require opening a second tab. Turning it off is one
input — enable-status-comment: false — and that is the honest migration for a repository that would
rather not widen its permissions yet. v1 stays published for exactly that repository.
Two properties make this safe to ship as a default rather than as an opt-in. Every comment failure is a warning that leaves the verdict untouched, so a refused token cannot fail a pull request the gate found defects in, and cannot pass one either. And the comment refuses to publish under any identity but the one you name — see Who writes the comment — so a pull request never accumulates a status board nobody in the organisation can edit or delete.
Prefer the SHA. GitHub documents a full commit SHA as "the only way to use an action as an immutable release", because a tag can be moved or deleted by anyone who compromises this repository, and organization policies can require SHA pinning. A floating major is the convenient option, not the safe one.
Never reference a branch. A branch ref means anyone with push access decides what runs on your pull requests.
Independent per-action versioning would need real tooling and namespaced tags; it is not worth it while the actions ship together. See When to stop and split for the point at which that changes.
jobs:
branch-validation:
runs-on: ubuntu-latest
permissions: {}
steps:
- uses: ailuracollective/actions/branch-validation@v2
with:
branch-types: feat,fix,chorepermissions: {} is correct and worth keeping: this check reads nothing from the API, and its
status comment is off by default here for the same reason. Opt in with
the token the comment needs, and read Who writes the comment for why that
token is not the one the checks read with:
permissions: {}
steps:
- uses: ailuracollective/actions/branch-validation@v2
with:
branch-types: feat,fix,chore
enable-status-comment: true
comment-token: ${{ secrets.AILURA_KITTY_TOKEN }}A pull-requests: write grant also works and is the whole answer for a repository with no
organisation account to post as — but then the comment belongs to github-actions[bot], and this
action will refuse to post it unless you say so with comment-author: github-actions[bot].
It runs on opened only. GitHub cannot rename a branch an open pull request points at, so
head.ref is fixed for the life of the pull request, and re-validating it on every event would spend
runner minutes re-deriving a fact that cannot have changed.
on:
pull_request:
types: [opened, synchronize, reopened, edited, labeled, unlabeled]
jobs:
pr-policy:
runs-on: ubuntu-latest
# What the five checks need. Nothing here can change the pull request: they read labels, the
# base branch and the issue a `Closes` points at, and stop there.
permissions:
pull-requests: read
contents: read
steps:
- uses: ailuracollective/actions/pull-request@v2
with:
title-max: 80
type-labels: feat,fix,chore,breaking-change
enable-title-length: false
# Publishing the comment is a write, so it gets a token of its own. Optional: a job that
# grants `pull-requests: write` and says nothing here works too, at the cost of an author
# nobody in the organisation owns.
comment-token: ${{ secrets.AILURA_KITTY_TOKEN }}| Check | Requires | Disable with |
|---|---|---|
linked-issue |
A Closes/Fixes/Resolves pointing at an issue carrying the approved label, in GitHub or Linear |
enable-linked-issue |
type-label |
Exactly one label from type-labels |
enable-type-label |
pr-title-length |
title-min–title-max characters, counted as characters |
enable-title-length |
pr-title-conventional |
<type>(<scope>)!: <description>, case-insensitive, with <type> from title-types |
enable-title-conventional |
pr-body-structure |
Every ## heading declared by the title type's template |
enable-body-structure |
Every failure names the check, what is wrong, and the exact fix.
All five checks produce one GitHub check status. That is the price of a composite action, and three properties make it workable.
Every check runs, then the job fails. A failing check does not stop the others, so the author sees every defect in one run and fixes them in one push instead of discovering one per push.
The job summary is a table. Each run writes all five verdicts to the workflow run's job summary.
skipped is reported as skipped. pr-body-structure cannot resolve a template when the title
names no allowed type. It reports skipped and names pr-title-conventional as the owner, rather
than a green tick for a validation that never happened. The closing line of the summary reads
All N checks passed (M skipped, not run for this pull request): a skip is counted as a
non-failure, so a required status is still satisfied, but it is never folded into the passed count.
The job summary is only where somebody is looking while they are looking at the run. The same table is therefore published into the pull request conversation, where the author is already looking, and it is one comment per action, updated in place: the second run edits the first run's comment instead of adding another, and what stays on the pull request is always the current status. It is on by default for the PR policy, off by default for branch validation — that action exists so a consumer can validate branch names granting nothing at all — and either way is one input away:
steps:
- uses: ailuracollective/actions/pull-request@v2
with:
comment-token: ${{ secrets.AILURA_KITTY_TOKEN }}
enable-status-comment: falseThe body is the same rendering as the job summary — every check, its verdict and its detail, the counts, and a link to the workflow run — posted by Ailura Kitty, and preceded by an invisible HTML marker naming the action:
<!-- ailuracollective-actions:status action=pull-request run=1700000000 -->
That marker is what makes the three properties below hold.
- One action never overwrites another's comment. The key is the action's own directory name, and two actions run in separate jobs on the same pull request. A pull request that adopts both the PR policy and branch validation carries one comment per action, each with only its own checks.
- A comment anyone else wrote is never rewritten. Only a body that starts with the marker carrying a run id can have been written by this mechanism, so quoting the marker, or an earlier comment, in a reply is inert: the quoted text is left exactly as it was.
- A slower run cannot overwrite a newer result. Run ids only increase, so a run whose id is lower than the one recorded in the comment it found publishes nothing and says so in the log. Without that guard, the second of two concurrent runs to finish would leave its outdated table as the current status.
Publishing is an addition to the reporting, never a replacement for it: the job summary is written
first and is what this action guarantees, and every comment failure — a refused token, an API error, a
missing variable — is a warning that leaves the verdict untouched. A gate that cannot comment must
still fail the pull requests it found defects in, and a gate that passes must not be failed by a
comment. A refused token names pull-requests: write as the fix, and notes the case no permission
block can widen: a pull_request trigger from a fork gets a read-only token, so fork pull requests
get the job summary and no comment. The same limitation the Linear key already has.
Ailura Kitty, and the action refuses to post under any other identity.
A GitHub comment is authored by the account that owns the token that wrote it — the payload cannot
carry an author. The workflow's own token would therefore post as github-actions[bot]: nobody can
edit or delete what it wrote, and the comment outlives the person who could fix it. So the publishing
token belongs to the organisation:
jobs:
pr-policy:
runs-on: ubuntu-latest
permissions:
pull-requests: read
contents: read
steps:
- uses: ailuracollective/actions/pull-request@v2
with:
comment-token: ${{ secrets.AILURA_KITTY_TOKEN }}
comment-author: AiluraKittycomment-author defaults to AiluraKitty and is checked against gh api user before anything is
written. Two properties follow from making that a rule rather than a convention:
- Nothing is posted under an identity nobody chose. A token belonging to another account — a bot, a maintainer's personal token, a GitHub App installation — publishes nothing and warns, naming both logins and the three ways to resolve it.
- A token whose identity cannot be read publishes nothing either. An unreadable identity is not an identity that matched.
The writing token is comment-token, not github-token, and that split is the load-bearing part.
Publishing a comment is the only write this action makes, so it is the only call that needs a token
with the power to write; github-token still goes to the five checks, which only read, and stays
grantable as the read-only scope it is. An organisation's personal access token can post as a person
and can be spent anywhere — a stranger's pull request should not get to hold one while five scripts
judge it. Consumers who have no opinion about authorship pass neither input and get the behaviour
they had: comment-token falls back to github-token.
The token belongs in a secret. A with: value is echoed into the step's rendered command; an env
value is not, which is why the report reads GH_TOKEN from the environment and why the workflow
above passes the secret by name rather than by value.
Fork pull requests cannot comment at all, for the reason above: no secrets, so no token that could be Kitty's. They get the job summary, which is the reporting this hub guarantees.
Labels and titles are different sets, and one input cannot be both:
| Vocabulary | Size | Read by | |
|---|---|---|---|
type-labels |
What the repository actually creates. A contributor picks the nearest of a coarse family. | 5 in this organisation | type-label |
title-types |
Conventional Commits, which release tooling parses out of the squashed subject. | 12 | pr-title-conventional, pr-body-structure |
title-types defaults to type-labels, so a consumer that uses one vocabulary for both declares
nothing extra. Declare the second input only when they differ:
- uses: ailuracollective/actions/pull-request@v2
with:
type-labels: type/feature,type/bug,type/documentation,type/improvement,type/task
title-types: feat,fix,docs,chore,style,refactor,perf,test,build,ci,revert,breaking-changeWith that set, a pull request titled fix: … and labelled type/bug passes both checks and is
measured against .github/PULL_REQUEST_TEMPLATE/fix.md. Before title-types existed the two checks
could not both be satisfied: a label set of type/bug left the title grammar demanding a literal
type/bug: subject, and a label set of fix left the label check unsatisfiable on any repository
that does not carry a label named fix.
The shape of the reference picks the source: #N is GitHub, TEAMKEY-N is Linear. Both require a
closing keyword, so a stray identifier pasted out of a log never satisfies the gate. Linear is off
by default.
- uses: ailuracollective/actions/pull-request@v2
with:
linked-issue-sources: github,linear
linear-approved-label: approved
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}The key is a secret passed through env:, not a with: input, because a with: value is rendered
into the step's command. linear-approved-label works exactly like approved-label: the issue must
carry it, and a maintainer applies it during triage.
TEAMKEY-Nis not actually closed by the keyword. GitHub closes#Non merge; it has no idea what a Linear issue is. The keyword is still required so the gate reads the same in both worlds.- A missing key is a failure, not a skip, reported as a configuration defect naming the secret. A
pull_requestfrom a fork never receives secrets, so fork pull requests cannot be validated this way — the message says so rather than leaving a bare auth error. The gate is strongest against pull requests opened from branches, and only advisory against forks. - A rejected key is distinguished from an unreachable network. An HTTP 400/401/403 reports the key as invalid, expired, or lacking access to that team; a transport failure names the host.
The title's type resolves to a template by one rule, with no lookup table to maintain:
<template-dir>/<type>.md, if it exists- otherwise
default-template
Adding a template file is all it takes to give a type its own required sections. The type is read from
the title rather than from a label, which is what lets the two vocabularies differ and what keeps this
check independent of type-label: when a title's type is not allowed, that is the grammar check's
failure, and when a type cannot be used as a file name that is a workflow defect reported as one. Types
are validated as path segments before being joined into a path, so a misconfigured title-types input
is reported rather than escaping the template directory. The template is read from the base branch
through the contents API, and the action never runs actions/checkout: a job holding a token must not
put untrusted fork code on the runner.
dependabot[bot] is the case that forces this. Its branches read
dependabot/npm_and_yarn/pkg-1.2.3, and two independent rules reject them: the type segment
npm_and_yarn is not in branch-types, and the ownership rule compares the branch's first segment
(dependabot) with the author login (dependabot[bot]), which can never be equal. Dependabot's
configurable pull-request-branch-name.prefix fixes neither the missing type segment nor the
comparison, so the mechanism is an explicit list:
- uses: ailuracollective/actions/pull-request@v2
with:
skip-actors: dependabot[bot]
type-labels: feat,fix,chore,breaking-changeBoth the branch validation and the PR policy actions take skip-actors, and the default is empty:
nobody loses validation without opting in.
- The match is literal, whole-string and case-insensitive.
dependabotmatchesdependabot[bot]and nothing else — notdependabot-malicious-fork, and notrobotics-teamwhen you writebot. Entries are never patterns: a list you cannot read and verify is a list nobody verifies, and a substring rule is the one mistake that silently exempts the wrong pull requests. The tested identity is the pull request author (github.event.pull_request.user.login), not the actor that triggered the event. - An exemption is a
skip, never apass. The check did not run, so it must not render a green tick. The row names the exempt login, so the bypass is visible in the run's audit trail, andlib/report.shcounts a skip as a non-failure — a required status stays satisfied. - The exemption covers the whole action, not one check. A Dependabot pull request fails the linked-issue and type-label gates too, and per-check toggles are configuration nobody gets right. The event gate is still evaluated first, so a check that does not run reports its own not-applicable skip.
- Keep the list short and reviewed. Whoever can edit the workflow can also remove the action entirely, so the list is not a security boundary against a maintainer — it is a record of which automated accounts you chose to stop validating. Every entry is a policy decision, not a convenience.
on:
issues:
types: [opened]
jobs:
triage:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: ailuracollective/actions/triage@v2
with:
auto-label-name: status:needs-review--add-label is a no-op when the label is present, so this never duplicates it. Nothing removes it,
so a deliberate maintainer removal sticks. A missing label on the remote repository is a warning, not
a failure — but a refused token is reported as a configuration defect naming the issues: write
grant, because that is the mistake consumers actually make.
For consumers who want template resolution inside a job of their own, rather than the whole policy gate.
- uses: ailuracollective/actions/pull-request-template@v2
id: tpl
with:
type: feat
- run: echo "sections come from ${{ steps.tpl.outputs.path }}"Outputs: path, source (dedicated, default, or none when nothing was read), found, ref,
file. A missing template is a ::warning:: with found=false by default; set fail-on-missing: true for a hard failure. See its README for the ref fallback chain and why
it shares code with the PR policy instead of duplicating it.
bash tests/run-checks.shEvery check runs against the stubbed gh, curl and jq in tests/stubs/, with no network, no token and
no runner. The stubs live in tests/stubs/ and are driven entirely by env vars. The suite discovers
every action in the repository, so it grows as the hub does.
The assertion count is deliberately not written down. A number in this sentence is wrong the moment a
script is added, and a test suite that documents a stale count of itself is a small lie that
everybody stops reading. bash tests/run-checks.sh prints the real one.
action.yml the index: the one Marketplace listing for the four actions below
branch-validation/action.yml branch validation, one check
branch-validation/branch-name.sh its entry point, beside its own manifest
lib/common.sh shared module: result recording, event gating, escaping, parsers
lib/template.sh shared module: the template resolution rule
lib/comment.sh shared module: the sticky pull request comment, one per action
lib/report.sh shared module: the job-summary table and the aggregated exit
pull-request/action.yml PR policy, five checks
pull-request/<check>.sh its entry points
triage/action.yml issue triage
triage/auto-label.sh its entry point
pull-request-template/action.yml the standalone resolver
pull-request-template/resolve.sh its entry point
tests/run-checks.sh the harness; discovers every action
tests/stubs/ offline stand-ins
.shellcheckrc source resolution, so the shared modules are analysed
.yamllint.yml the two YAML rules this repository's content cannot satisfy
An action's exclusive scripts live in the action's own directory, beside its action.yml. The
repository root holds only the index manifest, what is shared — lib/ — plus one directory per
action. There is no scripts/ container, the root is the listing rather than an action, and none of
the four directories is more principal than another.
A check and an action can need the same rule without either importing the other's reporting contract,
so a shared rule goes in lib/ and both source it. What may not be duplicated is the rule, the
path-traversal guard, or the ref chain.
The suite above is the first layer and the cheapest. It is not sufficient on its own, because a shell suite cannot see a composite action, and the failure mode of trusting only it is a rule that is correct in isolation and never runs.
| Layer | What it is | What it can catch |
|---|---|---|
| Offline suite | bash tests/run-checks.sh, no network |
The rules themselves: parsers, the path guard, the ref chain, verdict recording |
| Static analysis | .github/workflows/checks.yml runs shellcheck, yamllint and actionlint |
Quoting and expansion mistakes, manifest shape, workflow expression and context errors |
| Self-validation | .github/workflows/self-validation.yml runs these actions, by directory path, against real pull requests |
Everything the first two cannot: a composite action that fails to resolve, a missing permissions grant, an event that never reaches the check, a stub that disagrees with the real API |
| Release smoke | Not automated | Whether the published tag still works, which a directory path by definition never tests |
The split between layer 1 and layer 3 is the one that matters. A directory path means the pull
request is validated by the code the pull request contains, so a change that breaks the policy fails
its own run — that is the signal, and it is why layer 3 uses ./<name> and not @v2. The cost is
that the published tag is untested by it, which is what the fourth layer is for. Run the smoke test
after a release: open a pull request, let it fail on purpose, and read the message as the author who
will receive it. Nothing in the suite checks whether a failure message is useful to a human.
Two deliberate asymmetries:
- Fork pull requests are skipped by layer 3. A directory path needs the head on the runner, and
the policy holds a token while it runs — the combination the action's own README forbids for forks.
The
ifis at workflow level so all three jobs agree. - The three linters are pinned by version and digest, not taken from the runner image. A required status that changes verdict when GitHub ships a new image is a status nobody trusts.
To set the labels layer 3 depends on, run the Bootstrap labels workflow once. The policy reads
labels by name and cannot create one, so a fresh repository starts unable to pass linked-issue.
The harness discovers actions rather than listing them, so a new action needs no test edits: the
suite finds every action.yml in any directory beside the root — the root manifest included — parses
it, and checks that
each step declares run and shell: bash, that every script a step invokes exists, that every
reporting action's check steps declare continue-on-error, that every module in lib/ is
actually used, and that the root manifest declares no inputs and invokes no script. A new directory
that breaks any of those fails the suite.
The root is reserved for the index. A new action goes in its own directory and never in the root: a fifth manifest there would be a second action competing with the listing, and the harness would reject any root manifest that grows an input or a check step.
1. <name>/action.yml. A composite action, same shape as any other here. Put the directory at the
repository root, not under an actions/ folder: the path in uses: is the directory path within the
repository, so actions/<name>/ would make consumers write the doubled
ailuracollective/actions/actions/<name>@v2.
2. <name>/<verb>.sh. The entry point, one per check if it has several:
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=../lib/common.sh
source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh"
prv_init my-check
prv_gate 'pull_request' || exit 0
prv_escape "${INPUT_THING}" # untrusted input is read quoted, and escaped before any command
prv_record pass 'looks good'One ../ hop, because an action's directory is a sibling of lib/. Invoke it as
bash ${{ github.action_path }}/my-check.sh, not as a bare path, so a consumer who lost the
executable bit on a clone or a zip download does not get a failure they cannot diagnose.
3. A report step, unless the action is a single step with nothing to aggregate. On a
pull_request trigger, the step also owns this action's sticky comment, which is what PRV_ACTION
selects and what the PR_NUMBER/GH_TOKEN/GH_REPO trio points it at:
- name: Report
if: always()
shell: bash
env:
RESULTS_DIR: ${{ runner.temp }}/prv-results
GITHUB_EVENT_NAME: ${{ github.event_name }}
PRV_ACTION: 'my-action'
PRV_PUBLISH_COMMENT: ${{ inputs.enable-status-comment }}
PRV_COMMENT_AUTHOR: ${{ inputs.comment-author }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ inputs.comment-token || inputs.github-token }}
GH_REPO: ${{ github.repository }}
PRV_TITLE: 'My action'
PRV_CHECKS: 'my-check|my-other-check'
PRV_LABELS: 'My check|My other check'
run: bash ${{ github.action_path }}/../lib/report.shPRV_ACTION is the action's own directory name, and it is the only thing telling this action's
comment from another's: two actions run in separate jobs on the same pull request, and each updates
only the comment carrying its own key. Omit it and nothing is published — the report says so as a
warning naming the variable, rather than posting a comment no later run could find. PR_NUMBER is not
a runner default, so it has to be passed; GITHUB_RUN_ID and GITHUB_REPOSITORY are, and are read
from the runner instead of from the manifest because they cannot be misconfigured.
Pass GITHUB_STEP_SUMMARY never. GitHub sets it in every step's environment, and a step-level
env: entry replaces that value rather than merging with it. The spelling
GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} looks like a pass-through and is not one: the
env context holds the workflow's own variables, not the runner's defaults, so it resolves to an
empty string and the step summary is never written. Every report step in this repository carried
that line from the initial commit, so the table reached the log and no job summary anywhere, with
nothing reporting the loss. lib/report.sh now warns when there is no summary path, and the manifest
group asserts that no step declares a runner default by reading it back out of the env context.
PRV_COMMENT_AUTHOR is the login the comment has to belong to, and the report checks the token
against it before writing anything: a comment is authored by whoever holds the writing token, so an
unheld identity means an anonymous github-actions[bot] comment nobody can later correct. See Who
writes the comment. Give the manifest a comment-token input and fall
back to github-token the way the line above does: the comment is the only write in the action, and
it is the only one that should ever see a token with the power to write.
4. <name>/README.md. Usage, outputs, and what the action does not do.
5. Tests. A group in tests/run-checks.sh exercising the entry point against the stubs. The
manifest, syntax and discovery checks come for free.
- Untrusted pull request input reaches a script only through
env:, is always read quoted, and is never spliced into script text. - A Linear identifier from a pull request body travels as a GraphQL variable, never inside the query text, and is shape-checked before it is placed in the JSON payload.
- User-controlled text in a workflow command is percent-escaped, and
%is escaped first. - No check script exits non-zero to signal failure. Each records a verdict and exits 0; only
lib/report.shfails the job, which is what makes aggregation possible. - Reporting is additive. The job summary is written first and is the reporting this hub guarantees; the status comment is published on top of it with its failures swallowed, so nothing about the pull request is decided by whether a comment could be written.
- A comment is published only under a chosen identity. The token's account is read and compared against the configured author, because a comment's author is the token's owner and an unheld one belongs to nobody who can edit it.
- A check that records nothing is reported as
error, never as a pass. - Every script uses
set -euo pipefail. - Comments are one line, and only where a competent editor would otherwise get it wrong: security invariants, non-obvious footguns, and facts not derivable from the code.
Tags are repository-wide. A git tag versions the whole repository, so every action in it moves version together. You cannot ship a breaking change to one action without moving the others. If the actions start releasing at genuinely different cadences, that becomes the bottleneck and the answer is separate repositories, not a better tag scheme.
An action that reads lib/ is not extractable. pull-request-template depends on lib/ at the repository
root, so copying pull-request-template/ into another repository does not work. It works here because a tag
download brings the whole repository. The alternative is inlining a second copy, which is what this
layout deliberately removed: an inlined copy cannot be tested against the same fixtures as the one it
duplicates.
- Actions release at different cadences. The shared tag starts forcing artificial releases.
- One action needs permissions another must not have. They cannot share a caller's single token
block, and least-privilege guidance stops being expressible. Triage and the PR policy are separated
for exactly this reason, and they are still in one repository because that cost is only paid when a
consumer adopts both. Branch validation is the clearest case: its check reads nothing, so it grants
nothing — and the status comment that would need
pull-requests: writeis off by default there for that reason.
Neither is true today.