Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

ailuracollective/actions

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.

The actions

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.

One Marketplace listing

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.

Versioning

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.

Why the status comment was a major

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.

Branch validation

jobs:
  branch-validation:
    runs-on: ubuntu-latest
    permissions: {}
    steps:
      - uses: ailuracollective/actions/branch-validation@v2
        with:
          branch-types: feat,fix,chore

permissions: {} 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.

Pull request policy

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 }}

Checks

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.

One status, every finding

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 status comment

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: false

The 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.

Who writes the comment

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: AiluraKitty

comment-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.

Two vocabularies, two inputs

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-change

With 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.

Linked issues, in GitHub or Linear

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-N is not actually closed by the keyword. GitHub closes #N on 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_request from 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.

Template resolution

The title's type resolves to a template by one rule, with no lookup table to maintain:

  1. <template-dir>/<type>.md, if it exists
  2. 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.

Exempt actors

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-change

Both 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. dependabot matches dependabot[bot] and nothing else — not dependabot-malicious-fork, and not robotics-team when you write bot. 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 a pass. 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, and lib/report.sh counts 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.

Issue triage

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.

PR template resolver

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.

Working on this hub

bash tests/run-checks.sh

Every 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 four layers of testing

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 if is 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.

Adding an action

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.sh

PRV_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.

Conventions

  • 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.sh fails 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.

Two costs of the hub, stated plainly

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.

When to stop and split

  • 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: write is off by default there for that reason.

Neither is true today.

About

Four composite GitHub Actions that gate contributions: branch naming, a linked approved issue, one type label, a conventional title, and a templated body. They read pull request metadata and never check out or run the code under review.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages