Read the shared caller contract first.
For a PR targeting a protected base branch, gates on a Linear issue that Linear has linked
to that exact PR — not merely a TEAM-123-shaped string in the branch, title, or body. The
validator reads GitHub's protected property for the current base branch, so repositories can
have any number of protected branches without duplicating a branch list in the caller. A PR
targeting an unprotected branch skips without querying Linear or publishing a Linear ticket
status.
For a protected branch, the only thing that turns the check green is an attachment Linear
returns for the PR's canonical html_url (attachmentsForURL) whose issue satisfies the
configured team/state policy. Any supported linking method works: identifier in the branch
name, PR title, a Closes/Fixes/Resolves line in the body, or a manual link pasted into the
issue in Linear.
Identifiers extracted from PR text are used only to explain a red check ("you referenced
BE-1234 but Linear has not linked it") — they can never make it green. Automated checks buy
existence + linkage + traceability; relevance is still a code-review job.
The validator needs the Linear secret and a write token, which GitHub withholds from fork and
Dependabot PR runs. The fix is the standard pull_request → workflow_run split:
- an unprivileged signal workflow runs on the PR (
contents: read, no secret, checks out nothing), and - a privileged validate workflow runs on
workflow_run: completedof the signal workflow and calls this reusable, which GitHub grants the secret and a write token even for a fork PR.
The validate job runs on your default branch, so its job-level check is not the thing you
require in branch protection. This reusable publishes a commit status named Linear ticket
on the PR head SHA; you require that context once warn-only observation is done.
Important
The context was renamed from linear-ticket to Linear ticket. GitHub renders it verbatim
as the check's name, so it is prose now rather than a slug. Three consequences when you bump
past that pin:
- If your ruleset already requires
linear-ticket, update it in the same change. The old context is never reported again, so a required check nobody publishes leaves every PR pending — which blocks merges exactly as a failure would. (No caller required it at the time of the rename, which is why it was done then.) - Already-open PRs do not gain the new status on their own. The signal workflow fires on
pull_request, so moving the pin publishes nothing for a PR that is already open: until it takes a new commit, or someone re-runs its validate workflow, it carries the old context alone — there is no newLinear ticketbeside it. Require the new context before those PRs are re-validated and every one of them sits pending, so backfill deliberately (push or re-run) rather than waiting for head SHAs to turn over. - The stale
linear-ticketstatus survives, and it is not inert. Commit statuses are per-context and last-write-wins, so whatever value the old context last held stays on that head SHA — and it is frequentlysuccess, not the red you might picture (any pass, any exemption, and every outcome undersoft-fail: false). That matters twice over: whilelinear-ticketis still required, a stalesuccesskeeps satisfying the requirement even if the verdict has since changed underneath it (issue canceled, attachment removed, exempt label pulled,team-keystightened); and either value still counts toward that commit's combined status andstatusCheckRollup, which tooling reads — this repo's owngrade-pr-risk.shdoes. Droplinear-ticketfrom the ruleset in the same change rather than letting it age out.
secrets.LINEAR_API_TOKEN |
Required. The value placed verbatim into Linear's Authorization header — a personal API key raw, or an OAuth actor-auth access token prefixed with Bearer followed by one space. Missing/invalid fails the check closed as an infrastructure error (never green). |
| A Linear→GitHub integration | The workspace's GitHub integration must be connected so Linear creates the PR attachments this check reads. |
name: Linear Ticket Signal # this exact name is referenced by the validate workflow
on:
pull_request:
types: [opened, edited, reopened, ready_for_review, synchronize, labeled, unlabeled]
permissions:
contents: read
jobs:
signal:
runs-on: ubuntu-latest
steps:
- name: Signal
run: echo "Signalling the Linear ticket validator (no PR content is checked out)."The labeled/unlabeled types are required so adding or removing the exemption label reruns
the check. This workflow checks out nothing, runs no PR content, and emits no artifact — that
is what makes the workflow_run privilege boundary safe.
name: Linear Ticket
on:
workflow_run:
workflows: ["Linear Ticket Signal"] # must match the signal workflow's name exactly
types: [completed]
jobs:
validate:
permissions:
actions: read # read the triggering workflow_run
contents: read # checkout the validator scripts; PR code is never checked out
pull-requests: write # the one marker diagnostic comment
statuses: write # publish the `Linear ticket` commit status
uses: Comfy-Org/github-workflows/.github/workflows/linear-ticket.yml@<full-commit-sha> # v1
with:
workflows_ref: <same-full-commit-sha>
# team-keys: BE,ENG # optional; LEAVE EMPTY (default) unless you truly want
# # team scoping — a restricted list fails cross-team PRs
# exempt-label: linear-exempt # optional; empty disables exemption
# exempt-actors: dependabot[bot],renovate[bot] # optional; bot PRs skip the check
# exempt-paths: infrastructure/dynamicconfig/** # optional; a PR whose changed files
# # ALL match is exempt. Empty disables it.
# require-open-issue: true # default
enforce: false # WARN-ONLY during rollout; flip to true when ready
# soft-fail: true # default; warn-only still shows a RED (non-blocking)
# # check. Set false for a silent always-green pilot.
secrets:
LINEAR_API_TOKEN: ${{ secrets.LINEAR_API_TOKEN }}Do not use secrets: inherit — pass only LINEAR_API_TOKEN.
Then ask a maintainer to add your repo to the LINEAR_TICKET_CALLERS roster secret on
Comfy-Org/github-workflows — that roster is what keeps your pin current
(see Staying current). Enrolment does not backfill your pin:
ask them to workflow_dispatch bump-linear-ticket-callers.yml once after adding you.
actions: read
contents: read
pull-requests: write
statuses: writeGrant the whole union. A reusable workflow can only narrow the caller's token, and GitHub
validates the nested job's declared permissions: against this block at startup — before
any if: runs. A short grant fails the whole run with an opaque "workflow file issue" and no
job-level detail.
| Input | Default | Notes |
|---|---|---|
workflows_ref |
— (required) | Pin to the SAME full commit SHA as uses:. Validated to be a full 40-hex lowercase SHA and an ancestor of this repo's main before checkout — a branch, tag, short SHA, or fork-authored SHA fails the run. |
team-keys |
'' |
Comma-separated allow-list matched against the resolved issue's API team.key, never an identifier prefix. Leave empty (recommended) unless a repo genuinely wants team scoping — a restricted list fails a cross-team PR on first contact. Malformed or duplicate entries fail the run. |
exempt-label |
'' |
Single label that waives the requirement (recommended linear-exempt). Empty disables exemption. |
exempt-actors |
'' |
Comma-separated PR-author logins whose PRs skip the check without a label (e.g. dependabot[bot],renovate[bot]) — the non-manual hatch for bot PRs that never carry a ticket. Opt-in; empty means no bypass. Listing an actor means all of its PRs merge without a Linear ticket, so keep it to trusted automation accounts. |
exempt-paths |
'' |
Comma-separated path patterns. A PR is exempt when every changed file matches at least one pattern — one config file plus one code file is not exempt. The hatch for config-only chores (dynamic-config flips, version syncs) that arrive from many different authors, where exempt-label is manual per-PR and exempt-actors would waive the gate on all of that account's real work too. Empty (the default) disables it. Only * (does not cross /) and ** (crosses /) are wildcards; everything else is literal. ! negation and empty, duplicate, absolute or .. entries fail the run. See Why exempt-paths is an in-validator exemption. |
require-open-issue |
true |
Reject a linked issue whose Linear state.type is completed/canceled. backlog/unstarted/started/triage pass. |
enforce |
true |
false is warn-only: a failing verdict never exits the job nonzero. soft-fail decides how loudly it is reported; the diagnosis is identical either way. Warn-only is not a promise the job always exits 0 — a broken run still does (a failed terminal status write, a missing token/repo, malformed team-keys, a non-pull_request trigger). |
soft-fail |
true |
Warn-only only (ignored when enforce: true). true publishes a red failure status, so the PR's check list shows the check failing — loud, but non-blocking for as long as Linear ticket is not a required status in your ruleset. false restores the silent variant (warn-only publishes success; only the summary and comment carry the verdict). Do not require the Linear ticket context while enforce: false — required + soft-fail would block merges. |
The obvious way to stop a check running on config-only PRs is a paths-ignore: on the caller's
signal workflow. Do not do that. It is the same required-check trap the Gotchas section
below describes from the other direction.
Linear ticket is a commit status, and the whole point of the rollout is that your ruleset
eventually requires that context. A paths-ignore: stops the signal workflow firing, so the
validator never runs, so no Linear ticket status is ever published for that PR — and a required
status context that is never published does not "pass by omission". GitHub leaves it pending
forever, which blocks the merge exactly as a failure would, for precisely the PRs you were
trying to let through. The same reasoning is why an unprotected base branch is the only case
this workflow declines to publish a status at all: those PRs are outside the gate entirely, and
their base branch is not one your ruleset protects.
So the exemption is decided inside the validator, and an exempt PR takes the same
finish_exempt() exit the label and actor hatches take: a green Linear ticket status, a
job summary naming the reason, and no marker comment. The status is published, the required
context is satisfied, and the audit trail says why.
Three more properties worth knowing before you set it:
- It short-circuits before Linear is queried. An exempt PR spends no Linear API budget and
passes even while Linear is unreachable — same as
exempt-labelandexempt-actors. - It costs one extra GitHub API lookup,
GET /repos/{owner}/{repo}/pulls/{number}/files— paginated at 100 files per page, so it is one request per page, not one request for the whole list (a 250-file PR costs 3 requests; an accepted PR just under the 3000-file cap costs 30). That is GitHub-owned metadata, not PR content — nothing is checked out, and the filenames it returns are treated as untrusted data and only pattern-matched. Thepull-requests: writeyou already grant covers it; no new permission is needed. - It fails closed when the file list cannot be trusted. That endpoint caps at 3000 files
and truncates silently, so a PR at or past the cap — or any failed/malformed read — reports
a red
Could not verify — the PR's changed-file list is unreadable, rather than declaring a partial list fully matched. In practice this means a >3000-file PR in a repo that setsexempt-pathsfails the check as an infrastructure error even if it carries a perfectly good linked ticket; that is deliberate (a run must not publish a verdict it did not reach), and it is one more reason not to setexempt-pathsin a repo that does not need it.
A deliberate subset of GitHub's filter-pattern grammar, small enough to read at a glance:
** |
zero or more of any character, including / |
* |
zero or more characters, excluding / |
| everything else | literal — ?, [], + and . match themselves |
GitHub's own paths: grammar additionally reads ? and + as postfix quantifiers on the
preceding character, which is the opposite of what shell-glob habits expect. A half-remembered
quantifier inside a merge-gate waiver is how a pattern quietly matches more than its author
meant, so those are literal here instead. Every limitation of this grammar under-matches, and
under-matching leaves the ticket requirement in force — the safe direction.
Two consequences:
**is translated positionally, soa/**/b.yamlrequires both slashes literally and does not matcha/b.yaml. For a directory exemption writea/**, which is what you want.infrastructure/dynamicconfig/**does not matchinfrastructure/dynamicconfig-backup/x.yaml(the/before**is literal) and does not match the bare directory path either.
A rename contributes both its new path and its previous_filename, so moving a code file
into an exempted directory does not buy the exemption. A PR with zero changed files is never
exempt — "every file matches" must not be vacuously true on an empty or fully-reverted branch.
Warning
Nothing stops you writing exempt-paths: **, and nothing will tell you afterwards: it matches
every path, so every PR reports green and the gate is off while still looking configured.
Scope patterns to the directory the ticket-less chores actually live in.
soft-fail is new, and it defaults to true. If your caller already runs enforce: false,
taking a SHA bump past the commit that added this input changes what your PRs show: warn-only
used to publish a green status in every outcome, and now publishes a red one. (Under its
then-name linear-ticket — that bump predates the context rename above, so a caller still on an
older pin is looking for the old spelling.) Nothing about blocking changes — a status only
blocks when your ruleset requires that context — but the PR check list goes from green to red,
and anything you have keyed off the status (auto-merge bots, dashboards, other automation) sees
failure where it saw success.
Two things to do before you take the bump:
-
Check your ruleset — under the name your CURRENT pin publishes. If the status context is already a required check while you are still on
enforce: false, the red status will block merges. Search forlinear-ticketif you have not yet taken the rename bump andLinear ticketif you have; looking only for the new spelling on a pre-rename caller finds nothing and reports safe while the old requirement is live. That combination was always a misconfiguration (rollout step 6 is the last step for a reason), but soft-fail is what makes it bite. Remove the required check, or flipenforce: true— do not sit in between. -
Decide which pilot you want. Loud (the default) is recommended: authors fix links during the pilot instead of discovering the requirement on flip day. If you want the old behaviour, add one line to your caller:
enforce: false soft-fail: false # keep the pre-soft-fail behaviour: warn-only stays green
An absent SOFT_FAIL — which only happens if your uses: pin and workflows_ref have
skewed apart, so an older linear-ticket.yml is running newer scripts — is treated as
false, so pin skew alone can never flip you to red. Keep both pins on the same SHA anyway.
-
Land both caller files with
enforce: false, and leaveLinear ticketout of your ruleset's required checks — that omission, not the input, is what keeps the pilot non-blocking. Observe for ~a week: attachment latency, URL matching, retry counts, team/state outcomes, fork/Dependabot behaviour, and rate-limit headroom (the validator logsX-RateLimit-*headers).With the default
soft-fail: true, a warn-only failure shows on the PR as a redLinear ticketcheck plus the marker comment, which is what makes authors notice and fix links during the pilot instead of discovering the requirement on flip day. Nobody is blocked: a non-required failing check leaves the merge button live. If you want the quieter first pass, setsoft-fail: falseand the status stays green. -
Confirm the
Linear ticketstatus publishes on same-repo, fork, and Dependabot PR head SHAs, and that automatic (branch/title/body) and manual links both resolve to the same canonical URL. -
Track the stale-link case. Linking an issue in Linear after the check went red produces no GitHub event, so the status stays red until someone re-runs the workflow or edits the PR (the failure comment says this). Count how often that happens during the warn-only week. If it's rare, the re-run instruction is enough; if it's frequent, the fix is a
repository_dispatchdriven by a Linear webhook — not a larger retry budget — and is a deliberate v2 follow-up, out of scope here. -
Decide bot-PR handling: set
exempt-actors(e.g.dependabot[bot],renovate[bot]) so dependency PRs pass without hand-labelling each one. If the warn-only week also surfaces a recurring stream of ticket-less config-only chores from many different authors — the caseexempt-actorscannot express without waiving the gate on all of those accounts' real work — setexempt-pathsfor the directory those chores live in, and confirm one such PR shows a greenLinear ticketnaming the exemption before you move on. -
Flip
enforce: true. Leave the check non-required for a short window — the PR-visible result is unchanged from the soft-fail pilot; what changes is that the validator's own run now goes red too, so a systemic breakage is visible in the Actions tab. -
Add the
Linear ticketstatus context as a required check in every ruleset that protects a branch where tickets are required. Only now does anything block. The validator discovers every protected base branch dynamically; no branch list belongs in the workflow caller.
Nothing here changes branch protection automatically — the last step is a manual repo-admin action.
The required check is a commit status, not the job — and the two names now look alike. Your ruleset's check picker will offer both of these:
| pick | name | what it is |
|---|---|---|
| ✅ | Linear ticket |
the commit status this reusable publishes on the PR head SHA |
| ❌ | Linear Ticket / validate |
the validator's own job, which runs on your DEFAULT branch |
They differ only by capitalisation and the / validate suffix, so read carefully. Requiring the
job instead of the status is the classic mistake here: the job is not tied to the PR's head
commit, so the requirement is never satisfied on the PR and every merge hangs pending.
Protection is evaluated for the PR's current base branch. GitHub's branch response reports
protection from branch protection rules or rulesets. Retargeting a PR triggers a fresh run; an
unprotected target gets no Linear ticket status and no Linear API query. The status must be
absent rather than successful because GitHub scopes commit statuses to a SHA, not a PR: success
from an unprotected PR could otherwise overwrite failure on a protected PR sharing that commit.
A red check is not the same as a blocked merge. What blocks is your ruleset requiring the
Linear ticket context — nothing this workflow does. That is why warn-only can (and by default
does) publish a red status: it is a real, visible signal that costs nobody a merge. The
corollary is the footgun: do not add the context to your ruleset until you flip enforce: true,
or the warn-only pilot starts blocking.
A manual link created after a red run does not emit a GitHub event. After linking in Linear,
either re-run the failed validate workflow or edit the PR title/body to trigger a fresh
edited event. The failure comment says exactly this.
Snapshot, not continuous. A green check means the issue was linked and met policy when that commit's check ran. Moving the issue to Done later does not retroactively fail an existing check; the next PR event re-evaluates against current Linear state.
Fails closed on infrastructure errors. An auth/schema/timeout/rate-limit-exhaustion error is reported as an infrastructure failure (red in enforce mode), not as an invalid ticket — a broken credential must not silently disable the org-wide control. Availability is handled by bounded retry, per-branch concurrency, the warn-only rollout, and the auditable exemption label.
Store the token in the right form. Personal API key → raw. OAuth actor-auth token → prefixed
with Bearer followed by one space. The value is dropped straight into the Authorization header.