pith-pdf 0.1.1

PDF text extraction across classic xref, xref streams, cmap and CID fonts
Documentation
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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
name: CD

# CD handles:
#  - Release on workflow_dispatch (release_type=beta, then stable):
#    better-semantic-release -> tag -> cdylib + SDK artifacts -> opt-in publishes
#  - Tag push -> SDK artifacts for the tag (manual `git tag v* && git push --tags` flow)
#
# A pith repo ships ONE Rust core and up to FOUR SDK packages off the same cdylib:
#   sdk/python (ctypes wheel)  - gated by vars.PUBLISH_PYPI   (PyPI trusted publishing)
#   sdk/node   (koffi)         - gated by vars.PUBLISH_NPM    (npm provenance)
#   sdk/java   (JNI)           - gated by vars.PUBLISH_MAVEN  (Maven Central Portal)
#   sdk/go     (cgo)           - no registry publish; consumers pin the tag,
#                                so the build job only compiles + tests it.
# The Rust crate itself is gated by vars.PUBLISH_CRATE. A repo that lacks an SDK
# directory (pith-h264 has none) skips that SDK's build and publish by itself.
#
# The cdylib is built on five platforms (PITH_PLATFORMS below, one matrix leg each)
# and every package that ships it is checked for all five before it is uploaded:
# scripts/check-package-contents.py lists the wheel / JAR / npm tarball entries and
# fails the job when a platform's library is missing. A registry accepts a package
# without a library and the version can never be replaced, so the check runs first.
#
# Every publish job asks its registry whether the version already exists and skips
# it if so, so re-dispatching a tag fills in what is missing instead of failing on
# what is already there. A version on a registry is permanent; that check is the
# only thing between a retry and a hard error.
#
# Repo Secrets required:
#   GITHUB_TOKEN             - built-in
#   CI_APP_KEY               - n24q02m-ci GitHub App private key (release job: checkout + tag push)
#   CARGO_REGISTRY_TOKEN     - crates.io token (publish-crate)
#   CENTRAL_USERNAME / CENTRAL_PASSWORD / CENTRAL_GPG_PRIVATE_KEY / CENTRAL_GPG_PASSPHRASE
#                            - Maven Central Portal token + signing key (publish-maven)
#
# Repo Variables required:
#   CI_APP_ID                - n24q02m-ci GitHub App ID (see this repo's Actions variables)

on:
  push:
    branches: [main]
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      release_type:
        description: "Release type"
        required: true
        type: choice
        options:
          - beta
          - stable
      publish_existing_tag:
        description: "Recovery only: publish an already-created tag (e.g. v0.1.2), skipping semantic-release. Use when the auto-computed version's tag name is permanently reserved by a GitHub immutable release."
        required: false
        type: string
        default: ""
      expected_version:
        description: "Optional guard: abort before tagging unless the computed version is this one (e.g. 0.1.1, without the -beta.N suffix)."
        required: false
        type: string
        default: ""

env:
  # Release lane: dispatch-only (release_type=beta, then stable); push only feeds tag builds.
  RELEASE_TYPE: ${{ inputs.release_type }}
  # Node-style platform ids. Must name exactly the legs of the build matrix: the
  # package-contents gates read this list, the matrix produces the libraries.
  PITH_PLATFORMS: linux-x64 linux-arm64 darwin-arm64 darwin-x64 win32-x64

permissions:
  contents: write

concurrency:
  group: cd-${{ github.ref }}
  cancel-in-progress: false

