mandrel-platform 1.13.3 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/check-checkout-clean-excludes.test.mjs +300 -0
- package/scripts/job-completed-hook.test.mjs +398 -0
- package/scripts/release-asset-download-flags.test.mjs +333 -0
- package/scripts/runner-env-drift.test.mjs +55 -6
- package/templates/runbooks/runner-provisioning.md +50 -11
- package/templates/runner/.env.example +15 -0
- package/templates/runner/check-runner-env-drift.sh +15 -7
- package/templates/runner/job-completed.sh +224 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED hook — reap THIS job's own surviving
|
|
4
|
+
# process tree at job end, on PERSISTENT self-hosted runners
|
|
5
|
+
# (mandrel-platform runner kit).
|
|
6
|
+
#
|
|
7
|
+
# ── WHY A SECOND HOOK (the gap job-cleanup.sh cannot close) ─────────────────
|
|
8
|
+
#
|
|
9
|
+
# `job-cleanup.sh` (ACTIONS_RUNNER_HOOK_JOB_STARTED) reaps a PREVIOUS job's
|
|
10
|
+
# orphans at the START of the next one. That is the right defence for a job
|
|
11
|
+
# that has not begun — but it runs before the new job's own processes exist,
|
|
12
|
+
# so it can do nothing once that job is minutes in. Nothing reaped a job's
|
|
13
|
+
# tree when the job ENDED, and a cancelled job is exactly where the tree
|
|
14
|
+
# survives: the runner terminates the step it is executing, not everything
|
|
15
|
+
# that step forked.
|
|
16
|
+
#
|
|
17
|
+
# The observed failure (consumer run 34854313590, 2026-09-14): a `Unit` job
|
|
18
|
+
# exited 143 (SIGTERM) two minutes into a 32-minute budget with every test
|
|
19
|
+
# passing and no cancellation request in the runner's own `Worker_*.log` —
|
|
20
|
+
# i.e. the signal came from outside the runner. Four minutes earlier the SAME
|
|
21
|
+
# runner had hosted a `cancelled` Unit job for a superseded push. Every
|
|
22
|
+
# concurrent job on the pool's other runners passed. A survivor of the
|
|
23
|
+
# cancelled job — a vitest fork or a dev server — was still on that runner
|
|
24
|
+
# with the next job's processes.
|
|
25
|
+
#
|
|
26
|
+
# This hook runs after the last step of every job, cancelled or not, and
|
|
27
|
+
# terminates whatever of that job's tree is still alive: SIGTERM, a bounded
|
|
28
|
+
# grace period, then SIGKILL. Together the two hooks are belt and braces —
|
|
29
|
+
# started = defence against the PREVIOUS job, completed = the job cleans up
|
|
30
|
+
# after itself while the runner still knows whose processes these are.
|
|
31
|
+
#
|
|
32
|
+
# ── CONCURRENCY SAFETY (the same load-bearing constraints as job-cleanup.sh)
|
|
33
|
+
#
|
|
34
|
+
# Multiple runners on one host typically run as the SAME OS user, so anything
|
|
35
|
+
# resolved against $HOME is SHARED across every co-resident runner. This hook
|
|
36
|
+
# therefore honours the constraints issue #343 produced, one for one:
|
|
37
|
+
#
|
|
38
|
+
# 1. NEVER a $HOME-shared path. `~/setup-pnpm` (pnpm/action-setup's DEFAULT
|
|
39
|
+
# `dest`) is not a reap target here any more than it is in
|
|
40
|
+
# job-cleanup.sh: a co-resident runner may be mid-install in it. The
|
|
41
|
+
# pnpm shim is runner-scoped at install time instead — see
|
|
42
|
+
# templates/runbooks/runner-provisioning.md § "pnpm scoping".
|
|
43
|
+
# 2. NEVER a shared $TMPDIR scan, and no directory enumeration at all. The
|
|
44
|
+
# hook runs on the JOB's clock, so every read is billed to the job. Its
|
|
45
|
+
# whole input is ONE `ps` snapshot, whose cost is a function of the
|
|
46
|
+
# host's process count — bounded and small — never of the 841,690-entry
|
|
47
|
+
# temp root that made `Set up runner` take 5m29s in #343.
|
|
48
|
+
# 3. EVERY path derives from RUNNER_DIR, which is unique per runner. A
|
|
49
|
+
# process is a reap candidate only when its command line resolves inside
|
|
50
|
+
# THIS runner's `<RUNNER_DIR>/_work/` tree, so a co-resident runner's
|
|
51
|
+
# processes are unreachable from here by construction.
|
|
52
|
+
# 4. NEVER this script, its own ancestors, or the runner itself. The
|
|
53
|
+
# ancestor chain is walked and protected explicitly, and
|
|
54
|
+
# `Runner.Worker` / `Runner.Listener` are excluded by name — signalling
|
|
55
|
+
# either would take the runner offline mid-job.
|
|
56
|
+
#
|
|
57
|
+
# ── WHAT IS REAPED ──────────────────────────────────────────────────────────
|
|
58
|
+
#
|
|
59
|
+
# Seed: processes whose command line contains `<RUNNER_DIR>/_work/`. Then the
|
|
60
|
+
# seeds' DESCENDANTS, transitively, by parent pid — a job's `sleep`, `esbuild`
|
|
61
|
+
# or worker fork carries no runner path in its own argv, so matching on the
|
|
62
|
+
# path alone would leave the leaves of the tree behind. Descendants of a
|
|
63
|
+
# runner-scoped process are runner-scoped by parentage, so the expansion does
|
|
64
|
+
# not widen the blast radius beyond this runner.
|
|
65
|
+
#
|
|
66
|
+
# ── PORTABILITY ─────────────────────────────────────────────────────────────
|
|
67
|
+
#
|
|
68
|
+
# macOS AND Linux runners, and macOS's system bash 3.2 (Apple cannot ship a
|
|
69
|
+
# GPL3 bash) — so no `declare -A`, no `mapfile`/`readarray`, no `${var,,}`.
|
|
70
|
+
# Process handling uses only forms both platforms have: `ps -A -w -w -o
|
|
71
|
+
# pid=,ppid=,command=` and `kill -TERM` / `-KILL` / `-0` with explicit pids.
|
|
72
|
+
# Deliberately NOT `pkill -f`: a pattern kill cannot exclude this script, its
|
|
73
|
+
# ancestors, or a co-resident runner's lookalike, and its matching semantics
|
|
74
|
+
# differ between the two platforms. The repeated `-w` is load-bearing —
|
|
75
|
+
# without it BSD `ps` truncates argv and a runner path late in a long node
|
|
76
|
+
# command line would go unseen.
|
|
77
|
+
#
|
|
78
|
+
# ── PARAMETERIZATION ────────────────────────────────────────────────────────
|
|
79
|
+
#
|
|
80
|
+
# RUNNER_DIR — the runner's root directory. Defaults to the directory
|
|
81
|
+
# containing this script (the kit installs the hook into the
|
|
82
|
+
# runner root, next to config.sh / run.sh). Override via env
|
|
83
|
+
# only if you install the hook elsewhere.
|
|
84
|
+
#
|
|
85
|
+
# Configured via
|
|
86
|
+
# `ACTIONS_RUNNER_HOOK_JOB_COMPLETED=<RUNNER_DIR>/job-completed.sh` in the
|
|
87
|
+
# runner's `.env` (see .env.example in this directory).
|
|
88
|
+
#
|
|
89
|
+
# NEVER fails the job — best-effort cleanup, always exits 0. Findings go to
|
|
90
|
+
# the job log, where they are attributable to the job that leaked them.
|
|
91
|
+
|
|
92
|
+
set -u
|
|
93
|
+
|
|
94
|
+
RUNNER_DIR="${RUNNER_DIR:-$(cd "$(dirname "$0")" && pwd)}"
|
|
95
|
+
RUNNER_WORK="${RUNNER_DIR}/_work"
|
|
96
|
+
|
|
97
|
+
# Grace between SIGTERM and SIGKILL: 30 polls × 0.1s = 3s worst case. Polled
|
|
98
|
+
# rather than slept whole, so a tree that exits on SIGTERM — the normal case —
|
|
99
|
+
# costs one poll, and a job with nothing to reap sleeps not at all.
|
|
100
|
+
GRACE_POLLS=30
|
|
101
|
+
POLL_INTERVAL=0.1
|
|
102
|
+
|
|
103
|
+
log() {
|
|
104
|
+
printf 'job-completed: %s\n' "$1"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Protected set: this script and every one of its ancestors. On a real runner
|
|
108
|
+
# the chain runs job-completed.sh -> Runner.Worker -> Runner.Listener, and
|
|
109
|
+
# signalling any of them would end the job's own bookkeeping or the runner
|
|
110
|
+
# service. Bounded at 32 hops so a cycle in a hostile process table cannot
|
|
111
|
+
# spin here on the job's clock.
|
|
112
|
+
self_pid=$$
|
|
113
|
+
protected=" ${self_pid} "
|
|
114
|
+
ancestor=${self_pid}
|
|
115
|
+
hops=0
|
|
116
|
+
while [ "$hops" -lt 32 ]; do
|
|
117
|
+
parent=$(ps -o ppid= -p "$ancestor" 2>/dev/null | tr -d '[:space:]')
|
|
118
|
+
case "$parent" in
|
|
119
|
+
"" | 0 | 1) break ;;
|
|
120
|
+
esac
|
|
121
|
+
protected="${protected}${parent} "
|
|
122
|
+
ancestor=$parent
|
|
123
|
+
hops=$((hops + 1))
|
|
124
|
+
done
|
|
125
|
+
|
|
126
|
+
# ONE snapshot, reused for the seed pass and every expansion round. Re-running
|
|
127
|
+
# `ps` per round would bill the job for each; it would also let the table shift
|
|
128
|
+
# underneath the walk, so a single snapshot is the cheaper AND the more
|
|
129
|
+
# consistent choice.
|
|
130
|
+
snapshot=$(ps -A -w -w -o pid=,ppid=,command= 2>/dev/null)
|
|
131
|
+
if [ -z "$snapshot" ]; then
|
|
132
|
+
log "process table unavailable — nothing reaped"
|
|
133
|
+
exit 0
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Seed: command lines resolving inside THIS runner's work tree. The pattern is
|
|
137
|
+
# quoted inside the `case`, so a metacharacter in a runner path is matched
|
|
138
|
+
# literally rather than globbed.
|
|
139
|
+
doomed=""
|
|
140
|
+
while read -r pid ppid command; do
|
|
141
|
+
[ -n "$pid" ] || continue
|
|
142
|
+
case "$protected" in
|
|
143
|
+
*" ${pid} "*) continue ;;
|
|
144
|
+
esac
|
|
145
|
+
case "$command" in
|
|
146
|
+
*Runner.Worker* | *Runner.Listener*) continue ;;
|
|
147
|
+
esac
|
|
148
|
+
case "$command" in
|
|
149
|
+
*"${RUNNER_WORK}/"*) doomed="${doomed}${pid} " ;;
|
|
150
|
+
esac
|
|
151
|
+
done <<SNAPSHOT
|
|
152
|
+
$snapshot
|
|
153
|
+
SNAPSHOT
|
|
154
|
+
|
|
155
|
+
# Expand to descendants, transitively. Each round adds the children of pids
|
|
156
|
+
# already condemned; the loop stops as soon as a round adds nothing, and is
|
|
157
|
+
# bounded at 32 rounds (a deeper live tree than any job produces) so a
|
|
158
|
+
# malformed table cannot loop forever.
|
|
159
|
+
rounds=0
|
|
160
|
+
while [ "$rounds" -lt 32 ]; do
|
|
161
|
+
added=0
|
|
162
|
+
while read -r pid ppid command; do
|
|
163
|
+
[ -n "$pid" ] || continue
|
|
164
|
+
case "$protected" in
|
|
165
|
+
*" ${pid} "*) continue ;;
|
|
166
|
+
esac
|
|
167
|
+
case " ${doomed}" in
|
|
168
|
+
*" ${pid} "*) continue ;;
|
|
169
|
+
esac
|
|
170
|
+
case "$command" in
|
|
171
|
+
*Runner.Worker* | *Runner.Listener*) continue ;;
|
|
172
|
+
esac
|
|
173
|
+
case " ${doomed}" in
|
|
174
|
+
*" ${ppid} "*)
|
|
175
|
+
doomed="${doomed}${pid} "
|
|
176
|
+
added=1
|
|
177
|
+
;;
|
|
178
|
+
esac
|
|
179
|
+
done <<SNAPSHOT
|
|
180
|
+
$snapshot
|
|
181
|
+
SNAPSHOT
|
|
182
|
+
[ "$added" -eq 1 ] || break
|
|
183
|
+
rounds=$((rounds + 1))
|
|
184
|
+
done
|
|
185
|
+
|
|
186
|
+
if [ -z "$doomed" ]; then
|
|
187
|
+
log "no surviving processes under ${RUNNER_WORK} — nothing to reap"
|
|
188
|
+
exit 0
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
log "reaping processes that outlived this job under ${RUNNER_WORK}: ${doomed% }"
|
|
192
|
+
for pid in $doomed; do
|
|
193
|
+
kill -TERM "$pid" 2>/dev/null
|
|
194
|
+
done
|
|
195
|
+
|
|
196
|
+
# Liveness is read from the process state, not from `kill -0`: a ZOMBIE still
|
|
197
|
+
# answers signal 0 but can never be signalled again — it disappears when its
|
|
198
|
+
# parent waits, or when init reaps it after the parent dies. Polling one out
|
|
199
|
+
# would spend the whole grace period, on the job's clock, waiting for
|
|
200
|
+
# something already dead. An empty state means the pid is gone.
|
|
201
|
+
polls=0
|
|
202
|
+
while :; do
|
|
203
|
+
alive=""
|
|
204
|
+
for pid in $doomed; do
|
|
205
|
+
state=$(ps -o state= -p "$pid" 2>/dev/null | tr -d '[:space:]')
|
|
206
|
+
case "$state" in
|
|
207
|
+
"" | Z*) continue ;;
|
|
208
|
+
esac
|
|
209
|
+
alive="${alive}${pid} "
|
|
210
|
+
done
|
|
211
|
+
[ -n "$alive" ] || break
|
|
212
|
+
[ "$polls" -lt "$GRACE_POLLS" ] || break
|
|
213
|
+
sleep "$POLL_INTERVAL"
|
|
214
|
+
polls=$((polls + 1))
|
|
215
|
+
done
|
|
216
|
+
|
|
217
|
+
if [ -n "$alive" ]; then
|
|
218
|
+
log "grace expired — escalating to SIGKILL: ${alive% }"
|
|
219
|
+
for pid in $alive; do
|
|
220
|
+
kill -KILL "$pid" 2>/dev/null
|
|
221
|
+
done
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
exit 0
|