-
Notifications
You must be signed in to change notification settings - Fork 0
215 lines (199 loc) · 10.9 KB
/
Copy pathopenspec-label.yml
File metadata and controls
215 lines (199 loc) · 10.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
name: OpenSpec Label
# Reports whether a pull request's stack still carries an unresolved OpenSpec
# change. It labels, it warns, and it never fails.
#
# NOTHING HERE BLOCKS, AND THAT IS THE DESIGN. Three earlier versions of this
# check reported through a check status and all three were deleted in 8d1f3a1:
# `pr-check-openspec.yml` reddened a pull request whenever `gh pr list` failed,
# a step in `validate.yml` ran `main` red for as long as a forward-merging stack
# took to drain, and both trained people to route around red. A signal expected
# to be red is not a signal. A label costs a shrug when it is wrong, which is
# what lets the predicate stay simple enough to be dependable.
#
# THE LABEL PREDICATE ASKS NOTHING ABOUT STACK POSITION. It reads the head tree:
# does any directory other than `archive/` exist under `openspec/changes/`? A
# stack has not resolved its OpenSpec change until one of its branches archives
# it, and that is a property of the stack rather than of a branch's place in it,
# so every pull request in the stack gets the same answer with no API call that
# can fail. This is the shape `changeset.yml` uses, and it is the only shape in
# this repository that has never misfired.
#
# THIS IS NOT A STEP IN `Validate`, DELIBERATELY. `release-cli-nightly.yml`
# triggers on `workflow_run: workflows: [Validate]`, matched by that workflow's
# `name:` string. Any signal outside that workflow is invisible to the nightly,
# so an unarchived change cannot gate a nightly publish. The separation is what
# enforces it, not a rule anyone has to remember.
on:
# No `branches:` filter. The label is a fact about the stack, so it belongs on
# every pull request in one, and a filter is not a dependable way to scope a
# workflow anyway: GitHub sometimes resolves a stacked pull request's eventual
# target and matches on that, and sometimes stops without warning.
#
# `ready_for_review` is not in the default set and must be named, or a draft
# marked ready keeps whatever label it had until something happens to push.
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: OpenSpec Label
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# The head commit, not the merge ref: the question is what this branch
# carries. A child branch contains its ancestors' commits, so the head
# tree already answers for the whole stack below it.
ref: ${{ github.event.pull_request.head.sha }}
- name: Report unresolved OpenSpec changes
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
HEAD_REF: ${{ github.head_ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
LABEL: Open OpenSpec
run: |
set -euo pipefail
# The listing is delegated to openspec-tracking.cjs --list rather
# than to a shell `find`. A `find` form can always be made portable
# for today's shapes (this file once carried `-printf '%f\n'`, a GNU
# extension BSD find rejects, fixed to `-exec basename {} \;`
# instead), but that is a property someone has to get right again
# every time the listing changes. Routing it through `readdirSync`,
# already covered by openspec-tracking.test.cjs, removes the
# question of which `find` flags are portable rather than answering
# it correctly this once.
#
# The script can still fail (an unreadable `openspec/changes`), and
# that must read as "could not tell" rather than as "resolved":
# reporting an unreadable directory as zero unarchived changes would
# tell a stack its OpenSpec change is done when nobody checked. So
# the exit status is captured outside the assignment position `set
# -e` watches, and an unreadable directory gets its own summary and
# an explicit annotation instead of either silence or an abort. This
# workflow's whole point is to never fail a check for a reason
# unrelated to the pull request.
#
# STDERR GOES TO A FILE, NEVER INTO THE CAPTURED VALUE. This read
# `2>&1`, which folds anything node writes to stderr into the JSON.
# Node writes to stderr while exiting zero all the time: an
# ExperimentalWarning, a deprecation notice from a newer runner
# image, a NODE_OPTIONS preload message. The guard does not fire on
# any of them, and `jq` then fails on the polluted value in
# assignment position, where `set -e` does abort the step. Measured
# with a single `(node:1) Warning: something at startup` line: `jq:
# parse error: Invalid numeric literal`, exit 5, a red check on a
# pull request for a reason that has nothing to do with the pull
# request. The stderr text is still available for the warning, it is
# just read from the file on the failure branch.
changes=""
listing_failed=0
list_stderr=/tmp/openspec-label-list.err
if ! raw=$(node .github/scripts/openspec-tracking.cjs --list 2>"$list_stderr"); then
echo "::warning::Could not list openspec/changes/ ($(tr '\n' ' ' <"$list_stderr")). Leaving the '$LABEL' label untouched rather than guessing."
listing_failed=1
elif ! changes=$(jq -r '.[]' <<<"$raw"); then
# A listing that parses as nothing is "could not tell", the same as
# a failed listing, and takes the same path. Falling through with
# an empty `changes` would report "resolved" and remove the label
# from a branch whose change is still there.
echo "::warning::Could not parse the change listing. Leaving the '$LABEL' label untouched rather than guessing."
listing_failed=1
fi
if [ "$listing_failed" -eq 1 ]; then
{
echo "### OpenSpec: unknown"
echo ""
echo "Could not list \`openspec/changes/\` on this branch, so whether an unarchived change remains is unknown. The \`$LABEL\` label was left as-is."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ -z "$changes" ]; then
echo "No unarchived OpenSpec changes on this branch."
{
echo "### OpenSpec: resolved"
echo ""
echo "This branch carries no unarchived change under \`openspec/changes/\`."
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HEAD_REPO" = "$REPO" ]; then
gh pr edit "$PR" --repo "$REPO" --remove-label "$LABEL" || \
echo "::notice::Could not remove the '$LABEL' label; it may not have been applied."
fi
exit 0
fi
echo "Unarchived OpenSpec changes on this branch:"
echo "$changes" | sed 's/^/ - /'
# A fork's `pull_request` token is read-only. `pull_request_target` is
# the usual answer and it is rejected here: it runs a writable token
# against the base repository, which is too much privilege to buy a
# cosmetic label. The fork path reports through the summary alone.
labelled=1
if [ "$HEAD_REPO" = "$REPO" ]; then
gh pr edit "$PR" --repo "$REPO" --add-label "$LABEL" || labelled=0
else
labelled=0
fi
{
echo "### OpenSpec: unresolved"
echo ""
echo "This branch carries an unarchived change under \`openspec/changes/\`:"
echo ""
echo "$changes" | sed 's/^/- `/;s/$/`/'
echo ""
if [ "$labelled" -eq 1 ]; then
echo "Labelled \`$LABEL\`. The label clears when a branch in this stack archives the change."
else
echo "The \`$LABEL\` label could not be applied. A pull request from a fork gets a read-only token, and this workflow does not use \`pull_request_target\` to work around that."
fi
} >> "$GITHUB_STEP_SUMMARY"
# The tip is the last branch that can archive before the change
# reaches `main`, so it is the only position where a warning is
# actionable. Reading it needs the API, which introduces a failure
# that is not about this pull request: the deleted workflow exited 1
# here and reddened pull requests for `gh` timeouts. This one skips
# the warning and says it could not tell.
#
# Gated on same-repo for the same reason the label write above is. A
# fork's head branch lives in the fork, so no pull request in this
# repository can name it as a base: the count would be 0 whatever the
# real stack position, and every fork pull request would be told it is
# the tip. Reporting "could not tell" is honest; guessing is not.
if [ "$HEAD_REPO" != "$REPO" ]; then
echo "::notice::Stack position is not readable for a fork pull request; skipping the tip warning."
{
echo ""
echo "_This pull request comes from a fork, so its stack position cannot be read from this repository. No tip warning was evaluated._"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if ! children=$(gh pr list --repo "$REPO" --state open --base "$HEAD_REF" --json number --jq 'length'); then
echo "::notice::Could not determine stack position; skipping the tip warning."
{
echo ""
echo "_Stack position could not be determined, so no tip warning was evaluated._"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "${children:-0}" -gt 0 ]; then
echo "$children open pull request(s) are stacked on top; the change is not due for archiving."
{
echo ""
echo "_${children} pull request(s) are stacked above this one, so the change is not due for archiving yet._"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::warning::This is the tip of its stack and leaves an OpenSpec change unarchived. Run 'pnpm openspec archive <change>' before merging, or the standing specs reach main describing requirements the code has already met."
{
echo ""
echo "**No pull request is stacked above this one.** It is the tip, so it is the"
echo "last branch that can archive the change before it reaches \`main\`."
echo ""
echo "\`\`\`bash"
echo "$changes" | sed 's/^/pnpm openspec archive /'
echo "\`\`\`"
echo ""
echo "_This is a warning. It does not block the merge._"
} >> "$GITHUB_STEP_SUMMARY"