jobs:
  readme-sync:
    name: Verify README registry metadata
    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
        with:
          python-version: "3.13"
      - name: Verify README registry metadata
        run: python scripts/repo-bootstrap/verify_readme_sync.py --repo-root=.

  # ==================== Release (workflow_dispatch) ====================
  release:
    name: Semantic Release
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    outputs:
      released: ${{ steps.release.outputs.released || steps.forced.outputs.released }}
      tag: ${{ steps.release.outputs.tag || steps.forced.outputs.tag }}
      version: ${{ steps.release.outputs.version || steps.forced.outputs.version }}
      is_prerelease: ${{ steps.release.outputs.is_prerelease || steps.forced.outputs.is_prerelease }}
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - name: Generate GitHub App Token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          app-id: ${{ vars.CI_APP_ID }}
          private-key: ${{ secrets.CI_APP_KEY }}
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}
      # Version bump runs on the fork (orphan-tag + registry-collision guards).
      # The publish steps below stay upstream — the fork does not replace them.
      - id: plan
        # Read-only: computes the version the next step would tag and mutates nothing.
        name: Plan the release (read-only)
        uses: n24q02m/better-semantic-release@0e57c64481cb78492ad1ff8025b0cd368dbb695f # v1.13.0
        if: inputs.publish_existing_tag == ''
        with:
          github_token: ${{ steps.app-token.outputs.token }}
          config_file: semantic-release.toml
          mode: plan
      - name: Refuse a release that is not the expected version
        # A tag cannot be taken back and a registry version cannot be replaced, so a
        # commit history that would bump the suite off its patch line (0.2.0 next to
        # 0.1.x siblings breaks every `pith-* = "0.1"` pin) stops here, before the tag.
        # `plan` does not know it is a beta, so the -beta.N suffix is cut before comparing.
        if: inputs.publish_existing_tag == '' && inputs.expected_version != ''
        shell: bash
        env:
          PLAN_JSON: ${{ steps.plan.outputs.plan_json }}
          PLAN_BLOCKED: ${{ steps.plan.outputs.plan_blocked }}
          EXPECTED: ${{ inputs.expected_version }}
        run: |
          python3 - <<'PYEOF'
          import json, os, sys
          plan = json.load(open(os.environ["PLAN_JSON"], encoding="utf-8"))
          os.remove(os.environ["PLAN_JSON"])  # keep the worktree clean for the release commit
          planned = (plan.get("version") or "").split("-")[0]
          expected = os.environ["EXPECTED"].removeprefix("v")
          print("planned:", plan.get("version"), "| expected:", expected, "| blocked:", os.environ["PLAN_BLOCKED"])
          if os.environ["PLAN_BLOCKED"] == "true":
              sys.exit("the release plan is blocked: " + json.dumps(plan.get("blockers")))
          if planned != expected:
              sys.exit("computed version " + str(plan.get("version")) + " is not the expected " + expected)
          PYEOF
      - name: Drop the plan file
        if: always() && inputs.publish_existing_tag == ''
        shell: bash
        run: rm -f release-plan.json
      - id: release
        uses: n24q02m/better-semantic-release@0e57c64481cb78492ad1ff8025b0cd368dbb695f # v1.13.0
        if: inputs.publish_existing_tag == ''
        with:
          github_token: ${{ steps.app-token.outputs.token }}
          # Not optional: without it the run silently falls back to defaults and
          # ignores tag_format / allow_zero_version / major_on_zero. That does not
          # fail the job -- it publishes a wrong version number.
          config_file: semantic-release.toml
          prerelease: ${{ env.RELEASE_TYPE == 'beta' }}
          prerelease_token: beta
      - name: Confirm the tag carries the expected version
        # The plan above cannot see the beta suffix; this is the exact comparison.
        if: inputs.publish_existing_tag == '' && inputs.expected_version != '' && steps.release.outputs.released == 'true'
        shell: bash
        env:
          VERSION: ${{ steps.release.outputs.version }}
          EXPECTED: ${{ inputs.expected_version }}
        run: |
          base="${VERSION%%-*}"
          [ "$base" = "${EXPECTED#v}" ] || { echo "released ${VERSION}, expected ${EXPECTED}"; exit 1; }
      - if: inputs.publish_existing_tag == '' && steps.release.outputs.released == 'true'
        uses: python-semantic-release/publish-action@5a5718ce47b892ef699f2972dae122297771d641 # v10.6.1
        with:
          github_token: ${{ steps.app-token.outputs.token }}
          tag: ${{ steps.release.outputs.tag }}
      - name: Use existing tag (immutable-release recovery)
        id: forced
        if: inputs.publish_existing_tag != ''
        run: |
          TAG="${{ inputs.publish_existing_tag }}"
          VERSION="${TAG#v}"
          {
            echo "released=true"
            echo "tag=$TAG"
            echo "version=$VERSION"
            # A hyphen in the version is a semver pre-release (0.1.1-beta.1).
            case "$VERSION" in *-*) echo "is_prerelease=true" ;; *) echo "is_prerelease=false" ;; esac
          } >> "$GITHUB_OUTPUT"

  # ==================== Build ====================
  # One cdylib per platform plus the SDK artifacts that wrap it, on every tag build.
  # Cross-language commands here (pip, npm, go) are the pith suite's whole
  # point: this language variant builds the Python/Node/Go SDKs off the Rust
  # core, unlike the single-language per-language templates.
  #
  # `runs-on` MUST be the matrix value. It used to be the repo's default runner, so
  # all three legs ran on Linux: the npm package would have carried one platform's
  # cdylib and PyPI only ever got a Linux wheel.
  #
  # The matrix is the single list of platforms the suite ships. `platform` is the id
  # Node itself reports (process.platform-process.arch); the npm loader, the JAR
  # resource directory and PITH_PLATFORMS all use that same spelling. Nothing is
  # cross-compiled: each leg builds on a runner of its own architecture, so the
  # wheel and the cdylib it carries cannot disagree.
  build:
    name: Build SDK Artifacts (${{ matrix.platform }})
    if: |
      !cancelled() && needs.release.result != 'failure' && (
        startsWith(github.ref, 'refs/tags/v') ||
        (github.event_name == 'workflow_dispatch' && needs.release.outputs.released == 'true')
      )
    needs: [release]
    runs-on: ${{ matrix.os }}
    # Which SDKs this repo has, read once from the tree. The publish jobs gate on
    # these so a repo without an SDK (pith-h264 has none) does not fail a job that
    # has nothing to ship.
    outputs:
      has_python: ${{ steps.detect.outputs.has_python }}
      has_node: ${{ steps.detect.outputs.has_node }}
      has_java: ${{ steps.detect.outputs.has_java }}
      stem: ${{ steps.stem.outputs.stem }}
    env:
      # Where cargo drops the cdylib. Kept in one place so the SDK build steps
      # below never hardcode a toolchain-internal directory name.
      PITH_CDYLIB_DIR: target/release
    strategy:
      fail-fast: false
      matrix:
        include:
          - { platform: linux-x64, os: ubuntu-latest }
          - { platform: linux-arm64, os: ubuntu-24.04-arm }
          - { platform: darwin-arm64, os: macos-latest }
          - { platform: darwin-x64, os: macos-15-intel }
          - { platform: win32-x64, os: windows-latest }
    steps:
      - name: Harden Runner
        if: runner.os == 'Linux'
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          ref: ${{ needs.release.outputs.tag || github.ref }}
      - name: Detect which SDKs this repo has
        id: detect
        shell: bash
        run: |
          {
            for sdk in python:pyproject.toml node:package.json go:go.mod java:pom.xml; do
              lang="${sdk%%:*}"
              file="${sdk#*:}"
              if [ -f "sdk/$lang/$file" ]; then
                echo "has_$lang=true"
              else
                echo "has_$lang=false"
              fi
            done
          } >> "$GITHUB_OUTPUT"
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @ 2026-08-05
      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
        with:
          python-version: "3.13"
      - name: Name the cdylib
        # The library file name is derived from the one cdylib target the workspace
        # declares, so the package-contents gates never hardcode a per-repo name.
        id: stem
        shell: bash
        run: |
          python - <<'PYEOF'
          import json, os, subprocess, sys
          meta = json.loads(subprocess.check_output(["cargo", "metadata", "--no-deps", "--format-version", "1", "--locked"]))
          stems = sorted({t["name"].replace("-", "_") for p in meta["packages"] for t in p["targets"] if "cdylib" in t["crate_types"]})
          if len(stems) != 1:
              sys.exit("expected exactly one cdylib target in the workspace, found: %r" % stems)
          print("cdylib stem:", stems[0])
          with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out:
              out.write("stem=" + stems[0] + chr(10))
          PYEOF
      - name: Refuse a tag whose manifests disagree
        # The tag, every workspace crate, sdk/python/pyproject.toml,
        # sdk/node/package.json and sdk/java/pom.xml must all carry one version.
        # A registry version is permanent: shipping `0.1.1` out of a package.json
        # that still says `0.1.0` cannot be taken back. Fails before anything builds.
        id: version-guard
        shell: bash
        env:
          EXPECTED_VERSION: ${{ needs.release.outputs.version || github.ref_name }}
        run: |
          python -m pip install --quiet packaging
          cargo metadata --no-deps --format-version 1 --locked > "$RUNNER_TEMP/crates.json"
          CRATES_JSON="$RUNNER_TEMP/crates.json" python - <<'PYEOF'
          import json, os, sys, tomllib
          import xml.etree.ElementTree as ET
          from pathlib import Path
          from packaging.version import Version

          expected = os.environ["EXPECTED_VERSION"].removeprefix("v")
          seen = {}
          crates = json.loads(Path(os.environ["CRATES_JSON"]).read_text(encoding="utf-8"))
          for pkg in crates["packages"]:
              seen["crate " + pkg["name"]] = pkg["version"]
          node = Path("sdk/node/package.json")
          if node.exists():
              seen["sdk/node/package.json"] = json.loads(node.read_text(encoding="utf-8"))["version"]
          pom = Path("sdk/java/pom.xml")
          if pom.exists():
              root = ET.parse(pom).getroot()
              ns = root.tag[: root.tag.index("}") + 1] if root.tag.startswith("{") else ""
              el = root.find(ns + "version")
              seen["sdk/java/pom.xml"] = el.text.strip() if el is not None and el.text else "<missing>"
          bad = [k for k, v in seen.items() if v != expected]
          # PEP 440 spells a beta `0.1.1b1`, semver spells it `0.1.1-beta.1`: compare
          # the pyproject version as a version, not as a string.
          py = Path("sdk/python/pyproject.toml")
          if py.exists():
              found = tomllib.loads(py.read_text(encoding="utf-8")).get("project", {}).get("version")
              if found is not None:
                  seen["sdk/python/pyproject.toml"] = found
                  if Version(found) != Version(expected):
                      bad.append("sdk/python/pyproject.toml")
          for key, value in sorted(seen.items()):
              print(("BAD " if key in bad else "ok  ") + key + " = " + value)
          if bad:
              sys.exit("these manifests disagree with tag version " + expected + ": " + ", ".join(bad))
          PYEOF
      - name: Build the core cdylib
        # --locked for the same reason ci.yml uses it on clippy and test: without it
        # cargo is free to re-resolve, so the SDK binaries attached to the release are
        # built against a dependency set CI never ran.
        shell: bash
        run: cargo build --workspace --release --locked
      - name: Regenerate reference.json (hex-exact vectors)
        # Written next to the artifacts so every SDK package ships byte-identical
        # vectors; the committed copy is verified in ci.yml.
        shell: bash
        run: |
          cargo run --locked --bin gen-reference -- gen
          # pith-math keeps its vectors in tests/ and its generator writes there; the
          # other repos write ./reference.json directly. Either way the file that
          # ships is the one in the repository root.
          test -f reference.json || cp tests/reference.json reference.json
      - name: Collect cdylibs for the SDKs
        shell: bash
        run: |
          mkdir -p dist/sdk
          find "$PITH_CDYLIB_DIR" -maxdepth 1 -type f \
            \( -name '*.so' -o -name '*.dylib' -o -name '*.dll' \) \
            -exec cp {} dist/sdk/ \;
          ls -la dist/sdk
      - name: Stage the cdylib under this platform's id
        # The Node loader looks for prebuilds/<platform>/ and the JAR carries the same
        # directory name under hash/pith/native/, so each leg stages its own cdylib
        # under the matrix id. Unconditional: the Java job and the release assets
        # read it too, and a repo without an SDK just uploads a directory nobody reads.
        shell: bash
        run: |
          mkdir -p "dist/prebuilds/${{ matrix.platform }}" dist/assets
          cp dist/sdk/* "dist/prebuilds/${{ matrix.platform }}/"
          for f in dist/sdk/*; do cp "$f" "dist/assets/${{ matrix.platform }}-$(basename "$f")"; done
          ls -la "dist/prebuilds/${{ matrix.platform }}"
      - name: Build the Python wheel (ctypes)
        # sdk/python's build backend copies the cdylib from PITH_CDYLIB_DIR into
        # the wheel. Wheels are platform-specific, so this runs inside the matrix;
        # publish-pypi uploads all of them together. Wheels only: a plain
        # sdist cannot carry the binary and is not shipped.
        if: steps.detect.outputs.has_python == 'true'
        shell: bash
        run: |
          python -m pip install --upgrade build
          python -m build --wheel sdk/python
      - name: Repair the Linux wheel to manylinux
        # PyPI rejects the bare linux_<arch> platform tag; auditwheel rewrites it
        # to manylinux_2_*_<arch> and bundles the cdylib it depends on.
        if: runner.os == 'Linux' && steps.detect.outputs.has_python == 'true'
        shell: bash
        run: |
          python -m pip install --upgrade auditwheel
          auditwheel repair sdk/python/dist/*.whl -w sdk/python/dist-manylinux
          mv sdk/python/dist-manylinux/*.whl sdk/python/dist/
      - name: Set up Go for the cgo binding
        if: steps.detect.outputs.has_go == 'true'
        uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
        with:
          go-version-file: sdk/go/go.mod
      - name: Go build and test
        # sdk/go loads the freshly built cdylib at run time (dlopen / LoadLibrary), so
        # the directory has to be absolute: the tests run from inside sdk/go.
        if: steps.detect.outputs.has_go == 'true'
        shell: bash
        env:
          PITH_CDYLIB_DIR: ${{ github.workspace }}/target/release
        run: |
          cd sdk/go
          CGO_ENABLED=1 go build ./...
          CGO_ENABLED=1 go test ./...
      - name: Upload SDK artifacts
        uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
        with:
          # dist/sdk/ is not uploaded: libpith_x.so is the same file name on both Linux
          # legs (and .dylib on both macOS legs), so merging the artifacts would let
          # one overwrite the other. dist/prebuilds/<platform>/ keeps them apart.
          name: sdk-${{ matrix.platform }}
          path: |
            dist/prebuilds/
            dist/assets/
            sdk/python/dist/
            reference.json
      - name: Attach build output to the release
        # The legs attach the same reference.json, so the asset delete/replace
        # can race and 404. That must not take the publish jobs down with it.
        continue-on-error: true
        if: vars.PUBLISH_RELEASE_ASSET == 'true'
        uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
        with:
          tag_name: ${{ needs.release.outputs.tag || github.ref_name }}
          files: |
            dist/assets/*
            reference.json

  # ==================== Publish crate ====================
  publish-crate:
    name: Publish to crates.io
    needs: [readme-sync, release, build]
    # `!cancelled()` switches off the implicit "every need succeeded" gate, so
    # each dependency this job actually relies on has to be named here or it is
    # only decorative. readme-sync is named for that reason: a crates.io version
    # is permanent, and yanking it does not take the wrong README back down.
    if: |
      !cancelled() && needs.build.result == 'success' &&
      needs.readme-sync.result == 'success' &&
      vars.PUBLISH_CRATE == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.release.outputs.tag || github.ref }}
      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @ 2026-08-05
      - name: Publish the crates crates.io does not have yet
        # A crates.io version cannot be replaced, only yanked. Without --locked the
        # artifact that goes up is built against a resolution CI never tested.
        # `cargo publish --workspace` errors on a version that already exists, so each
        # workspace crate is looked up first and only the missing ones are passed on;
        # one invocation keeps cargo's own dependency ordering between them.
        shell: bash
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          cargo metadata --no-deps --format-version 1 --locked > "$RUNNER_TEMP/crates.json"
          pending=()
          while read -r name version; do
            code="$(curl -sS -o /dev/null -w '%{http_code}' -A "pith-hash-cd (github.com/${GITHUB_REPOSITORY})" "https://crates.io/api/v1/crates/${name}/${version}")"
            case "$code" in
              200) echo "skip ${name} ${version}: already on crates.io" ;;
              404) pending+=("-p" "$name") ;;
              *) echo "crates.io answered HTTP ${code} for ${name} ${version}"; exit 1 ;;
            esac
          done < <(jq -r '.packages[] | select(.publish != []) | "\(.name) \(.version)"' "$RUNNER_TEMP/crates.json")
          if [ "${#pending[@]}" -eq 0 ]; then
            echo "every crate is already on crates.io"
            exit 0
          fi
          cargo publish --locked "${pending[@]}"

  # ==================== Publish Python SDK ====================
  publish-pypi:
    name: Publish Python SDK to PyPI
    needs: [readme-sync, release, build]
    if: |
      !cancelled() && needs.build.result == 'success' &&
      needs.readme-sync.result == 'success' &&
      needs.build.outputs.has_python == 'true' &&
      vars.PUBLISH_PYPI == 'true'
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.release.outputs.tag || github.ref }}
      - name: Download SDK artifacts from every platform
        uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
        with:
          pattern: sdk-*
          merge-multiple: true
          path: wheelhouse
      - name: Collect wheels
        # Each Linux leg leaves both the bare linux_<arch> wheel and its manylinux
        # repair in the artifact; PyPI rejects the first, so it is left behind.
        run: |
          mkdir -p wheelhouse-flat
          find wheelhouse -name '*.whl' ! -name '*-linux_*' -exec cp {} wheelhouse-flat/ \;
          ls -la wheelhouse-flat/
      - name: Check wheels
        run: |
          python -m pip install --upgrade twine
          twine check wheelhouse-flat/*.whl
      - name: Refuse a wheel set that misses a platform
        # twine check only validates metadata. A wheel without its cdylib, or a
        # platform with no wheel at all, installs and then fails at import, and the
        # version cannot be replaced on PyPI.
        env:
          STEM: ${{ needs.build.outputs.stem }}
        run: |
          python scripts/check-package-contents.py wheels 'wheelhouse-flat/*.whl' --platforms "${PITH_PLATFORMS// /,}" --stem "$STEM"
      - name: Publish to PyPI
        # OIDC trusted publishing -- no PyPI token is stored on the repo.
        # skip-existing: a re-dispatch uploads only the wheels PyPI is missing
        # (e.g. the macOS and Windows wheels of a version that only has Linux).
        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
        with:
          packages-dir: wheelhouse-flat
          skip-existing: true

  # ==================== Publish Node SDK ====================
  publish-npm:
    name: Publish Node SDK to npm
    needs: [readme-sync, release, build]
    if: |
      !cancelled() && needs.build.result == 'success' &&
      needs.readme-sync.result == 'success' &&
      needs.build.outputs.has_node == 'true' &&
      vars.PUBLISH_NPM == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.release.outputs.tag || github.ref }}
      - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"
      - name: Check whether npm already has this version
        # npm answers an unknown version of a known package with exit 0 and no output,
        # and an unknown package with E404. Anything else (network, auth, 5xx) is not
        # an answer, so it fails instead of being read as "not there".
        id: exists
        shell: bash
        env:
          VERSION: ${{ needs.release.outputs.version || github.ref_name }}
        run: |
          VERSION="${VERSION#v}"
          NAME="$(node -p "require('./sdk/node/package.json').name")"
          rc=0
          out="$(npm view "${NAME}@${VERSION}" version 2>"$RUNNER_TEMP/npm-view.err")" || rc=$?
          if [ "$rc" -eq 0 ] && [ "$out" = "$VERSION" ]; then
            echo "${NAME}@${VERSION} is already on npm"
            echo "skip=true" >> "$GITHUB_OUTPUT"
          elif [ "$rc" -eq 0 ] && [ -z "$out" ]; then
            echo "skip=false" >> "$GITHUB_OUTPUT"
          elif grep -q E404 "$RUNNER_TEMP/npm-view.err"; then
            echo "skip=false" >> "$GITHUB_OUTPUT"
          else
            cat "$RUNNER_TEMP/npm-view.err"
            exit 1
          fi
      - name: Download SDK artifacts from every platform
        if: steps.exists.outputs.skip == 'false'
        uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
        with:
          pattern: sdk-*
          merge-multiple: true
          path: sdk-dist
      - name: Assemble the npm package with prebuilt cdylibs
        # Each leg staged its cdylib under prebuilds/<platform>/. The package is packed
        # here, then read back by the next step before anything is published.
        if: steps.exists.outputs.skip == 'false'
        id: pack
        shell: bash
        run: |
          mkdir -p sdk/node/prebuilds
          cp -R sdk-dist/dist/prebuilds/. sdk/node/prebuilds/
          cp sdk-dist/reference.json sdk/node/
          cd sdk/node
          npm ci
          TARBALL="$RUNNER_TEMP/$(npm pack --silent --pack-destination "$RUNNER_TEMP")"
          echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
      - name: Refuse a tarball that misses a platform
        # A package missing one platform installs fine and then fails to load on that
        # OS, and the version cannot be replaced, so the tarball that will be
        # published is opened and every platform's library must be in it.
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        env:
          STEM: ${{ needs.build.outputs.stem }}
          TARBALL: ${{ steps.pack.outputs.tarball }}
        run: |
          python3 scripts/check-package-contents.py npm "$TARBALL" --platforms "${PITH_PLATFORMS// /,}" --stem "$STEM"
      - name: Publish to npm
        # OIDC provenance -- no npm token is stored on the repo. The gated tarball
        # itself is what goes up, not a second pack of the same directory.
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        working-directory: sdk/node
        env:
          VERSION: ${{ needs.release.outputs.version || github.ref_name }}
          TARBALL: ${{ steps.pack.outputs.tarball }}
        run: |
          case "${VERSION#v}" in
            *-*) DIST_TAG=beta ;;
            *) DIST_TAG=latest ;;
          esac
          npm publish "$TARBALL" --provenance --no-git-checks --tag "$DIST_TAG"

  # ==================== Publish Java SDK ====================
  publish-maven:
    name: Publish Java SDK to Maven Central
    needs: [readme-sync, release, build]
    # Same !cancelled() discipline as publish-crate: the implicit "every need
    # succeeded" gate is switched off, so each dependency this job actually
    # relies on is named here. readme-sync gates the publish because a Maven
    # Central version is permanent and cannot take a wrong README back down.
    if: |
      !cancelled() && needs.build.result == 'success' &&
      needs.readme-sync.result == 'success' &&
      needs.build.outputs.has_java == 'true' &&
      vars.PUBLISH_MAVEN == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
        with:
          egress-policy: audit
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.release.outputs.tag || github.ref }}
      - name: Check whether Maven Central already has this version
        id: exists
        shell: bash
        env:
          VERSION: ${{ needs.release.outputs.version || github.ref_name }}
        run: |
          VERSION="${VERSION#v}"
          read -r GROUP ARTIFACT < <(python3 -c "import xml.etree.ElementTree as ET; r = ET.parse('sdk/java/pom.xml').getroot(); n = r.tag[: r.tag.index('}') + 1]; print(r.find(n + 'groupId').text, r.find(n + 'artifactId').text)")
          URL="https://repo1.maven.org/maven2/${GROUP//.//}/${ARTIFACT}/${VERSION}/${ARTIFACT}-${VERSION}.pom"
          code="$(curl -sS -o /dev/null -w '%{http_code}' "$URL")"
          case "$code" in
            200) echo "${GROUP}:${ARTIFACT}:${VERSION} is already on Maven Central"; echo "skip=true" >> "$GITHUB_OUTPUT" ;;
            404) echo "skip=false" >> "$GITHUB_OUTPUT" ;;
            *) echo "Maven Central answered HTTP ${code} for ${URL}"; exit 1 ;;
          esac
      - name: Download the cdylibs built by the matrix
        # The Java tests replay reference.json through the real JNI bindings, so the
        # artifacts only go up after the suite passes hex-exact against the cdylib the
        # build job made. Nothing is rebuilt here: a second cargo build could drift from
        # the binaries the other registries received.
        if: steps.exists.outputs.skip == 'false'
        uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
        with:
          pattern: sdk-*
          merge-multiple: true
          path: sdk-dist
      - name: Bundle every platform's cdylib into the JAR
        # PithNative extracts hash/pith/native/<platform>/<library> from the JAR when
        # no override is set, so a JAR built without these resources only works where
        # PITH_CDYLIB_DIR is set by hand. Every platform must be present: the version
        # cannot be replaced, and the JAR is the whole package.
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        run: |
          for platform in $PITH_PLATFORMS; do
            dest="sdk/java/src/main/resources/hash/pith/native/$platform"
            mkdir -p "$dest"
            cp sdk-dist/dist/prebuilds/"$platform"/* "$dest"/
            test -n "$(ls -A "$dest")" || { echo "no cdylib for $platform"; exit 1; }
          done
          find sdk/java/src/main/resources/hash/pith/native -type f
      - name: Set up JDK 21 with Maven Central credentials and the GPG key
        # server-id `central` lands in ~/.m2/settings.xml and matches the
        # central-publishing-maven-plugin's publishingServerId; the gpg inputs
        # import the signing key and wire GPG_PASSPHRASE into the
        # gpg.passphrase server entry that maven-gpg-plugin reads.
        if: steps.exists.outputs.skip == 'false'
        uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
        with:
          distribution: temurin
          java-version: "21"
          server-id: central
          server-username: CENTRAL_USERNAME
          server-password: CENTRAL_PASSWORD
          gpg-private-key: ${{ secrets.CENTRAL_GPG_PRIVATE_KEY }}
          gpg-passphrase: GPG_PASSPHRASE
      - name: Test, sign and bundle the Java SDK
        # Two passes. `verify` runs the tests (against the linux-x64 cdylib the build
        # job produced; the other platforms are checked by the package-contents gate
        # below, not executed) and signs, so a failing test or signature stops here with
        # a real exit code. The second pass only assembles central-bundle.zip: it points
        # the plugin at an unreachable Central URL so the bundle is built and the upload
        # fails, which `|| true` absorbs; the file check is what proves it was assembled.
        # -DskipPublishing=true is NOT usable here: central-publishing 0.11.0 then skips
        # the bundling too and writes no zip.
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        env:
          CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
          CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
          GPG_PASSPHRASE: ${{ secrets.CENTRAL_GPG_PASSPHRASE }}
          PITH_CDYLIB_DIR: ${{ github.workspace }}/sdk-dist/dist/prebuilds/linux-x64
        run: |
          mvn -B -Prelease verify -f sdk/java/pom.xml
          mvn -B -Prelease -DskipTests -DcentralBaseUrl=http://127.0.0.1:9 deploy -f sdk/java/pom.xml || true
          BUNDLE=sdk/java/target/central-publishing/central-bundle.zip
          test -f "$BUNDLE" || { echo "bundle missing"; exit 1; }
          python3 - <<'PYEOF'
          import zipfile, os
          src = "sdk/java/target/central-publishing/central-bundle.zip"
          zin = zipfile.ZipFile(src)
          zout = zipfile.ZipFile(src + ".clean", "w", zipfile.ZIP_DEFLATED)
          kept = []
          for n in zin.namelist():
              name = n.replace("\\", "/")
              base = name.split("/")[-1]
              if base.startswith("maven-metadata") or base.startswith("_remote.repositories"):
                  continue
              zout.writestr(name, zin.read(n))
              kept.append(name)
          zout.close(); zin.close(); os.replace(src + ".clean", src)
          poms = [n for n in kept if n.endswith(".pom")]
          ascs = [n for n in kept if n.endswith(".asc")]
          assert len(poms) >= 1 and len(ascs) >= 4, "bundle incomplete after clean: %s" % kept
          print("clean bundle:", len(kept), "entries; poms:", poms)
          PYEOF
      - name: Refuse a JAR that misses a platform
        # The JAR inside the bundle is the file Maven Central will serve. Its entries
        # are listed (never executed) and every platform's library must be there.
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        env:
          STEM: ${{ needs.build.outputs.stem }}
        run: |
          python3 - <<'PYEOF'
          import os, zipfile
          bundle = zipfile.ZipFile("sdk/java/target/central-publishing/central-bundle.zip")
          jars = [n for n in bundle.namelist() if n.endswith(".jar") and not n.endswith(("-sources.jar", "-javadoc.jar"))]
          assert len(jars) == 1, "expected one main JAR in the bundle, found: %r" % jars
          with open(os.path.join(os.environ["RUNNER_TEMP"], "main.jar"), "wb") as out:
              out.write(bundle.read(jars[0]))
          print("main jar:", jars[0])
          PYEOF
          python3 scripts/check-package-contents.py jar "$RUNNER_TEMP/main.jar" --platforms "${PITH_PLATFORMS// /,}" --stem "$STEM" --prefix hash/pith/native
      - name: Upload the Java SDK to Maven Central
        if: steps.exists.outputs.skip == 'false'
        shell: bash
        env:
          CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
          CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
        run: |
          BUNDLE=sdk/java/target/central-publishing/central-bundle.zip
          TOKEN="$(printf "%s:%s" "$CENTRAL_USERNAME" "$CENTRAL_PASSWORD" | base64 -w0)"
          echo "::add-mask::$TOKEN"
          ID="$(curl -sS --fail-with-body -X POST -H "Authorization: Bearer $TOKEN" -F "bundle=@$BUNDLE" "https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC")"
          echo "deployment: $ID"
          for _ in $(seq 1 60); do
            sleep 10
            ST="$(curl -sS --fail-with-body -X POST -H "Authorization: Bearer $TOKEN" "https://central.sonatype.com/api/v1/publisher/status?id=$ID")" || ST=""
            echo "$ST"
            if echo "$ST" | grep -q '"FAILED"'; then
              exit 1
            fi
            if echo "$ST" | grep -qE '"(PUBLISHING|PUBLISHED)"'; then
              exit 0
            fi
          done
          echo "deployment $ID never left validation"
          exit 1