-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaction.yml
More file actions
335 lines (306 loc) · 14.1 KB
/
Copy pathaction.yml
File metadata and controls
335 lines (306 loc) · 14.1 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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
---
name: Unwrap Markdown prose
# Marketplace rejects a description of 125 characters or more, and this one was
# 141. What went is the tail of the list rather than the framing: the structures
# named here are a sample and never were the whole of it -- the readme carries
# the full set under "What it leaves alone" -- but declining to act is the
# feature, so the clause saying so had to survive the cut.
description:
Join manual soft-wrap line breaks in Markdown prose, leaving code, tables,
lists, front matter and hard breaks alone.
author: Michael I Chen
branding:
icon: align-left
color: blue
inputs:
paths:
description:
Space-separated Markdown files or globs to inspect. Defaults to every
tracked Markdown file in the repository.
required: false
default: ''
write:
description:
Rewrite files in place. Left false, the run only reports what would
change, which is what a check job wants.
required: false
default: 'false'
fail-on-change:
description:
Exit non-zero when any file changed or would change. This is what makes
the step a gate rather than a report.
required: false
default: 'true'
annotate:
description:
Emit a workflow annotation for each file carrying manual line breaks, and
a table in the job summary. Needs no permissions, so it is the one review
signal that works identically on a pull request from a fork.
required: false
default: 'true'
implementation:
description: Which build runs the pass -- `auto`, `rust` or `python`. `auto`
downloads the prebuilt Rust binary for this action's version and falls
back to `pip install` when the release carries none for this runner. `rust` and
`python` pin the choice, and `rust` turns that fallback into an error.
The result does not depend on this -- both implementations answer to the
same conformance corpus and emit the same bytes -- so the explicit values
are for pinning behavior deliberately or isolating a suspected
divergence, not for routine use.
required: false
default: 'auto'
python-version:
description:
Python used on the fallback path, and only there. `auto` reaches it when
the release carries no binary for this runner, and an `implementation`
of `python` reaches it always. The tool needs 3.10+.
required: false
default: '3.13'
outputs:
changed:
description: '"true" when any file changed or would change.'
value: ${{ steps.unwrap.outputs.changed }}
implementation:
description:
Which build actually ran, `rust` or `python`. Worth reading when
`implementation` was left at `auto`, because that is the input whose
result depends on what the release carries for this runner.
value: ${{ steps.resolve.outputs.mode }}
# Three steps rather than one, because what differs between the two
# implementations is the install and not the pass. The Rust path downloads one
# binary and provisions nothing; the Python path is what a runner with no
# published binary for its platform gets.
#
# A missing asset and a wrong checksum are handled differently on purpose. The
# first says this release covers platforms other than this one, which is a
# reason to fall back. The second is never a reason to do anything but stop: a
# workflow that fetches an executable and runs it unchecked is a supply-chain
# problem no matter who published it, and falling back there would turn a failed
# verification into a silent downgrade.
runs:
using: composite
steps:
- name: Resolve the implementation
id: resolve
shell: bash
env:
INPUT_IMPLEMENTATION: ${{ inputs.implementation }}
ACTION_PATH: ${{ github.action_path }}
ACTION_REPOSITORY: ${{ github.action_repository }}
WORKFLOW_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
case "${INPUT_IMPLEMENTATION}" in
auto | rust | python) ;;
*)
echo "::error::implementation takes auto, rust or python," \
"not '${INPUT_IMPLEMENTATION}'"
exit 1
;;
esac
# Requested explicitly: nothing to resolve, and no download to attempt.
if [ "${INPUT_IMPLEMENTATION}" = 'python' ]; then
{
echo 'mode=python'
echo 'command=unwrap-markdown-prose-py'
} >>"${GITHUB_OUTPUT}"
exit 0
fi
# `auto` may fall back; `rust` was asked for and may not.
fall_back() {
if [ "${INPUT_IMPLEMENTATION}" = 'rust' ]; then
echo "::error::implementation: rust was requested, but $1"
exit 1
fi
echo "::notice::$1 -- running the Python implementation instead"
{
echo 'mode=python'
echo 'command=unwrap-markdown-prose-py'
} >>"${GITHUB_OUTPUT}"
exit 0
}
# The version in the action's own manifest, not the ref the consumer
# wrote. A ref can be a tag, a branch or a commit, while a release is
# named after a version, so this resolves the same way for `@v0.0.1`,
# for `@main`, and for a local `uses: ./`. It also self-corrects between
# releases: a manifest naming a version nobody has published finds no
# manifest to verify against, and falls back.
version="$(sed -n "s/^version = ['\"]\([^'\"]*\)['\"].*/\1/p" \
"${ACTION_PATH}/Cargo.toml" | head -1)"
if [ -z "${version}" ]; then
fall_back "no version found in the action's Cargo.toml"
fi
# `action_repository` is the repository the action was taken from, which
# is what carries the release. It is empty for a local `uses: ./`, where
# the workflow's own repository is the same thing.
repository="${ACTION_REPOSITORY:-${WORKFLOW_REPOSITORY}}"
# Only the targets the release actually builds. Anything else -- a
# Windows arm64 runner, a platform Actions offers before the release
# matrix covers it -- has no asset and takes the fallback.
case "${RUNNER_OS}-${RUNNER_ARCH}" in
Linux-X64) asset='unwrap-markdown-prose-rs-x86_64-unknown-linux-gnu' ;;
Linux-ARM64) asset='unwrap-markdown-prose-rs-aarch64-unknown-linux-gnu' ;;
macOS-ARM64) asset='unwrap-markdown-prose-rs-aarch64-apple-darwin' ;;
macOS-X64) asset='unwrap-markdown-prose-rs-x86_64-apple-darwin' ;;
Windows-X64) asset='unwrap-markdown-prose-rs-x86_64-pc-windows-msvc.exe' ;;
*) fall_back "the release carries no binary for ${RUNNER_OS} ${RUNNER_ARCH}" ;;
esac
# `mktemp -d` rather than RUNNER_TEMP, which on a Windows runner is a
# path with backslashes that bash reads as escapes.
directory="$(mktemp -d)"
base="https://github.com/${repository}/releases/download/v${version}"
# The manifest first. Without it there is nothing to verify against, and
# an unverifiable binary is one this action will not run.
if ! curl --fail --silent --show-error --location \
--output "${directory}/SHA256SUMS" "${base}/SHA256SUMS"; then
fall_back "no SHA256SUMS published for v${version} of ${repository}"
fi
if ! grep -q "[ *]${asset}\$" "${directory}/SHA256SUMS"; then
fall_back "v${version} publishes no ${asset}"
fi
# Past this point every failure is fatal. The manifest named this asset,
# so a download that fails or a digest that disagrees is a broken or a
# tampered release rather than an uncovered platform.
if ! curl --fail --silent --show-error --location \
--output "${directory}/${asset}" "${base}/${asset}"; then
echo "::error::SHA256SUMS lists ${asset} but it did not download"
exit 1
fi
grep "[ *]${asset}\$" "${directory}/SHA256SUMS" >"${directory}/expected"
if command -v sha256sum >/dev/null; then
verify() { sha256sum --check --strict expected; }
else
verify() { shasum -a 256 --check --strict expected; }
fi
if ! (cd "${directory}" && verify); then
echo "::error::${asset} does not match its published checksum;" \
"refusing to run it"
exit 1
fi
chmod +x "${directory}/${asset}"
{
echo 'mode=rust'
echo "command=${directory}/${asset}"
} >>"${GITHUB_OUTPUT}"
# Both of these are the fallback path, and neither runs when a verified
# binary is already in hand.
- if: steps.resolve.outputs.mode == 'python'
uses: actions/setup-python@v7
with:
python-version: ${{ inputs.python-version }}
- name: Install the hook
if: steps.resolve.outputs.mode == 'python'
shell: bash
run: python -m pip install --disable-pip-version-check "${{ github.action_path }}"
- name: Unwrap Markdown prose
id: unwrap
shell: bash
env:
INPUT_PATHS: ${{ inputs.paths }}
INPUT_WRITE: ${{ inputs.write }}
INPUT_FAIL_ON_CHANGE: ${{ inputs.fail-on-change }}
INPUT_ANNOTATE: ${{ inputs.annotate }}
TOOL: ${{ steps.resolve.outputs.command }}
run: |
set -uo pipefail
# Default to every tracked Markdown file rather than a glob the shell
# would expand here: `git ls-files` already honors .gitignore and skips
# submodules, and an unmatched glob would otherwise reach the tool as a
# literal path.
if [ -n "${INPUT_PATHS}" ]; then
# shellcheck disable=SC2086
set -- ${INPUT_PATHS}
else
set --
while IFS= read -r file; do
set -- "$@" "${file}"
done < <(git ls-files -z '*.md' '*.markdown' | tr '\0' '\n')
fi
if [ "$#" -eq 0 ]; then
echo "No Markdown files to inspect."
echo "changed=false" >>"${GITHUB_OUTPUT}"
exit 0
fi
# Seeded with `--json` rather than started empty, so the expansion
# below is never of an empty array: bash 3.2, which is what a macOS
# runner can offer, reads that as an unbound variable under `set -u`.
args=(--json)
[ "${INPUT_WRITE}" = "true" ] && args+=(--write)
[ "${INPUT_FAIL_ON_CHANGE}" = "true" ] && args+=(--fail-on-change)
# The report is wanted on the log whichever way the gate goes, so the
# exit status is captured rather than allowed to end the step.
"${TOOL}" "$@" "${args[@]}" >report.json
status=$?
cat report.json
# `python3` rather than a provisioned interpreter: every runner
# image ships one, and this reads a JSON report rather than running
# the tool, so it costs no setup step on the Rust path.
python3 - "${GITHUB_OUTPUT}" <<'PY'
import json
import os
import sys
def escape(value: str) -> str:
"""Escape a value used as a workflow-command property.
A property list is comma-separated with colon-delimited keys, so a
path containing either would silently truncate the annotation
rather than fail. Percent goes first, or it would double-escape the
sequences the others introduce.
"""
for old, new in (
("%", "%25"),
("\r", "%0D"),
("\n", "%0A"),
(":", "%3A"),
(",", "%2C"),
):
value = value.replace(old, new)
return value
with open("report.json", encoding="utf-8") as report:
payload = json.load(report)
changed = [file for file in payload["files"] if file["changed"]]
annotate = os.environ["INPUT_ANNOTATE"] == "true"
# Annotations need no token permissions at all, which is what makes them
# the one review signal that behaves identically on a pull request from
# a fork -- where the token is read-only however the workflow asks.
# They carry no line number because the report carries none for a
# changed file; adding one is a change to the `--json` payload, which is
# corpus-governed and belongs to both implementations at once rather than
# to this action. The payload's `warnings` do carry a line, and this step
# does not annotate them -- they reach the log through the report dump
# above, and turning one into an annotation is the same corpus-governed
# decision taken from the other end.
if annotate:
level = (
"error" if os.environ["INPUT_FAIL_ON_CHANGE"] == "true" else "warning"
)
for file in changed:
print(
f"::{level} file={escape(file['path'])},"
"title=Markdown prose is hard-wrapped::"
f"{file['paragraphs_unwrapped']} paragraph(s) carry "
f"{file['line_breaks_removed']} manual line break(s)."
)
for error in payload["errors"]:
print(f"::error::{error}")
# The summary is where a file-level annotation is reliably seen: an
# annotation without a line lands on line one, which is often outside
# the diff, while the summary is on the job page whatever the diff is.
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if annotate and summary and (changed or payload["errors"]):
with open(summary, "a", encoding="utf-8") as handle:
handle.write("### Markdown prose\n\n")
if changed:
handle.write("| file | paragraphs | line breaks |\n")
handle.write("| -- | --: | --: |\n")
for file in changed:
handle.write(
f"| `{file['path']}` | {file['paragraphs_unwrapped']} "
f"| {file['line_breaks_removed']} |\n"
)
for error in payload["errors"]:
handle.write(f"\n- {error}\n")
with open(sys.argv[1], "a", encoding="utf-8") as output:
output.write(f"changed={str(payload['changed']).lower()}\n")
PY
rm -f report.json
exit "${status}"