-
Notifications
You must be signed in to change notification settings - Fork 1
267 lines (262 loc) · 15.9 KB
/
Copy pathlinear-ticket.yml
File metadata and controls
267 lines (262 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
name: Linear Ticket (reusable)
# Reusable gate that requires PRs targeting a protected branch to carry a Linear issue Linear
# has LINKED to that exact PR before they can merge (design: pr-linear-ticket-enforcement).
# PRs targeting an unprotected branch skip without querying Linear or publishing this context.
# The only thing that passes the ticket gate is an attachment Linear returns for this PR's
# canonical html_url whose issue meets the configured team/state policy — a `TEAM-123`-shaped
# string in author text never does.
#
# TWO-STAGE, FORK-SAFE EXECUTION. This privileged validator is invoked by a caller that runs
# on `workflow_run: completed` of an unprivileged, no-op signal workflow in the consumer repo.
# GitHub gives THIS run the caller's write token and the Linear secret even when the signal
# run (a fork or Dependabot PR) had neither. Non-negotiable constraints (design §6):
# * the signal workflow checks out nothing, runs no PR content, emits no artifact/cache;
# * this validator never downloads that run's artifacts/caches and never checks out the PR
# branch or merge commit — it reads only GitHub-owned data and Linear's API;
# * every PR-derived value (branch, title, body, labels, URL, changed-file paths) is
# untrusted DATA, passed through env / GraphQL variables, never shell-interpolated.
# The `exempt-paths` changed-file lookup reads GitHub-owned metadata (the pulls/{n}/files
# endpoint), NOT the PR branch, so it stays inside those constraints; the filenames it returns
# are still author-controlled strings and are only ever pattern-MATCHED.
# The job runs on the DEFAULT branch, so its job-level check must NOT be the required merge
# check. Instead validate.sh publishes the `Linear ticket` commit status on the refetched PR
# head SHA; branch protection requires THAT context. See docs/callers/linear-ticket.md.
#
# REPORTING MODES — how loudly a failure is reported. This workflow NEVER decides whether a PR
# can merge; branch protection does, by requiring the `Linear ticket` context or not:
# enforce: true red status, job exits nonzero. Gating (once required).
# enforce: false red status, job stays green on a failing VERDICT (a
# (soft-fail: true, the default) broken run still exits nonzero). Shows on the PR exactly
# like enforce does but blocks nothing while the context is
# not required — the loud pilot rung, since a green check
# nobody reads teaches the repo nothing during observation.
# enforce: false, soft-fail: false green status; only the job summary and the marker comment
# carry the verdict. The silent variant.
#
# PERMISSIONS the caller must grant (the single job's union — GitHub validates the grant at
# STARTUP, before any `if:`):
# actions: read # read the triggering workflow_run
# contents: read # checkout THIS repo's scripts (no PR code is ever checked out)
# pull-requests: write # the one marker diagnostic comment
# statuses: write # publish the `Linear ticket` commit status
#
# SECRET: LINEAR_API_TOKEN (required). Store the value exactly as it goes into Linear's
# Authorization header — a personal API key raw, an OAuth actor-auth access token prefixed
# with `Bearer `. Missing/invalid fails the check CLOSED as an infrastructure error, never
# green (design §7).
#
# Caller pattern: docs/callers/linear-ticket.md (the signal + validate pair). Do not use
# `secrets: inherit`; pass only LINEAR_API_TOKEN.
on:
workflow_call:
inputs:
workflows_ref:
description: >-
Full 40-hex commit SHA of THIS repo to load the validator scripts from. Pin to the
SAME SHA as the caller's `uses:` — a floating ref would run new scripts under an old
workflow. Required; validated to be a full lowercase SHA and an ancestor of this
repo's main before checkout.
type: string
required: true
team-keys:
description: >-
Optional comma-separated allow-list of Linear team keys (matched against the
resolved issue's API team.key, never an identifier prefix). Empty (default) accepts
a linked issue from any team visible to the token. Malformed or duplicate entries
fail the run.
type: string
required: false
default: ''
exempt-label:
description: >-
Optional single label that waives the requirement (recommended `linear-exempt`).
Empty disables exemption. Adding/removing it reruns the check.
type: string
required: false
default: ''
exempt-actors:
description: >-
Optional 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 and EMPTY by default — there is no built-in bot bypass; a
repo that lists an actor here accepts that all of that account's PRs merge without a
Linear ticket.
type: string
required: false
default: ''
exempt-paths:
description: >-
Optional comma-separated path patterns; a PR is exempt when EVERY changed file
matches at least one of them (mirroring `paths-ignore` semantics: one config file
plus one code file is NOT exempt). The hatch for config-only chores — dynamic-config
flips, version syncs — that come from many different authors, so neither
`exempt-label` (manual, per PR) nor `exempt-actors` (waives that account's real work
too) fits. Empty (default) disables it, leaving every existing caller byte-identical.
Only `*` (not across `/`) and `**` (across `/`) are wildcards; everything else is
literal, `!` negation is rejected, and empty/duplicate/absolute/`..` entries fail the
run. This is an IN-VALIDATOR exemption, deliberately not a `paths-ignore:` on the
signal workflow: a filtered-out signal publishes no `Linear ticket` status at all, and
a required context that is never published leaves the PR pending forever.
type: string
required: false
default: ''
require-open-issue:
description: >-
When true (default), reject a linked issue whose Linear state.type is
completed/canceled. backlog/unstarted/started/triage pass.
type: boolean
required: false
default: true
enforce:
description: >-
true (default) fails closed on a missing/invalid link or infrastructure error, and
the job itself exits nonzero. false is warn-only: a failing VERDICT never exits the
job nonzero, and `soft-fail` decides how loudly it is reported. 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).
type: boolean
required: false
default: true
soft-fail:
description: >-
Warn-only only (ignored when `enforce: true`). true (default) publishes a red
`failure` commit 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 the repo's
branch protection, which is exactly what a warn-only pilot means. The marker comment
says so. false restores the silent variant: warn-only publishes `success` and only
the job summary and comment carry the verdict.
BEHAVIOUR CHANGE for callers already on `enforce: false`: before this input existed
warn-only always published `success`, so taking a SHA bump past this commit flips
those callers to a red (still non-required) status with no caller-side edit. Set
`soft-fail: false` in the caller to keep the old always-green behaviour.
FOOTGUN: `enforce: false` + `soft-fail: true` + `Linear ticket` already required in
branch protection WOULD block merges. Do not require the context before flipping
`enforce: true`.
type: boolean
required: false
default: true
secrets:
LINEAR_API_TOKEN:
description: >-
Value placed verbatim into Linear's Authorization header (personal key raw, or
`Bearer <oauth-token>`). Required; a missing token fails the check as an
infrastructure error.
required: true
permissions: {}
concurrency:
# One validation per PR branch; a newer push/edit supersedes the in-flight run, and
# validate.sh additionally refetches the head SHA before any terminal status write.
group: linear-ticket-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
validate:
# Only a completed run of the signal workflow should reach here (the caller filters the
# workflow name + `types: [completed]`); guard the conclusion so a failed/cancelled signal
# run does not trigger a spurious validation.
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
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
steps:
- name: Require a pinned workflows_ref
# workflows_ref runs THIS repo's scripts in a job holding the caller's write token +
# the LINEAR_API_TOKEN secret, so it is a code-execution trust boundary. GitHub does
# NOT enforce `required: true` for workflow_call inputs (an omitted input arrives as ''
# and checkout silently takes the default branch), so validate on TWO axes before
# checkout:
# SHAPE — a full 40-hex lowercase SHA; rejects everything mutable (branch, tag,
# refs/pull/N/head), which could skew between jobs mid-run.
# ANCESTRY — the SHA is reachable from THIS repo's upstream main. Shape proves the
# value is immutable, not WHICH repo authored it: a fork of this PUBLIC
# repo shares the object store and GitHub serves a fork PR's head objects
# from this repo's URL unauthenticated, so a fork-authored 40-hex SHA is
# just as well-shaped and would otherwise run with the caller's token. Only
# merged upstream history may be pinned; no opt-out. Fails CLOSED on infra
# errors. Mirrors pr-area-label.yml / pr-risk.yml. See BE-5546.
env:
WORKFLOWS_REF: ${{ inputs.workflows_ref }}
run: |
set -euo pipefail
# actions/checkout trims `ref`, so strip whitespace before the -z test. Echo only a
# sanitized, bounded form so a multi-line value can't forge ::workflow commands:: in
# this PUBLIC log.
REF="$(printf '%s' "$WORKFLOWS_REF" | tr -d '[:space:]')"
if [ -z "$REF" ]; then
echo "::error::workflows_ref is required; pin it to the same commit SHA as the uses: line (see the caller doc)"
exit 1
fi
safe_ref=$(printf '%s' "$REF" | tr -c 'A-Za-z0-9._/-' '?'); safe_ref=${safe_ref:0:64}
# Length + character-class, not `grep -Eq '^..$'`: grep anchors per LINE, so a
# multi-line value carrying one SHA-shaped line would pass. Lowercase only — the value
# is handed to checkout verbatim and everything that emits a SHA emits lowercase.
if [ "${#REF}" -ne 40 ] || [ -n "${REF//[0-9a-f]/}" ]; then
echo "::error::workflows_ref must be a full 40-hex lowercase commit SHA of Comfy-Org/github-workflows (got '${safe_ref}'). Branches, tags and PR-head refs are mutable and are rejected before checkout."
exit 1
fi
# ANCESTRY. The upstream URL is a LITERAL: inside a reusable workflow no github context
# names this repo, so a variable would be an alias a fork could point at itself. Two
# bounded fetches (--filter=blob:none — no file content): depth-1 proves the SHA is in
# this repo's object network; the second brings main to walk down to the pin. The fetch
# retries (a transient github.com error must not red the fleet); the verdict does not.
scratch=$(mktemp -d) || { echo "::error::ancestry check for workflows_ref (${safe_ref}) could not allocate scratch space; failing closed."; exit 1; }
trap 'rm -rf "$scratch"' EXIT
git init -q "$scratch" || { echo "::error::ancestry check for workflows_ref (${safe_ref}) could not init a scratch repo; failing closed."; exit 1; }
fetch_upstream() {
for attempt in 1 2 3; do
if timeout 40 git -C "$scratch" -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=30 \
fetch --quiet --no-tags --filter=blob:none "$@"; then
return 0
fi
[ "$attempt" -lt 3 ] && sleep $((attempt * 5))
done
return 1
}
if ! fetch_upstream --depth=1 https://github.com/Comfy-Org/github-workflows "$REF"; then
echo "::error::workflows_ref (${safe_ref}) could not be fetched from Comfy-Org/github-workflows after 3 attempts; failing closed."
exit 1
fi
if ! fetch_upstream https://github.com/Comfy-Org/github-workflows +refs/heads/main:refs/heads/upstream-main; then
echo "::error::main of Comfy-Org/github-workflows could not be read after 3 attempts, so workflows_ref (${safe_ref}) cannot be shown merged upstream; failing closed."
exit 1
fi
# --is-ancestor has THREE outcomes: 0 yes, 1 no, >1 git could not answer (infra fault).
# Don't report an infra fault as a hostile pin; both still fail closed, only the message differs.
ancestry_rc=0
GIT_NO_LAZY_FETCH=1 git -C "$scratch" merge-base --is-ancestor "$REF" upstream-main || ancestry_rc=$?
if [ "$ancestry_rc" -gt 1 ]; then
echo "::error::the ancestry test for workflows_ref (${safe_ref}) could not COMPLETE (git error, not a verdict); failing closed."
exit 1
fi
if [ "$ancestry_rc" -ne 0 ]; then
echo "::error::workflows_ref (${safe_ref}) is not an ancestor of Comfy-Org/github-workflows main. A well-shaped SHA can still be fork-authored; only merged upstream commits may be pinned. If this is a genuine just-merged pin, re-run."
exit 1
fi
- name: Checkout validator scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Comfy-Org/github-workflows
ref: ${{ inputs.workflows_ref }}
path: .linear-ticket-tools
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Validate the linked Linear ticket
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LINEAR_API_TOKEN: ${{ secrets.LINEAR_API_TOKEN }}
TEAM_KEYS: ${{ inputs.team-keys }}
EXEMPT_LABEL: ${{ inputs.exempt-label }}
EXEMPT_ACTORS: ${{ inputs.exempt-actors }}
EXEMPT_PATHS: ${{ inputs.exempt-paths }}
REQUIRE_OPEN_ISSUE: ${{ inputs.require-open-issue }}
ENFORCE: ${{ inputs.enforce }}
SOFT_FAIL: ${{ inputs.soft-fail }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: python3 .linear-ticket-tools/scripts/linear-ticket/validate.py