-
Notifications
You must be signed in to change notification settings - Fork 0
1890 lines (1831 loc) · 98.5 KB
/
Copy pathrelease.yml
File metadata and controls
1890 lines (1831 loc) · 98.5 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
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
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: CI
# ONE pipeline for everything. On EVERY push (any branch) and pull request it
# builds and tests the whole product in parallel -- the full tox matrix
# (lint/mypy/pytest), the wheel + sdist, the self-contained PyInstaller binaries
# for Linux (amd64, arm64, i686, armv7, ppc64le, s390x and riscv64 in both glibc
# and musl/Alpine flavors, plus armv6 in musl only), macOS (arm64 + amd64 on
# macOS 15, shipped; plus macOS 26/Tahoe as non-gating CI coverage) and Windows
# (amd64 + arm64), and every Docker image (8 distros, full arch set, build-only)
# -- and gates on ALL of it. The `perf` job runs in the same fan-out: it
# benchmarks this commit against the latest release tag (paired on one runner,
# see benchmarks/) and, on a release, a regression beyond a metric's limit
# fails the gate, while the resulting chart + summary are attached to the
# GitHub Release and appended to its notes.
# No SOFTWARE is published on an ordinary commit; the
# one thing that is, is documentation -- a push to `develop` publishes wiki/ to
# the GitHub wiki (the `wiki` job, ungated, since a doc is not an artifact).
#
# A release happens ONLY when either:
# - a push to `main` carries a [release] marker at the START of the SUBJECT
# line of any of its commits:
# [release] -> minor (default)
# [release:major] -> major
# [release:minor] -> minor
# [release:patch] -> patch
# (if several pushed commits carry a marker, the LATEST such commit wins;
# bodies are never scanned, so prose that merely mentions a marker cannot
# trigger or escalate a release -- 1.3.0 shipped off a [release:patch]
# push because a commit body discussing the bare [release] marker
# out-bumped it), or
# - it is started via workflow_dispatch (bump from the dropdown, or an exact
# `tag` to release a specific X.Y.Z; the `tag` input wins over `bump`).
#
# On a release, once EVERY build + test job above has succeeded (the gate),
# and ONLY then, the pipeline publishes: the wheel + sdist to PyPI via Trusted
# Publishing (OIDC, skip-existing so a retry is a no-op), tags the commit
# (RELEASE_TOKEN so the tag can point at a commit that touches
# .github/workflows/), cuts a GitHub Release with ALL prebuilt binaries + a
# single SHA256SUMS, pushes every Docker image, and updates the Homebrew tap.
# Because the Release is gated on all binaries AND the Docker build, a broken or
# flaky arch (or Dockerfile) fails the run BEFORE anything is published -- never
# a half release. A failed publish leaves no orphan tag (tagged only after PyPI
# accepts), so a re-run cleanly retries.
#
# This file replaces the former build.yml + docker.yml + tox.yml + release.yml,
# whose binary/tox builds it deduplicates into one definition each.
#
# Example commit message (marker leads the subject line):
# [release:minor] Add retry backoff to the HTTP reporter
on:
push:
branches: ["**"] # build + test on every commit, on every branch
pull_request: # and on every PR (covers fork PRs, which don't push here)
workflow_dispatch:
inputs:
bump:
description: "Version bump level (ignored when 'tag' is set)"
type: choice
options: [patch, minor, major]
default: patch
tag:
description: "Exact version to release as X.Y.Z (overrides bump); leave blank to compute the next one"
type: string
default: ""
# One in-flight run per (head repo, branch). A same-repo branch push and its
# PR still share a group, so the duplicate is cancelled rather than run twice.
# The group is qualified by the HEAD repository because cancel-in-progress
# evaluates on the INCOMING run: an unqualified group let a fork PR whose head
# branch was named `main` land in the push-to-main group and cancel an
# in-flight release mid-publish (its own ref is refs/pull/N/merge, so the
# expression below was true for it). main is never cancelled by main pushes
# (cancel-in-progress false there): consecutive main pushes QUEUE so releases
# serialize instead of racing, and a workflow_dispatch release is likewise
# protected from a later push cancelling it.
concurrency:
group: ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.event_name != 'workflow_dispatch' }}
# Least-privilege default for all jobs; the publish jobs opt up to the narrower
# write scopes they actually need (see each one's own permissions:).
permissions:
contents: read
# Network hardening for transient PyPI/network blips. Runner-native jobs install
# with uv (UV_HTTP_TIMEOUT raises its per-request socket timeout; uv retries
# internally) and UV_PYTHON_DOWNLOADS=never keeps uv on the setup-python
# interpreter rather than fetching a managed one. The emulated foreign-arch
# binary jobs stay on pip inside their `docker run` containers (uv's official
# image is amd64/arm64 only and lacks musl ppc64le/s390x wheels) and read
# PIP_RETRIES/PIP_TIMEOUT, forwarded into `docker run` with `-e`. They also
# forward PIP_CACHE_DIR, pointed at an actions/cache-persisted directory in
# the mounted checkout, so the wheels pip compiles under QEMU carry over
# between runs instead of being rebuilt on every push.
env:
PIP_RETRIES: "5"
PIP_TIMEOUT: "60"
UV_HTTP_TIMEOUT: "60"
UV_PYTHON_DOWNLOADS: "never"
jobs:
# ---------------------------------------------------------------------------
# Decide + version (run on every event; feed both the gate and the publish).
# ---------------------------------------------------------------------------
# Decide whether this run is a release (and at what level), then compute the
# version: two steps in ONE job. They ran as separate serial jobs before, but
# each needed the same full-history checkout and every builder waited on both
# runner spin-ups, so merging them starts the whole fan-out sooner.
#
# The decide step is kept strict so the trigger logic lives in a real script,
# never a fuzzy `contains()` match. A release requires a workflow_dispatch OR
# a push to main with a [release] marker -- a marker on any other branch, or a
# PR, never releases (this file triggers on all branches + PRs, so the decide
# step itself must enforce the main-only rule the old push:branches:[main]
# filter used to).
#
# The version step computes the version ONCE so every builder pins the same
# number via SETUPTOOLS_SCM_PRETEND_VERSION. It always runs (the gate builds
# always need a version): on a release it computes the next X.Y.Z (or the
# explicit `tag` override) and refuses if that tag already exists; otherwise
# it emits the natural setuptools_scm dev version (e.g. 1.2.9.devN+g<sha>),
# exactly as the old build.yml did for its per-commit builds.
version:
runs-on: ubuntu-latest
outputs:
release: ${{ steps.d.outputs.release }}
bump: ${{ steps.d.outputs.bump }}
new: ${{ steps.ver.outputs.new }}
docker-matrix: ${{ steps.distros.outputs.matrix }}
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # full history + tags: the marker scan walks BEFORE..AFTER and setuptools_scm / tag math resolve
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Decide release
id: d
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
INPUT_BUMP: ${{ github.event.inputs.bump }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
set -euo pipefail
release=false
bump=minor
if [ "$EVENT" = "workflow_dispatch" ]; then
release=true
bump="${INPUT_BUMP:-minor}"
elif [ "$EVENT" = "push" ] && [ "$REF" = "refs/heads/main" ]; then
# Trigger if ANY commit introduced by this push has a [release]
# marker -- not just the tip commit (the marker usually sits on a
# develop commit one behind the merge commit that lands on main).
# The range is the commits new in this push (BEFORE..AFTER); on a
# brand-new branch BEFORE is all-zeros, so fall back to the tip.
zero="0000000000000000000000000000000000000000"
if [ -z "${BEFORE:-}" ] || [ "$BEFORE" = "$zero" ] \
|| ! git rev-parse -q --verify "${BEFORE}^{commit}" >/dev/null; then
subjects="$(git log -1 --pretty=%s "$AFTER")"
else
subjects="$(git log --pretty=%s "${BEFORE}..${AFTER}")"
fi
# Only commit SUBJECTS are scanned, and the marker must be at the
# very start of the subject. Bodies are never scanned: prose that
# merely mentions a marker (a changelog quote, a commit that
# documents this very syntax, dependabot notes) must not trigger
# or escalate a release -- that is exactly how 1.3.0 shipped from
# a [release:patch] push. If several commits carry a marker, the
# LATEST one wins (git log lists newest first); a bare [release]
# counts as minor.
marker="$(printf '%s\n' "$subjects" \
| grep -oiE '^\[release(:(major|minor|patch))?\]' \
| head -n1 \
| tr '[:upper:]' '[:lower:]' || true)"
if [ -n "$marker" ]; then
release=true
case "$marker" in
"[release:major]") bump=major ;;
"[release:patch]") bump=patch ;;
*) bump=minor ;;
esac
fi
fi
# else: a PR or a non-main push -> build + test only, never release.
echo "release=$release" >> "$GITHUB_OUTPUT"
echo "bump=$bump" >> "$GITHUB_OUTPUT"
echo "::notice::release=$release bump=$bump (event=$EVENT ref=$REF)"
- name: Compute version
id: ver
env:
RELEASE: ${{ steps.d.outputs.release }}
BUMP: ${{ steps.d.outputs.bump }}
OVERRIDE: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
if [ "$RELEASE" != "true" ]; then
# Build-only run: resolve setuptools_scm's natural dev version off
# git in a throwaway env (no venv/install left behind).
new="$(uv run --no-project --with setuptools-scm python -m setuptools_scm)"
echo "new=$new" >> "$GITHUB_OUTPUT"
echo "::notice::Build-only at version $new (no release)."
exit 0
fi
git fetch --tags --force
if [ -n "${OVERRIDE:-}" ]; then
# Explicit override from workflow_dispatch: release exactly this
# version instead of the computed next one. Tags in this repo are
# bare X.Y.Z and the rest of the pipeline (HISTORY.md lookup, asset
# names, the PyPI/scm version) assumes that shape, so reject anything
# else rather than silently building a malformed release.
new="$OVERRIDE"
if ! printf '%s' "$new" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Invalid tag '$new'; expected X.Y.Z (digits only, no leading 'v')." >&2
exit 1
fi
echo "::notice::Releasing $new (explicit tag override)"
else
latest=$(git tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)
latest=${latest:-0.0.0}
IFS='.' read -r major minor patch <<<"$latest"
case "$BUMP" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
new="${major}.${minor}.${patch}"
echo "::notice::Releasing $new (previous $latest, bump=$BUMP)"
fi
# Never clobber an existing tag (a failed publish leaves no orphan tag,
# so a clean retry is still possible).
if git rev-parse -q --verify "refs/tags/$new" >/dev/null; then
echo "::error::Tag $new already exists; refusing to re-release." >&2
exit 1
fi
echo "new=$new" >> "$GITHUB_OUTPUT"
# The ONE distro matrix (.github/docker-matrix.json): `docker` (the
# build-only gate) and `docker-push` both fromJSON this output, so the
# two lists can never drift apart (they were written twice and did).
# The gate ignores the per-row `suffix`; only the push tags use it.
- name: Emit the docker distro matrix
id: distros
run: |
set -euo pipefail
{
echo 'matrix<<JSON'
cat .github/docker-matrix.json
echo 'JSON'
} >> "$GITHUB_OUTPUT"
# ---------------------------------------------------------------------------
# Gate: tests + all binaries + all Docker images. Runs on EVERY event, in
# parallel. The publish jobs below need every one of these to succeed.
# ---------------------------------------------------------------------------
# lint + mypy + bandit in one job: each env finishes in well under a minute,
# so a second runner spin-up costs more wall clock than running them serially.
tox-static:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
# This repository is LF-only (see .gitattributes). Fail fast if any
# tracked blob ever reaches the index with CRLF or mixed line endings,
# e.g. one that slipped past `text=auto` normalization by being marked
# binary or committed by a client that ignored .gitattributes.
- name: Guard against CRLF line endings
run: |
crlf="$(git ls-files --eol | grep -E 'i/(crlf|mixed)' || true)"
if [ -n "$crlf" ]; then
echo "::error::Tracked files with CRLF/mixed line endings (this repo is LF-only):"
echo "$crlf"
exit 1
fi
- uses: actions/setup-python@v7.0.0
# tox-uv pulls in uv and makes tox provision envs + install deps with it.
- run: pip install tox tox-uv
- run: tox -e lint,mypy,bandit,openapi
# Dependency-license gate. cronstable's core is MIT and it BUNDLES its deps
# (the PyInstaller binaries and Docker images vendor the whole tree), so a
# copyleft (GPL/AGPL) or non-open source-available (SSPL/BUSL) dependency must
# never slip in. scripts/check_licenses.py reads the INSTALLED dist metadata
# (no third-party scanner) and fails on a disallowed license; weak file-scoped
# copyleft (LGPL/MPL) is reported but allowed. It installs the runtime plus
# every distributable extra (`speedups`, `push`, and `discovery` all ride in
# the binaries and Docker images) so nothing the artifacts can carry escapes
# the scan. zeroconf (discovery) is LGPL-2.1: expected in the report, and
# shipped with its compliance kit (in-binary notice, Release source archive,
# public build recipe). See LICENSING.md.
licenses:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Install runtime + distributable extras
run: |
set -euo pipefail
uv venv --python 3.14
uv pip install ".[speedups,push,discovery]"
- name: Check dependency licenses
run: uv run python .github/scripts/check_licenses.py
tox:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest] # macOS is POSIX like Linux; add macos-latest to cover it too
python: ['3.10', '3.11', '3.12', '3.13', '3.14']
experimental: [false]
include:
# Windows on ARM64: a gating row on the latest stable Python so the
# runtime is exercised on win-arm64 (the released .exe targets it too).
- os: windows-11-arm
python: '3.14'
experimental: false
# Python 3.15 is in beta. Run it for early signal, but never let it
# gate CI -> continue-on-error. allow-prereleases lets setup-python
# pick up the beta (a no-op for the stable rows).
- os: ubuntu-latest
python: '3.15'
experimental: true
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
# The suite runs in 2-9 min per cell (Windows is the slow end); 30 still
# bounds a hung asyncio test while leaving headroom for a degraded
# runner VM, which has cost us whole runs at 20.
timeout-minutes: 30
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- run: pip install tox tox-uv
# requirements_dev.txt installs the playwright LIBRARY into the tox env,
# but pip cannot fetch a browser, and without one the client/daemon cron
# engine differential (tests/test_web_engine_parity.py) self-skips, which
# is why it used to run nowhere at all: the dashboard ships a SECOND
# implementation of the schedule dialect, and nothing compared it against
# the daemon's outside a dev machine where someone had run
# `playwright install`.
#
# Only this cell: the download is ~150 MB and the differential is
# platform-independent (it replays the same golden corpus through the
# same two engines), so a second OS or Python row buys no signal and
# costs a browser fetch per row. The other cells keep self-skipping.
# Installed from the requirements_dev.txt line verbatim, so this step and
# the tox env normally resolve the same release and the browser
# downloaded here is the build that env goes looking for in the shared
# ms-playwright cache. The line carries no version pin, and the two
# installs resolve independently (pip here, uv inside tox), so on a skew
# the env's playwright wants a browser revision this step did not fetch,
# chromium.launch() raises, and the differential self-skips: silently
# green, with nothing comparing the second engine again. The enforcement
# step below turns that silence into a red cell.
- name: Install Chromium for the web engine differential
if: matrix.os == 'ubuntu-latest' && matrix.python == '3.14'
run: |
set -euo pipefail
pip install "$(grep -E '^playwright' requirements_dev.txt)"
python -m playwright install --with-deps chromium
- run: tox -e py
# The differential self-skips wherever Chromium is missing (every other
# cell, by design), so THIS cell must prove it actually ran: re-drive it
# through the tox env's own interpreter (and therefore the env's own
# resolved playwright, the one that must find the browser fetched above)
# and require a pass, not a skip. Cheap (<1s after the suite), and
# without it a pip/uv resolution skew quietly restores the
# "nothing compares the second engine" state this cell exists to end.
# The check reads pytest's junitxml counts (at least one test, zero
# failures, zero errors, zero skips) rather than grepping stdout for a
# literal "1 passed", which a second test in the file would flip to a
# false red.
- name: Enforce the web engine differential ran (no silent skip)
if: matrix.os == 'ubuntu-latest' && matrix.python == '3.14'
run: |
set -euo pipefail
.tox/py/bin/python -m pytest \
tests/test_web_engine_parity.py \
tests/test_web_heat_e2e.py \
tests/test_web_drawer_e2e.py \
-v -rs --junitxml=parity-enforce.xml
.tox/py/bin/python - <<'EOF'
import xml.etree.ElementTree as ET
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
for suite in ET.parse("parity-enforce.xml").getroot().iter("testsuite"):
for key in totals:
totals[key] += int(suite.get(key, 0))
print("parity differential:", totals)
assert totals["tests"] >= 1, "no parity test was collected"
assert totals["failures"] == 0 and totals["errors"] == 0
assert totals["skipped"] == 0, (
"a parity test skipped in the one cell that provisions "
"Chromium: the browser install or resolution went stale"
)
EOF
# Every matrix cell uploads its own coverage.xml; Codecov merges them by
# flag into one combined number. fail_ci_if_error stays false so a Codecov
# outage never reds the build (and fork PRs with no token still pass).
- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: ${{ matrix.os }}-py${{ matrix.python }}
fail_ci_if_error: false
# Wheel + sdist, built and `twine check`ed exactly as it will be published to
# PyPI. Uploaded as an artifact the `release` job publishes verbatim (so the
# exact bytes that were checked here are what ship).
dist:
needs: version
runs-on: ubuntu-latest
steps:
# Shallow on purpose: SETUPTOOLS_SCM_PRETEND_VERSION pins the version, so
# setuptools_scm never walks history here, and its git file-finder (which
# the wheel/sdist build does use) only needs the checked-out tree.
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Build distributions
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.new }}
run: |
set -euo pipefail
uv build # wheel + sdist into dist/
uvx twine check dist/*.whl dist/*.tar.gz # twine, run ephemerally
# `twine check` validates metadata and renders the long description; it
# does NOT look at file size, and neither does the publish action. A 1.2.31
# sdist that had grown past PyPI's per-file cap therefore sailed through
# every gate and was rejected two hours later, at upload, after the wheel
# had already been accepted. Catch it here instead, in seconds.
- name: Check artifacts against PyPI's per-file size limit
run: |
set -euo pipefail
# warehouse: MAX_FILESIZE = 100 * ONE_MIB (constants.py). The error
# text says "100 MB" but the value is 100 MiB.
limit=104857600
# Fail well below the cap: a release that squeaks under today is one
# screenshot away from failing, and the sdist ships every git-tracked
# file (see MANIFEST.in), so it grows with the repo, not the code.
warn=94371840
status=0
for f in dist/*.whl dist/*.tar.gz; do
size=$(wc -c < "$f")
printf '%10d bytes %s\n' "$size" "$f"
if [ "$size" -ge "$warn" ]; then
pct=$(( size * 100 / limit ))
echo "::error file=MANIFEST.in::$(basename "$f") is ${size} bytes, ${pct}% of PyPI's ${limit}-byte per-file limit. Prune it (MANIFEST.in) before releasing."
status=1
fi
done
exit "$status"
- uses: actions/upload-artifact@v7.0.1
with:
name: dist
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 7
# Self-contained glibc PyInstaller binaries (amd64, arm64) on native runners
# (no QEMU). The per-arch `--version` smoke test catches a bad bundle here.
# These target glibc (the Ubuntu runner's libc); `binaries-container` covers
# Alpine/musl hosts and the remaining glibc arches.
binaries:
needs: version
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
# Shallow on purpose: SETUPTOOLS_SCM_PRETEND_VERSION pins the version, so
# setuptools_scm never walks history here, and its git file-finder (which
# the wheel/sdist build does use) only needs the checked-out tree.
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Build binary
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.new }}
run: |
set -euo pipefail
# Runner-native, all-wheels arch: install into a throwaway uv venv and
# freeze from it with `uv run` (uv discovers the setup-python 3.14).
uv venv --python 3.14
uv pip install pyinstaller==6.21.0 # pin matches pyinstaller/Dockerfile
uv pip install . # bakes the pinned version into cronstable/version.py
# Bundle uvloop (the `speedups` extra) so the binary runs the faster
# event loop. Install a wheel where one exists, else build it from
# sdist (ship uvloop as broadly as it compiles); a failed install
# falls back to asyncio. The shared script then has verify_extra.py
# prove the result actually runs a loop (a source build can
# miscompile under emulation) and uninstalls it if not, so a broken
# uvloop is never frozen in and the binary cleanly uses asyncio.
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh uvloop "uvloop>=0.19" soft "using asyncio"
# Bundle orjson (the `speedups` extra) to accelerate the durable-state
# and cluster JSON paths; amd64/arm64 and macOS ship wheels (no Rust
# needed). The shared script round-trip-verifies it and drops a broken
# build, so the binary falls back to stdlib json (cronstable/_json).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_orjson.sh
# Bundle pynacl (the `push` extra): abi3 wheels cover amd64/arm64,
# so on this lane absence means a broken build, never a missing
# wheel. Install and verify are HARD failures here: a soft-fail
# would let a transient index blip silently ship the advertised
# push channel disabled. verify_extra.py round-trips a real
# sealed box so a miscompiled libsodium also fails the job.
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh pynacl "pynacl>=1.5" hard
# Bundle zeroconf (the `discovery` extra) for web.bonjour. Pure
# Python with optional speedups, so the install works on every
# arch and is a HARD failure too. It is LGPL-2.1: the notice
# ships inside the binary (--third-party-licenses), the source
# archive is attached to the Release, and the public build
# recipe is the relink path. See LICENSING.md.
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh zeroconf "zeroconf>=0.132" hard
uv run pyinstaller pyinstaller/cronstable.spec
dist/cronstable --version # smoke test: fails the job if the bundle is broken
# Prove the FROZEN bundle reaches PyNaCl and zeroconf too: a
# push-plus-bonjour config passes --validate-config only when
# both import inside the binary (both fail closed otherwise),
# catching an analysis or hiddenimport miss that --version
# cannot.
dist/cronstable --validate-config -c pyinstaller/smoke-push.yaml
mv dist/cronstable "dist/cronstable-linux-${{ matrix.arch }}"
- uses: actions/upload-artifact@v7.0.1
with:
name: cronstable-linux-${{ matrix.arch }}
path: dist/cronstable-linux-${{ matrix.arch }}
if-no-files-found: error
retention-days: 7
# Self-contained container-built Linux binaries, one per arch x libc pair.
# PyInstaller is not a cross-compiler and the runners are glibc, so every
# row builds inside `docker run --platform` (checkout + upload stay on the
# host). musl rows build on python:3.14-alpine and ship with a `-musl`
# suffix for Alpine/musl hosts; glibc rows build on python:3.14-slim and
# cover the arches the native `binaries` job has no runner for. amd64,
# arm64 and i686 execute natively; armv7, armv6, ppc64le, s390x and
# riscv64 run under QEMU. armv6 is musl-only (Debian/glibc ships no
# arm32v6); glibc amd64/arm64 are the native job above. Same pyinstaller
# pin and `--version` smoke test as that job; the per-libc bits (base
# image, package-manager commands, artifact suffix) ride the matrix.
binaries-container:
needs: version
strategy:
fail-fast: false
matrix:
libc: [musl, glibc]
arch: [amd64, arm64, i686, armv7, armv6, ppc64le, s390x, riscv64]
exclude:
# glibc amd64/arm64 build natively in `binaries`; armv6 is musl-only
- { libc: glibc, arch: amd64 }
- { libc: glibc, arch: arm64 }
- { libc: glibc, arch: armv6 }
include:
# per-libc: base image, artifact suffix, and the package-manager
# commands the build script splices in (no quote characters in any
# of them; they are injected into a single-quoted sh -c script)
- libc: musl
image: python:3.14-alpine
suffix: -musl
toolchain: 'retry 5 apk add --no-cache build-base libffi-dev zlib-dev'
nacl_tools: 'retry 3 apk add --no-cache libffi-dev make >/dev/null 2>&1 || true'
rust_setup: '. /src/.github/scripts/retry.sh && retry 5 apk add --no-cache curl && retry 3 curl --proto =https --tlsv1.2 --retry 5 --retry-connrefused --retry-delay 5 -sSfo /tmp/rustup.sh https://sh.rustup.rs && env CARGO_HOME=/opt/cargo RUSTUP_HOME=/opt/rustup sh /tmp/rustup.sh -y --default-toolchain stable --profile minimal --no-modify-path'
- libc: glibc
image: python:3.14-slim
suffix: ''
toolchain: 'retry 5 apt-get -o Acquire::Retries=5 update && retry 5 apt-get -o Acquire::Retries=5 install -y --no-install-recommends build-essential libffi-dev zlib1g-dev'
nacl_tools: 'retry 3 apt-get -o Acquire::Retries=5 install -y --no-install-recommends libffi-dev make >/dev/null 2>&1 || true'
rust_setup: '. /src/.github/scripts/retry.sh && retry 5 apt-get -o Acquire::Retries=5 update && retry 5 apt-get -o Acquire::Retries=5 install -y --no-install-recommends curl ca-certificates && retry 3 curl --proto =https --tlsv1.2 --retry 5 --retry-connrefused --retry-delay 5 -sSfo /tmp/rustup.sh https://sh.rustup.rs && env CARGO_HOME=/opt/cargo RUSTUP_HOME=/opt/rustup sh /tmp/rustup.sh -y --default-toolchain stable --profile minimal --no-modify-path'
# per-arch: docker platform, runner, and whether QEMU is needed
# (amd64/arm64/i686 execute natively on the runners)
- { arch: amd64, platform: linux/amd64, runner: ubuntu-24.04 }
- { arch: arm64, platform: linux/arm64, runner: ubuntu-24.04-arm }
- { arch: i686, platform: linux/386, runner: ubuntu-24.04 }
- { arch: armv7, platform: linux/arm/v7, runner: ubuntu-24.04, qemu: true }
- { arch: armv6, platform: linux/arm/v6, runner: ubuntu-24.04, qemu: true }
- { arch: ppc64le, platform: linux/ppc64le, runner: ubuntu-24.04, qemu: true }
- { arch: s390x, platform: linux/s390x, runner: ubuntu-24.04, qemu: true }
- { arch: riscv64, platform: linux/riscv64, runner: ubuntu-24.04, qemu: true }
runs-on: ${{ matrix.runner }}
steps:
# Shallow on purpose: SETUPTOOLS_SCM_PRETEND_VERSION pins the version, so
# setuptools_scm never walks history here, and its git file-finder (which
# the wheel/sdist build does use) only needs the checked-out tree.
- uses: actions/checkout@v7.0.1
# Persist pip's cache (otherwise lost with the runner) in the workspace,
# where the build container sees it at /src/.pip-cache via the bind
# mount and the PIP_CACHE_DIR forwarded below. pip keeps the wheels it
# BUILDS from sdists in this cache, so the slow QEMU compiles (uvloop,
# orjson's Rust build, pynacl's bundled libsodium, the musl aiohttp
# stack, PyInstaller's own bootloader) happen once per dependency bump
# instead of on every push; install_orjson.sh's plain-pip first attempt
# then finds the cached wheel and skips the rustup toolchain entirely.
# Resolution still queries the index every run, so which VERSIONS
# install is unchanged, and the verify probes still run against the
# installed result; the cache only skips re-downloading and re-compiling
# artifacts pip validates by hash. Keyed per arch AND libc: a wheel
# source-built on glibc and one built on musl both carry the plain
# linux_<arch> tag, so a shared cache could hand one row the other's
# ABI-incompatible build. The key's hashFiles covers pyproject.toml and
# this workflow, where the dep floors and the pyinstaller pin live;
# restore-keys seeds the rebuild from the previous cache when either
# file changes.
- name: Restore pip cache (QEMU-built wheels)
id: pip-cache
uses: actions/cache/restore@v6.1.0
with:
path: .pip-cache
key: pip-${{ matrix.libc }}-${{ matrix.arch }}-${{ hashFiles('pyproject.toml', '.github/workflows/release.yml') }}
restore-keys: |
pip-${{ matrix.libc }}-${{ matrix.arch }}-
# Register binfmt/QEMU so `docker run --platform` can execute a foreign-arch
# container. amd64/arm64/i686 run natively and skip this.
- if: ${{ matrix.qemu }}
uses: docker/setup-qemu-action@v4.2.0
# Pull the base image in its own retried step. Docker Hub times out often
# enough to red a release on its own (a 15s connect timeout to
# registry-1.docker.io took the s390x row down mid-release), and pulling
# here keeps the retry off the build below: that one runs for up to an
# hour under QEMU and must never be repeated for a registry blip.
# pull_base adds registry failover on top of the retries: the
# 2026-07-25 Hub outage outlived five attempts, so a same-digest
# mirror (ECR Public, then mirror.gcr.io) now backstops the pull.
- name: Pull the build image
run: |
set -euo pipefail
. .github/scripts/retry.sh
pull_base ${{ matrix.image }} ${{ matrix.platform }}
- name: Build binary (${{ matrix.libc }} container)
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.new }}
run: |
set -euo pipefail
docker run --rm --platform ${{ matrix.platform }} \
-e SETUPTOOLS_SCM_PRETEND_VERSION \
-e PIP_RETRIES -e PIP_TIMEOUT \
-e PIP_CACHE_DIR=/src/.pip-cache \
-v "$PWD:/src" -w /src \
${{ matrix.image }} sh -euc '
# The checkout is mounted at /src, so the runner-side retry helper
# is reachable in here too. Neither apk nor apt retries hard
# enough on its own, and a failed index fetch is a hard job
# failure, so the matrix-provided commands wrap themselves in it.
. /src/.github/scripts/retry.sh
# pip refuses to use a cache directory it does not own: this
# container runs as root while the bind-mounted workspace is
# the runner user, so without the chown every pip call logs
# "The cache has been disabled" and the whole persisted cache
# stores nothing. The host-side step after the build hands
# ownership back so the cache save can read it.
mkdir -p /src/.pip-cache && chown -R root:root /src/.pip-cache
# Per-libc C toolchain + strip (the spec sets strip=True), plus
# libffi/zlib headers for the deps that ship no wheel for this
# libc/arch and fall back to compiling from sdist (notably the
# i686 aiohttp stack; propcache on armv7). The full-wheel arches
# leave the toolchain unused.
${{ matrix.toolchain }}
pip install --upgrade pip
pip install pyinstaller==6.21.0 # pin matches the other binary jobs
pip install . # bakes the pinned version into cronstable/version.py
# Bundle uvloop (the `speedups` extra): a wheel where one exists
# (amd64/arm64), else a source build. The shared script has
# verify_extra.py prove the result runs a loop (a source build
# can miscompile under QEMU) and uninstalls it otherwise, so a
# broken uvloop is never frozen in; the binary then cleanly
# falls back to asyncio.
sh pyinstaller/install_extra.sh uvloop "uvloop>=0.19" soft "using asyncio"
# Bundle orjson (the `speedups` extra): a wheel on amd64/arm64,
# else a Rust source build. RUST_SETUP (per-libc, from the
# matrix) installs a CURRENT Rust via rustup (distro rustc is
# older than the orjson MSRV) only when the wheel is missing;
# the shared script round-trip-verifies the result and drops a
# broken QEMU build -> stdlib json fallback. install_orjson.sh
# runs RUST_SETUP via `sh -c`, a fresh shell, so the command
# sources the retry helper itself; rustup is fetched to a file
# and then executed rather than piped straight into sh: curl
# retries cannot un-feed a truncated script to an interpreter
# that has already started running it.
RUST_SETUP="${{ matrix.rust_setup }}" sh pyinstaller/install_orjson.sh
# Bundle pynacl (the `push` extra): wheels on amd64/arm64, else
# a plain-C source build (bundled libsodium, no Rust; the
# libffi/make toolchain is best-effort). Soft on this lane
# only: a build that fails the sealed-box round-trip is
# uninstalled, so the binary never ships a miscompiled
# libsodium (push fails closed).
${{ matrix.nacl_tools }}
sh pyinstaller/install_extra.sh pynacl "pynacl>=1.5" soft "push extra not bundled"
# Bundle zeroconf (the `discovery` extra): pure Python with
# optional speedups, so a HARD install even under QEMU. It
# is LGPL-2.1; the in-binary notice, Release source
# archive, and public build recipe are the compliance kit
# (see LICENSING.md).
sh pyinstaller/install_extra.sh zeroconf "zeroconf>=0.132" hard
pyinstaller pyinstaller/cronstable.spec
dist/cronstable --version # smoke test, native or emulated on its own libc
# Whenever the build env kept pynacl, the frozen binary
# must reach it (and zeroconf) too: the push-plus-bonjour
# config validates only when both import inside the bundle.
if python -c "import nacl" 2>/dev/null; then
dist/cronstable --validate-config -c pyinstaller/smoke-push.yaml
fi
mv dist/cronstable dist/cronstable-linux-${{ matrix.arch }}${{ matrix.suffix }}
'
# The container runs as root and pip creates its cache files via
# tempfile (mode 0600), so without this the runner-user tar in
# actions/cache's save step cannot read them: the save fails with a
# permissions warning the job ignores, and every push recompiles the
# QEMU wheels the cache exists to keep.
- name: Make the pip cache readable by the cache save
run: |
set -euo pipefail
if [ -d .pip-cache ]; then
sudo chown -R "$(id -u):$(id -g)" .pip-cache
fi
# Branch-gated like the docker job's buildx export, and for the same
# reason: the Actions cache is ONE 10 GB pool per repository, shared
# with that exporter's multi-GB layer blobs, and this job alone is 13
# matrix rows. Writing from every branch and every fork PR would evict
# the layer cache the docker build depends on. Every other branch
# still RESTORES through to what develop and main stored, which is
# where the QEMU compile savings actually come from.
- name: Save pip cache (long-lived branches only)
if: ${{ steps.pip-cache.outputs.cache-hit != 'true' && (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6.1.0
with:
path: .pip-cache
key: pip-${{ matrix.libc }}-${{ matrix.arch }}-${{ hashFiles('pyproject.toml', '.github/workflows/release.yml') }}
- uses: actions/upload-artifact@v7.0.1
with:
name: cronstable-linux-${{ matrix.arch }}${{ matrix.suffix }}
path: dist/cronstable-linux-${{ matrix.arch }}${{ matrix.suffix }}
if-no-files-found: error
retention-days: 7
# Self-contained macOS binaries. The shipped pair is macOS 15 (arm64 + amd64),
# signed + notarized on a release. macOS 26 (Tahoe) is built too for early
# OS-version breakage signal, but it is CI-ONLY: continue-on-error so a flaky
# Tahoe build never blocks a release, and it is neither signed nor attached.
# `ship` picks the shipped rows; `asset` names the artifact (the shipped ones
# keep the bare `macos-<arch>` names the Release + Homebrew formula expect).
binaries-macos:
needs: version
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: macos-15 # macOS 15, Apple Silicon -- shipped
ship: true
asset: macos-arm64
- arch: amd64
runner: macos-15-intel # macOS 15, Intel -- shipped
ship: true
asset: macos-amd64
- arch: arm64
runner: macos-26 # macOS 26 (Tahoe), Apple Silicon -- CI only
ship: false
asset: macos26-arm64
- arch: amd64
runner: macos-26-intel # macOS 26 (Tahoe), Intel -- CI only
ship: false
asset: macos26-amd64
runs-on: ${{ matrix.runner }}
# The macOS 26 rows are coverage-only: never let them gate a release.
continue-on-error: ${{ !matrix.ship }}
steps:
# Shallow on purpose: SETUPTOOLS_SCM_PRETEND_VERSION pins the version, so
# setuptools_scm never walks history here, and its git file-finder (which
# the wheel/sdist build does use) only needs the checked-out tree.
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14' # set explicitly; don't rely on the runner's preinstalled patch level
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Build binary
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.new }}
run: |
set -euo pipefail
# Runner-native, all-wheels arch: install into a throwaway uv venv and
# freeze from it with `uv run` (uv discovers the setup-python 3.14).
uv venv --python 3.14
uv pip install pyinstaller==6.21.0 # pin matches the Linux jobs
uv pip install . # bakes the pinned version into cronstable/version.py
# Bundle uvloop (the `speedups` extra) for the faster event loop; macOS
# ships wheels for both arches. The shared script has verify_extra.py
# confirm it imports and runs a loop, else uninstalls it so the binary
# falls back to asyncio.
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh uvloop "uvloop>=0.19" soft "using asyncio"
# Bundle orjson (the `speedups` extra); macOS ships wheels (no Rust).
# The shared script round-trip-verifies it and drops a broken build, so
# the binary falls back to stdlib json (cronstable/_json).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_orjson.sh
# Bundle pynacl (the `push` extra); macOS ships universal2
# wheels, so absence means a broken build. Install and verify
# are HARD failures on this lane (a soft-fail would silently
# ship the advertised push channel disabled).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh pynacl "pynacl>=1.5" hard
# Bundle zeroconf (the `discovery` extra): pure Python, wheels
# on both arches, HARD failure. LGPL-2.1; in-binary notice +
# Release source archive + public build recipe (LICENSING.md).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh zeroconf "zeroconf>=0.132" hard
uv run pyinstaller pyinstaller/cronstable.spec
dist/cronstable --version # smoke test: fails the job if the bundle is broken
# Prove the FROZEN bundle reaches PyNaCl and zeroconf too (both
# fail closed at config validation when they cannot import).
dist/cronstable --validate-config -c pyinstaller/smoke-push.yaml
# Assert the binary is the intended native arch: Rosetta would happily
# run an x86_64 build on the arm64 runner and let the smoke test pass,
# silently producing a mislabelled asset. `file` reports x86_64/arm64.
case "${{ matrix.arch }}" in
amd64) want=x86_64 ;;
arm64) want=arm64 ;;
esac
file dist/cronstable | grep -q "$want"
# Developer ID sign + notarize the SHIPPED binary in place, on a release
# only. Guarded on the cert secret too: if the signing secrets are not
# configured, the step warns and exits 0, shipping an unsigned binary.
# Renaming after signing is safe -- the notarization ticket is keyed to the
# code signature (cdhash), not the filename -- so this runs before the `mv`.
- name: Sign and notarize
if: ${{ needs.version.outputs.release == 'true' && matrix.ship }}
env:
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
MACOS_NOTARY_KEY_BASE64: ${{ secrets.MACOS_NOTARY_KEY_BASE64 }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
run: |
set -euo pipefail
if [ -z "${MACOS_CERT_P12_BASE64:-}" ]; then
echo "::warning::macOS signing secrets not set; shipping an unsigned, unnotarized binary."
exit 0
fi
# Import the Developer ID cert into a throwaway, randomly-keyed
# keychain scoped to this runner (never the login keychain).
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PW="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERT_PASSWORD" \
-t cert -f pkcs12 -k "$KEYCHAIN" -T /usr/bin/codesign
# Let codesign use the key non-interactively.
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
# Sign with the hardened runtime + secure timestamp (both required for
# notarization) and the PyInstaller entitlements, then verify.
codesign --force --options runtime --timestamp \
--entitlements pyinstaller/entitlements.plist \
--keychain "$KEYCHAIN" \
--sign "$MACOS_SIGN_IDENTITY" dist/cronstable
codesign --verify --strict --verbose=2 dist/cronstable
# Notarize: the zip is just the upload transport; the ticket binds to
# the binary's signature, so we keep shipping the bare signed binary.
echo "$MACOS_NOTARY_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
ditto -c -k dist/cronstable "$RUNNER_TEMP/cronstable.zip"
xcrun notarytool submit "$RUNNER_TEMP/cronstable.zip" \
--key "$RUNNER_TEMP/AuthKey.p8" \
--key-id "$MACOS_NOTARY_KEY_ID" \
--issuer "$MACOS_NOTARY_ISSUER_ID" \
--wait
- name: Rename binary
run: mv dist/cronstable "dist/cronstable-${{ matrix.asset }}"
- uses: actions/upload-artifact@v7.0.1
with:
name: cronstable-${{ matrix.asset }}
path: dist/cronstable-${{ matrix.asset }}
if-no-files-found: error
retention-days: 7
# Self-contained Windows binaries: amd64 on the hosted runner, arm64 on the
# windows-11-arm runner. Any C-extension dep lacking a win_arm64 wheel (parts
# of the aiohttp stack, ruamel.yaml.clib) compiles from sdist via the runner's
# Visual Studio ARM64 toolchain. Same pyinstaller pin + `--version` smoke test;
# the assets are cronstable-windows-<arch>.exe. Steps use bash (git bash ships
# on the Windows runners).
binaries-windows:
needs: version
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
- arch: arm64
runner: windows-11-arm
runs-on: ${{ matrix.runner }}
defaults:
run:
shell: bash
steps:
# Shallow on purpose: SETUPTOOLS_SCM_PRETEND_VERSION pins the version, so
# setuptools_scm never walks history here, and its git file-finder (which
# the wheel/sdist build does use) only needs the checked-out tree.
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.0.0
with:
python-version: '3.14'
- uses: astral-sh/setup-uv@v9.0.0
with:
version: "0.12.1"
- name: Build binary
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ needs.version.outputs.new }}
run: |
set -euo pipefail
# Runner-native (uv ships a win_arm64 build too); install into a
# throwaway uv venv and freeze from it with `uv run`.
uv venv --python 3.14
uv pip install pyinstaller==6.21.0 # pin matches the Linux/macOS jobs
uv pip install . # bakes the pinned version into cronstable/version.py
# Bundle orjson (the `speedups` extra) for the durable-state / cluster
# JSON paths; Windows amd64 ships a wheel (arm64 falls back if none).
# The shared script round-trip-verifies it and drops a broken build,
# so the binary falls back to stdlib json (cronstable/_json).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_orjson.sh
# Bundle pynacl (the `push` extra); Windows ships wheels for
# win32/amd64/arm64, so absence means a broken build. Install
# and verify are HARD failures on this lane (a soft-fail would
# silently ship the advertised push channel disabled).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh pynacl "pynacl>=1.5" hard
# Bundle zeroconf (the `discovery` extra): pure Python, wheels
# on win32/amd64/arm64, HARD failure. LGPL-2.1; in-binary
# notice + Release source archive + public build recipe
# (LICENSING.md).
PIP="uv pip" PIPUNINST="uv pip uninstall" PY="uv run python" \
sh pyinstaller/install_extra.sh zeroconf "zeroconf>=0.132" hard
uv run pyinstaller pyinstaller/cronstable.spec
./dist/cronstable.exe --version # smoke test: fails the job if the bundle is broken
# Prove the FROZEN bundle reaches PyNaCl and zeroconf too (both
# fail closed at config validation when they cannot import).
./dist/cronstable.exe --validate-config -c pyinstaller/smoke-push.yaml
mv dist/cronstable.exe "dist/cronstable-windows-${{ matrix.arch }}.exe"
- uses: actions/upload-artifact@v7.0.1
with:
name: cronstable-windows-${{ matrix.arch }}
path: dist/cronstable-windows-${{ matrix.arch }}.exe
if-no-files-found: error
retention-days: 7
# Build EVERY container image (8 distros, full published arch set) build-only,
# no push -- the Docker half of the gate. Proves every Dockerfile still builds
# for every arch on every commit; `docker-push` re-runs these with a push once
# the whole gate passes. Uniform across PRs and releases (no secrets, no
# login), so a fork PR exercises it too.
docker:
needs: version
runs-on: ubuntu-latest
strategy:
fail-fast: false # one distro failing must not cancel the others