tina4 3.8.69

Tina4 — Unified CLI for Python, PHP, Ruby, and Node.js frameworks
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