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
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
name: Release Binaries
on:
push:
tags:
- 'v*'
# Manual fire from the Actions tab - useful for a re-publish if the
# tagged commit needed a follow-up fix (e.g. Cargo.lock drift).
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to re-publish (e.g. v3.8.26). Leave empty to use main HEAD.'
required: false
# Least privilege by default; each job elevates only what it needs.
permissions:
contents: read
env:
# Pin the Rust toolchain so every runner produces bytes from the same
# compiler (reproducibility). Bump deliberately, never float.
RUST_VERSION: "1.94.0"
jobs:
# ---------------------------------------------------------------------------
# 1. Supply-chain gate. Runs BEFORE anything is built or signed, so we never
# sign a release that pulls a vulnerable / yanked / untrusted-source crate.
# `build` depends on this, so a failure here blocks the whole release.
# ---------------------------------------------------------------------------
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Run cargo-deny natively on our pinned toolchain. The Docker
# cargo-deny-action bundles an older Cargo that cannot parse edition2024
# dependency manifests (e.g. clap_lex); a prebuilt cargo-deny on a current
# Cargo resolves the tree fine.
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2025
with:
toolchain: ${{ env.RUST_VERSION }}
- uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5
with:
tool: cargo-deny
- run: cargo deny check advisories bans sources
build:
needs: audit
permissions:
contents: write # upload assets to the GitHub release
id-token: write # OIDC, for build-provenance attestation
attestations: write # write the provenance attestation
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
name: tina4-linux-amd64
run_smoke: true # runner is x64 Linux -> can execute
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
name: tina4-linux-arm64
run_smoke: false # cross-built ARM, can't run on x64
# Static musl builds exist for CONTAINERS. Every Tina4 production image
# ships the tina4 binary and launches through it, and two of the four
# base images are Alpine -- where a glibc binary cannot exec at all.
# A static musl binary has no loader dependency, so ONE artifact per
# arch serves both the Alpine (php, nodejs) and Debian-slim (python,
# ruby) images. Without these the Dockerfiles have nothing to install.
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: tina4-linux-musl-amd64
run_smoke: true # static binary runs on the x64 Linux runner
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: tina4-linux-musl-arm64
run_smoke: false # cross-built ARM, can't run on x64
- target: x86_64-apple-darwin
os: macos-latest
name: tina4-darwin-amd64
run_smoke: false # runner is arm64, x64 run needs Rosetta (not guaranteed)
- target: aarch64-apple-darwin
os: macos-latest
name: tina4-darwin-arm64
run_smoke: true # runner is arm64 -> can execute
- target: x86_64-pc-windows-msvc
os: windows-latest
name: tina4-windows-amd64.exe
run_smoke: true # runner is x64 Windows -> can execute
runs-on: ${{ matrix.os }}
env:
SIGN_ENABLED: ${{ secrets.CERT_THUMBPRINT }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Rust (pinned)
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2025
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux ARM)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
# musl-tools gives musl-gcc for the NATIVE x86_64 musl build, which the
# tree-sitter C grammars need at the cc step.
- name: Install musl toolchain (x86_64)
if: matrix.target == 'x86_64-unknown-linux-musl'
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
# aarch64-musl is cross-compiled and needs a real aarch64 MUSL toolchain.
# aarch64-linux-gnu-gcc is a GLIBC cross-compiler: it compiles the C
# grammars but cannot link a musl target, and v3.8.60 died exactly there
# ("linking with `aarch64-linux-gnu-gcc` failed"). zig ships musl sysroots
# for every arch, so cargo-zigbuild both compiles and links this cleanly
# without apt hunting for a musl-cross package.
# cargo-zigbuild comes from crates.io; zig does NOT -- install-action only
# resolves crates and failed with "For crate zig: zig is not found".
# The ziglang PyPI wheel ships the real zig toolchain and cargo-zigbuild
# discovers it via `python3 -m ziglang`, so no extra action pin is needed.
- name: Install cargo-zigbuild (aarch64 musl)
if: matrix.target == 'aarch64-unknown-linux-musl'
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2.82.5
with:
tool: cargo-zigbuild
- name: Install zig (aarch64 musl)
if: matrix.target == 'aarch64-unknown-linux-musl'
run: |
python3 -m pip install --user ziglang
python3 -m ziglang version
# --locked forces the committed Cargo.lock - the build can't silently
# resolve a different dependency set than the one that was reviewed.
- name: Build (zigbuild, aarch64 musl)
if: matrix.target == 'aarch64-unknown-linux-musl'
run: cargo zigbuild --release --locked --target ${{ matrix.target }}
- name: Build
if: matrix.target != 'aarch64-unknown-linux-musl'
run: cargo build --release --locked --target ${{ matrix.target }}
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
- name: Rename binary (Unix)
if: runner.os != 'Windows'
run: cp target/${{ matrix.target }}/release/tina4 ${{ matrix.name }}
- name: Rename binary (Windows)
if: runner.os == 'Windows'
run: cp target/${{ matrix.target }}/release/tina4.exe ${{ matrix.name }}
# Don't sign (or ship) a binary that can't even report its version.
# Only runs where the runner arch can execute the target.
- name: Smoke test (Unix)
if: matrix.run_smoke && runner.os != 'Windows'
run: |
chmod +x ${{ matrix.name }}
./${{ matrix.name }} --version
- name: Smoke test (Windows)
if: matrix.run_smoke && runner.os == 'Windows'
shell: pwsh
run: |
& ".\${{ matrix.name }}" --version
# Windows code-signing is a DELIBERATE, LOCAL step (a human enters the EV
# 2FA), NOT a CI step - the SimplySign OTP seed is never stored in CI, so
# nothing automated can sign as us. CI ships a DRAFT release;
# scripts/sign-release.ps1 signs the Windows .exe locally, regenerates
# SHA256SUMS over the signed bytes, and publishes. See scripts/RELEASING.md.
# SLSA provenance over the CI-built artifacts. Skipped for Windows: that
# binary is re-signed locally, so its shipped bytes differ from the build
# output - the EV Authenticode signature is its trust anchor instead.
- name: Attest build provenance
if: runner.os != 'Windows'
uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
with:
subject-path: ${{ matrix.name }}
# Stash the build artifact so the checksums job hashes exactly what the
# draft ships (the Windows .exe is re-signed + re-hashed locally later).
- name: Upload build artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with:
name: ${{ matrix.name }}
path: ${{ matrix.name }}
retention-days: 1
if-no-files-found: error
# NOTE: the matrix deliberately does NOT touch the GitHub release. Eight
# jobs calling action-gh-release concurrently on one tag is a race -- the
# action create-or-updates the same release, and concurrent writers lose
# assets. v3.8.62 shipped 4 of 8 binaries that way (no Windows .exe, so
# signing could not run) while EVERY build job still reported success,
# because the dropped uploads never failed. The `release-assets` job below
# is the single writer.
# ---------------------------------------------------------------------------
# 2. Provisional checksums over the CI-built artifacts, attached to the DRAFT.
# The local signing step (scripts/sign-release.ps1) re-signs the Windows
# .exe and REGENERATES SHA256SUMS over the signed bytes before publishing,
# so the released SHA256SUMS always matches what install.sh/.ps1 verify.
# ---------------------------------------------------------------------------
release-assets:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all build artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
path: dist
merge-multiple: true
# Every target must be present before anything is published. Without this
# a dropped or skipped build yields a draft that looks complete and a
# SHA256SUMS that authoritatively covers half a release -- worse than a
# missing file, because install.sh verifies against it and passes.
- name: Require every expected binary
run: |
set -eu
expected="tina4-linux-amd64 tina4-linux-arm64 tina4-linux-musl-amd64 \
tina4-linux-musl-arm64 tina4-darwin-amd64 tina4-darwin-arm64 \
tina4-windows-amd64.exe"
missing=""
for f in $expected; do [ -f "dist/$f" ] || missing="$missing $f"; done
ls -l dist
if [ -n "$missing" ]; then
echo "::error::draft would be incomplete, refusing to publish. Missing:$missing"
exit 1
fi
- name: Generate SHA256SUMS
run: |
cd dist
sha256sum * > ../SHA256SUMS
echo "Published checksums:"
cat ../SHA256SUMS
# ONE writer, ONE call, all assets. This is what removes the race.
- name: Upload all assets to the draft Release
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: |
dist/*
SHA256SUMS
draft: true # regenerated over the SIGNED bytes by scripts/sign-release.ps1
- name: Confirm the draft actually carries every asset
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
# GitHub's asset listing is EVENTUALLY CONSISTENT. Reading it straight
# after upload reported tina4-linux-amd64 missing on v3.8.63 when the
# asset was in fact there moments later, turning a healthy release
# red. Poll until the set settles rather than trusting one read.
# ONE source of truth for the expected set. The threshold below is
# DERIVED from it, so adding a build target can never leave a hardcoded
# count out of step with the names actually checked.
EXPECTED="tina4-linux-amd64 tina4-linux-arm64 tina4-linux-musl-amd64 tina4-linux-musl-arm64 tina4-darwin-amd64 tina4-darwin-arm64 tina4-windows-amd64.exe SHA256SUMS"
want=$(printf '%s\n' $EXPECTED | grep -c .)
# `|| true` matters under `set -eu`: a transient non-zero from gh (rate
# limit, 5xx, or a 404 on a draft that has not settled) used to kill the
# whole step INSIDE the command substitution, before a single name was
# checked. That reports a healthy, complete release as red and prints
# nothing to diagnose -- exactly what v3.8.64 did with all 8 assets
# present. Retry instead of dying, and let the per-name check decide.
got=""
for attempt in 1 2 3 4 5 6; do
# Read the draft by LISTING releases, never `gh release view <tag>`.
# A DRAFT has no tag association in the API until it is published, so
# GET /releases/tags/<tag> 404s for it -- which `2>/dev/null` then
# swallowed into an empty set, and the guard reported a complete
# release as INCOMPLETE with "actual (0)". That is what v3.8.66 hit
# with all 8 assets present on the draft. GET /releases DOES include
# drafts, so match on tag_name there instead.
got=$(gh api "repos/${{ github.repository }}/releases" --paginate \
--jq ".[] | select(.tag_name==\"${{ github.ref_name }}\") | .assets[].name" \
2>/dev/null | sort || true)
[ "$(printf '%s' "$got" | grep -c .)" -ge "$want" ] && break
echo "attempt $attempt: $(printf '%s' "$got" | grep -c .) of $want assets listed, waiting..."
sleep 10
done
# Report EVERY missing name plus both sets. The old version exited on the
# first miss, so a multi-asset failure needed several reruns to map out.
missing=""
for f in $EXPECTED; do
printf '%s\n' "$got" | grep -qx "$f" || missing="$missing $f"
done
if [ -n "$missing" ]; then
echo "::error::release is INCOMPLETE - never reached the release:$missing"
echo "expected ($want):"; printf '%s\n' $EXPECTED | sed 's/^/ /'
echo "actual ($(printf '%s' "$got" | grep -c .)):"; printf '%s\n' "$got" | sed 's/^/ /'
exit 1
fi
echo "all $want expected assets present:"; printf '%s\n' "$got" | sed 's/^/ /'
publish-crate:
needs: audit
# Publish the source crate ONLY on a final version tag (vX.Y.Z) - never on a
# prerelease/RC tag (vX.Y.Z-rc.N) or a manual branch dispatch, so an RC dry
# run can exercise the binary pipeline without pushing to crates.io.
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Rust (pinned)
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2025
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Publish to crates.io
# --allow-dirty handles the case where CI's cargo invocation
# regenerates Cargo.lock after checkout (the version bump shows up
# as a "modified" file in the working tree even when the lockfile
# is committed on the tagged commit). This is benign - the lock
# content reflects the same Cargo.toml we're about to publish.
run: |
set +e
out=$(cargo publish --allow-dirty 2>&1); rc=$?
echo "$out"
if [ "$rc" -eq 0 ]; then
echo "Published to crates.io."
elif echo "$out" | grep -qiE "already (exists|uploaded)|crate version .* is already"; then
echo "Version already on crates.io - nothing to do."
else
echo "::error::cargo publish failed for a reason other than 'already published' (e.g. auth/403) - see log above."
exit 1
fi
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
# ── tina4-cli container image ────────────────────────────────────────
# Every Tina4 production image installs the tina4 CLI and launches through
# it, so the CLI has to exist AS AN IMAGE for the app Dockerfiles to do
# COPY --from=ghcr.io/tina4stack/tina4-cli:<version> ...
# That is a plain layer copy: no Rust toolchain, no compile, no network
# fetch in the developer's build. All the cost lives here, once per release.
#
# It ships the STATIC MUSL binary on purpose. Two of the four base images
# are Alpine, where a glibc binary cannot exec at all, and a static musl
# build runs unchanged on both Alpine and Debian-slim -- one artifact, four
# images.
publish-cli-image:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download musl binaries
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
pattern: tina4-linux-musl-*
merge-multiple: true
path: cliimg
- name: Stage per-arch binaries
run: |
set -eux
ls -l cliimg
mkdir -p cliimg/linux/amd64 cliimg/linux/arm64
mv cliimg/tina4-linux-musl-amd64 cliimg/linux/amd64/tina4
mv cliimg/tina4-linux-musl-arm64 cliimg/linux/arm64/tina4
chmod +x cliimg/linux/*/tina4
# TARGETPLATFORM lets one Dockerfile serve both arches; buildx sets it
# per platform, so each image gets the binary built for ITS arch. A
# single-arch image here would silently break arm64 deployments.
cat > cliimg/Dockerfile <<'EOF'
FROM alpine:3.21
ARG TARGETPLATFORM
COPY ${TARGETPLATFORM}/tina4 /usr/local/bin/tina4
ENTRYPOINT ["/usr/local/bin/tina4"]
EOF
- name: Log in to GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0
- uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1
- name: Build and push (amd64 + arm64)
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
with:
context: cliimg
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/tina4stack/tina4-cli:${{ github.ref_name }}
ghcr.io/tina4stack/tina4-cli:latest
- name: Verify the pushed image actually runs
# A published image that cannot exec is worse than no image: every
# downstream app build would inherit it. Prove it runs before anyone
# depends on it.
run: |
set -eux
docker run --rm --pull=always \
ghcr.io/tina4stack/tina4-cli:${{ github.ref_name }} --version