Repository navigation
208 lines (187 loc) · 9.02 KB
/
Copy pathzig.yml
File metadata and controls
208 lines (187 loc) · 9.02 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
# This workflow is managed by gh actions-lock.
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# Zig FFI: tests, and the C ABI export pin.
#
# `zig build test` covers all five test roots -- main.zig, json_writer.zig,
# file_ops.zig, unified-api-adapter.zig and test/integration_test.zig. The
# adapter root carries the `comptime` 16-connector assertion and the four
# wire-contract tests; integration_test.zig and file_ops.zig carry the rest.
#
# `zig build` then pins the C ABI surface by counting the exported symbols in
# the built shared object, and by naming the six the Idris2 ABI declares
# normative. `MUST.contractile` states C ABI compatibility as an invariant and
# nothing else in the repository verifies it, so a breaking change to any of
# the 18 exports would otherwise land silently (hypatia#696).
#
# Both steps print their denominator. A test runner that runs zero tests and a
# linker that exports zero symbols both exit 0, so a step that does not say how
# many it saw is not evidence.
#
# No `paths:` filter, deliberately -- so it stays eligible to be made a
# required check without the filter/required deadlock.
name: Zig
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
jobs:
zig-test:
name: zig build test (FFI + wire contract)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
env:
# Matches the owner's local toolchain
# (developer/tools/opt/mise/installs/zig/0.16.0) and the `zig` pin in
# mise.toml. The "Agree with the mise.toml pin" step below asserts that
# agreement rather than trusting it.
ZIG_VERSION: '0.16.0'
# Official shasum for zig-x86_64-linux-0.16.0.tar.xz. Pinned rather than
# installed via a third-party action: it adds no `uses:` entry to
# actions.lock.
ZIG_SHA256: '70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00'
# The C ABI surface. This number is the contract, not a measurement of
# it -- changing it is a deliberate ABI decision, never a fix for a red
# build.
EXPECTED_EXPORTS: '18'
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
- name: Agree with the mise.toml pin
run: |
set -euo pipefail
# Two files name the Zig version: this workflow and mise.toml. A
# comment cross-referencing them would document the coupling without
# enforcing it, so they are compared instead (hypatia#813). This runs
# before the toolchain install so a disagreement fails in seconds.
pinned=$(awk -F'"' '/^[[:space:]]*zig[[:space:]]*=/ { print $2; exit }' mise.toml)
if [ -z "${pinned}" ]; then
echo "::error::mise.toml declares no pinned zig version -- it must name the same version as ZIG_VERSION (${ZIG_VERSION})"
exit 1
fi
echo "mise.toml zig = ${pinned}; workflow ZIG_VERSION = ${ZIG_VERSION}"
if [ "${pinned}" != "${ZIG_VERSION}" ]; then
echo "::error::mise.toml pins zig ${pinned} but this workflow installs ${ZIG_VERSION} -- the green result below would not be the version developers build with"
exit 1
fi
- name: Cache the Zig toolchain
id: cache-zig
uses: actions/cache@v6.1.0
with:
path: ~/.local/zig
key: zig-${{ env.ZIG_VERSION }}-x86_64-linux-${{ env.ZIG_SHA256 }}
- name: Install Zig
if: steps.cache-zig.outputs.cache-hit != 'true'
run: |
set -euo pipefail
tarball="zig-x86_64-linux-${ZIG_VERSION}.tar.xz"
curl -fsSL -o "/tmp/${tarball}" \
"https://ziglang.org/download/${ZIG_VERSION}/${tarball}"
echo "${ZIG_SHA256} /tmp/${tarball}" | sha256sum -c -
mkdir -p ~/.local/zig
tar -xJf "/tmp/${tarball}" -C ~/.local/zig --strip-components=1
- name: Put Zig on PATH
run: echo "$HOME/.local/zig" >> "$GITHUB_PATH"
- name: Verify the toolchain
run: |
set -euo pipefail
zig version
test "$(zig version)" = "${ZIG_VERSION}"
- name: Run the Zig tests
working-directory: ffi/zig
run: |
set -euo pipefail
# `--summary all` is what emits the "Build Summary:" line this step
# parses; without it a fully cached green build prints nothing to
# parse. The summary goes to stderr, so keep both streams.
zig build test --summary all 2>&1 | tee "${RUNNER_TEMP}/zig-test.log"
# The denominator. `zig build test` with no reachable `test` block
# exits 0 and reports "0/0 tests passed" -- a green tick that proves
# nothing.
#
# awk, not grep with its exit code suppressed: grep exits 1 when it
# matches nothing, which under `set -e` would have to be swallowed,
# and a swallowed exit code is indistinguishable from a masked
# failure to a reader or a scanner. awk exits 0 whether or not it
# matched, so the no-match case needs no suppression at all and the
# check below stays the only thing that can fail this step.
#
# Both forms carry the same field layout, so one parse covers both:
# Build Summary: 11/11 steps succeeded; 21/21 tests passed
# Build Summary: 9/11 steps succeeded (1 failed); 20/21 tests passed (1 failed)
# The denominator is wanted, not the numerator: a run where tests
# failed has already failed the step via `pipefail`.
count=$(awk '
/^Build Summary:/ {
for (i = 1; i <= NF; i++)
if ($i == "tests") { split($(i-1), a, "/"); print a[2]; exit }
}' "${RUNNER_TEMP}/zig-test.log")
count="${count:-0}"
echo "zig tests run: ${count}"
{
echo "### Zig tests"
echo ""
echo "- command: \`zig build test\` (all five roots)"
echo "- tests run: \`${count}\`"
} >> "${GITHUB_STEP_SUMMARY}"
# Floor, not an exact count: hypatia#812 requires this job to cover
# more than the 7 tests the old single-file scope reached. An exact
# number here would be drift surface -- every new test would redden
# a gate that has nothing to say about it.
if [ "${count}" -lt 8 ]; then
echo "::error::zig build test reported ${count} tests -- expected at least 8 (hypatia#812)"
exit 1
fi
- name: Pin the C ABI export surface
working-directory: ffi/zig
run: |
set -euo pipefail
so='zig-out/lib/libhypatia_ffi.so'
# Remove the artefact before rebuilding. If `zig build` fails, this
# step must not go on to read a shared object left by an earlier
# build -- that reads as a pass for a tree that does not compile.
# A fresh runner has no such file, but the guarantee should not
# depend on the runner being fresh.
rm -f "${so}"
zig build
test -f "${so}" || {
echo "::error::${so} was not built -- nothing to check the ABI against"
exit 1
}
# `grep -c` would exit 1 on zero matches, which is the case this
# step exists to catch; count through awk so the comparison below is
# the only thing that can fail.
exports=$(nm -D --defined-only "${so}" | awk '$2 == "T" && $3 ~ /^hypatia_/ { n++ } END { print n + 0 }')
echo "exported C functions: ${exports} (expected ${EXPECTED_EXPORTS})"
{
echo "### C ABI surface"
echo ""
echo "- exported \`hypatia_*\` functions: \`${exports}\` of \`${EXPECTED_EXPORTS}\`"
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${exports}" -ne "${EXPECTED_EXPORTS}" ]; then
echo "::error::libhypatia_ffi.so exports ${exports} hypatia_* symbols, expected ${EXPECTED_EXPORTS} -- this is a C ABI change (MUST.contractile, hypatia#696)"
nm -D --defined-only "${so}" | awk '$2 == "T" && $3 ~ /^hypatia_/ { print " " $3 }'
exit 1
fi
# A count alone cannot see a rename that preserves it. These six are
# the functions src/Hypatia/ABI/FFI.idr declares normative -- exactly
# the six exports that carry no handle -- so they are named, not
# merely counted.
missing=0
for fn in hypatia_health_check hypatia_scan_repo hypatia_dispatch \
hypatia_record_outcome hypatia_force_learning_cycle \
hypatia_get_confidence; do
if ! nm -D --defined-only "${so}" | awk -v f="${fn}" '$2 == "T" && $3 == f { found = 1 } END { exit !found }'; then
echo "::error::${fn} is declared normative in src/Hypatia/ABI/FFI.idr but is not exported"
missing=$((missing + 1))
fi
done
test "${missing}" -eq 0