-
Notifications
You must be signed in to change notification settings - Fork 1
321 lines (294 loc) · 14.7 KB
/
Copy pathci.yml
File metadata and controls
321 lines (294 loc) · 14.7 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
name: CI
# Nothing ran the tests until this existed, which for most of this repo's life
# was no loss because there were none. There are now, and what they guard is
# the thing that broke quietly for months: the configs this CLI writes into a
# member's opencode, Pi, Codex and Factory. A wrong window there is silent -
# the tool starts, answers, and only behaves oddly deep into a session - so the
# only place it gets caught is here.
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
# Everything here ran on ubuntu only, so darwin was a platform we shipped
# binaries for and had never executed a line on: it cross-compiled, and
# that was the whole of the evidence. The config writers are full of paths
# and of os.UserHomeDir, which is exactly the code that differs.
name: Vet & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# `gofmt -l` prints the files it would change and exits 0 either way, so
# the check has to look at the output rather than the status.
- name: Formatting
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "not gofmt'd:" >&2
echo "$unformatted" >&2
echo >&2
gofmt -d $unformatted >&2
exit 1
fi
# It caught a real one: `fmt.Sprintf("$%,.2f", v)`, a verb Go does not
# have, which printed $%!,(float64=1234.5).2f on the Costs tab for every
# figure over a thousand.
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# The release builds four targets from this same source. A build that
# only works on the runner's own platform is worth knowing about before
# the tag, not after.
- name: Cross-compile the release targets
run: |
set -euo pipefail
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"
arch="${target#*/}"
echo "building $os/$arch"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -o /dev/null .
done
# The installers are the one thing here that is not Go and is not tested
# by anything: they only run on a member's machine, and both times this
# one broke it broke at runtime with nothing to catch it. Neither rule
# below would have found the Byte[] bug - only running it did - but each
# is a mechanical rule for a mistake that already cost a release.
- name: Check the PowerShell installer
if: matrix.os == 'ubuntu-latest'
shell: pwsh
run: |
$script = Get-Content -Raw scripts/install.ps1
# Parse it, so a syntax error is not discovered by the first person
# to pipe it into iex.
$errors = $null
[System.Management.Automation.Language.Parser]::ParseInput($script, [ref]$null, [ref]$errors) | Out-Null
if ($errors.Count -gt 0) {
$errors | ForEach-Object { Write-Host "::error::$($_.Message)" }
exit 1
}
# Every Invoke-WebRequest needs -UseBasicParsing. Without it,
# PowerShell 5.1 hands the body to the Internet Explorer engine, and
# where that is absent or has never run its first-run setup the call
# throws a NullReferenceException. It is the default from 6 up, so
# this never shows on a modern pwsh and always shows on Windows 10.
$lines = $script -split "`n"
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match 'Invoke-WebRequest' -and $lines[$i] -notmatch '-UseBasicParsing' -and $lines[$i] -notmatch '^\s*#') {
Write-Host "::error file=scripts/install.ps1,line=$($i + 1)::Invoke-WebRequest without -UseBasicParsing"
exit 1
}
}
# No `exit` anywhere except the one guarded by MyInvocation.
#
# This script is published to be run as `irm ... | iex`, and `iex`
# runs what it is given in the CURRENT session: an `exit` there does
# not end a script, it ends the session. In a terminal that means the
# tab closes instantly, taking with it the error message printed one
# line earlier. Every failure path used to do exactly that, so the
# careful text about rate limits and checksums was written into a
# window that was already gone.
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -notmatch '^\s*exit\b') { continue }
$guarded = $false
for ($back = [Math]::Max(0, $i - 3); $back -lt $i; $back++) {
if ($lines[$back] -match 'MyInvocation\.MyCommand\.Path') { $guarded = $true }
}
if (-not $guarded) {
Write-Host "::error file=scripts/install.ps1,line=$($i + 1)::exit outside the MyInvocation guard closes the session when piped into iex"
exit 1
}
}
# Architecture detection has to survive the .NET type being out of
# reach. ConstrainedLanguage mode - the normal state of a machine
# under an AppLocker or WDAC policy - blocks arbitrary type access,
# and the type does not exist before .NET Framework 4.7.1. Either
# way the value came back empty and a perfectly ordinary x64 machine
# was reported as an unsupported architecture: a dead end over
# something that was never about the architecture.
$Repo = 'helmcode/nan-cli'
$collected = @()
$inside = $false
foreach ($line in $lines) {
if ($line -match '^function Get-Arch') { $inside = $true }
if ($inside) { $collected += $line }
if ($inside -and $line.TrimEnd() -eq '}') { break }
}
if ($collected.Count -eq 0) {
Write-Host "::error::could not find Get-Arch in the installer"
exit 1
}
$noDotNet = ($collected -join [Environment]::NewLine) -replace 'RuntimeInformation', 'NoSuchTypeHere'
foreach ($case in @(
@{ w6432 = ''; proc = 'AMD64'; want = 'amd64' },
@{ w6432 = ''; proc = 'ARM64'; want = 'arm64' },
# A 32-bit PowerShell on a 64-bit OS: the process says x86 and
# ARCHITEW6432 says what the OS really is. Asking in the wrong
# order sends an arm64 machine an x86 build nobody published.
@{ w6432 = 'ARM64'; proc = 'x86'; want = 'arm64' }
)) {
$env:PROCESSOR_ARCHITEW6432 = $case.w6432
$env:PROCESSOR_ARCHITECTURE = $case.proc
Invoke-Expression $noDotNet
# A throw here has to fail the step. Left uncaught it printed the
# installer's own error text and the job went green, which is a
# guard that guards nothing.
$got = $null
try {
$got = Get-Arch
} catch {
Write-Host "::error::with no .NET type, ARCHITEW6432='$($case.w6432)' ARCHITECTURE='$($case.proc)' threw instead of answering '$($case.want)'"
exit 1
}
if ($got -ne $case.want) {
Write-Host "::error::with no .NET type, ARCHITEW6432='$($case.w6432)' ARCHITECTURE='$($case.proc)' gave '$got', want '$($case.want)'"
exit 1
}
}
# Nothing may set a preference variable at script scope. `iex` runs
# this in the caller's session, so an assignment out there is an
# assignment to their shell for the rest of its life.
# $ErrorActionPreference = 'Stop' left behind turns every later
# non-terminating error in that session terminating - including
# inside the prompt function a terminal like Warp installs to know
# where a command begins and ends. When that throws, PowerShell
# falls back to its built-in PS> and the terminal loses the session:
# a prompt sits there and nothing typed at it does anything.
$depth = 0
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -match '^\s*#') { continue }
if ($depth -eq 0 -and $line -match '^\s*\$(ErrorAction|Progress|Warning|Information|Debug|Verbose|Confirm)Preference\s*=') {
Write-Host "::error file=scripts/install.ps1,line=$($i + 1)::a preference set at script scope follows the member into their shell; set it inside a function"
exit 1
}
$depth += ([regex]::Matches($line, '{')).Count - ([regex]::Matches($line, '}')).Count
}
Write-Host "installer parses, web requests basic-parsed, no unguarded exit, arch survives without .NET, no preference leaks"
# Four vulnerabilities in the standard library this repo compiled against
# went unnoticed until somebody ran this by hand. Nothing here was looking,
# so nothing was going to say so - not on the next release either.
#
# govulncheck reports only what the code actually reaches, which is why it
# can be a failing check rather than a list to triage: a finding here is a
# path from this CLI to the bug, and the fix is usually a toolchain or a
# dependency bump.
vulnerabilities:
name: Known vulnerabilities
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# A fixed version rather than @latest: a check that can fail the build
# should not change under it on the morning somebody tags that module.
- name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
# The other half of the same gap: scripts/install.sh is the way in for every
# member on macOS and Linux, and nothing had ever run it end to end. It was
# syntax-checked and its failure path exercised by hand; the path that
# actually installs - resolve the release, download, verify the checksum,
# unpack, put the binary somewhere - had not been.
#
# This runs it exactly as a member does, against the published release, and
# then runs what it installed.
installer:
name: Install from the published release (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
# The unauthenticated GitHub API allows 60 requests an hour per IP, and
# hosted runners share addresses: without a token this job fails on the
# version lookup at random, which is a flake and not a finding.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
# INSTALL_DIR inside the runner's temp, so there is no sudo prompt and
# nothing to clean up. The default path is /usr/local/bin and that is
# what the sudo fallback is for; this checks the installer, not sudo.
# /bin/bash explicitly, and the versions on the log, so which shell this
# ran under is a fact and not an assumption. macOS ships bash 3.2.57 -
# frozen in 2007 by its licence - where several things that work
# everywhere else do not.
- name: Which bash is being tested
run: |
/bin/bash --version | head -1
bash --version | head -1
- name: Run the installer
run: INSTALL_DIR="$RUNNER_TEMP/bin" /bin/bash scripts/install.sh
- name: Run what it installed
run: |
set -euo pipefail
"$RUNNER_TEMP/bin/nan" --version
"$RUNNER_TEMP/bin/nan" --help > /dev/null
# The subcommands reach for a session and must fail cleanly without
# one rather than panic, which is the state a fresh machine is in.
"$RUNNER_TEMP/bin/nan" me || true
# The default path, with no INSTALL_DIR. This is what the one-liner on
# nan.builders actually does, and it is the line printed in the README.
- name: Install where the one-liner puts it
run: |
set -euo pipefail
/bin/bash scripts/install.sh
command -v nan
nan --version
# The version lookup failing has to SAY so. It did not: the pipeline runs
# under `set -euo pipefail`, so a grep finding no tag_name - which is the
# rate-limited case - killed the script one line before the message that
# explains it. The message was written this morning and was unreachable.
# Found by this job, on macOS, the first time it ran.
- name: The failed lookup explains itself
env:
GITHUB_TOKEN: ""
run: |
set -uo pipefail
out="$(REPO=helmcode/this-repo-does-not-exist /bin/bash scripts/install.sh 2>&1)" || true
echo "$out"
grep -q "could not work out the latest version" <<<"$out" || {
echo "::error::the lookup failed without explaining itself" >&2
exit 1
}
# And it has to have failed for the reason it says. This step passed
# for a whole release while the script was dying on `auth[@]: unbound
# variable` two lines earlier - the bug produced the very message
# being grepped for, so the assertion was satisfied by the failure it
# existed to tell apart. A shell error in the output is never the
# expected path, whatever text follows it.
if grep -qiE "unbound variable|command not found|syntax error|bad substitution" <<<"$out"; then
echo "::error::the script hit a shell error, and the message that followed was not about the real cause" >&2
exit 1
fi
# And the branch nobody had ever taken: a destination the user cannot
# write to, where install_bin falls back to sudo. On a runner both
# /usr/local/bin and the temp dir are writable, so the fallback would
# never fire on its own and "it installs fine" would say nothing about
# it. A root-owned directory forces it.
- name: Force the sudo fallback
run: |
set -euo pipefail
sudo mkdir -p /opt/nan-sudo-test
sudo chmod 755 /opt/nan-sudo-test
sudo chown root /opt/nan-sudo-test
INSTALL_DIR=/opt/nan-sudo-test/bin /bin/bash scripts/install.sh 2>&1 | tee out.txt
# It has to have actually taken that branch, not quietly succeeded.
grep -q "retrying with sudo" out.txt || {
echo "::error::the sudo fallback never fired, so this proves nothing" >&2
exit 1
}
/opt/nan-sudo-test/bin/nan --version
test -x /opt/nan-sudo-test/bin/nan