-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjustfile
More file actions
2627 lines (2362 loc) · 119 KB
/
Copy pathjustfile
File metadata and controls
2627 lines (2362 loc) · 119 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
# SPDX-License-Identifier: GPL-3.0-or-later
# etr local test harness
# Needed so a shebang recipe receives *ARGS as real argv ($@) rather than losing
# quoting through textual ARGS interpolation -- see open-pr.
set positional-arguments := true
ETR_BIN := justfile_directory() + "/target/debug/etr"
ETRS_BIN := justfile_directory() + "/target/debug/etrs"
ETR_REL := justfile_directory() + "/target/release/etr"
ETRS_REL := justfile_directory() + "/target/release/etrs"
STRESS_BIN := justfile_directory() + "/tools/stress/target/release/stress_tool"
INSTALL := home_directory() + "/.cargo/bin"
# ===== PROJECT — the only part of the install family this repo owns =====
#
# etr is the TWO-BINARY case the shared standard exists for: the COMMON block below is
# written against these, so it stays byte-identical to the siblings that ship one binary.
BINS := "etr etrs"
MAN_PAGES := "man/etr.1 man/etrs.1"
# Do NOT edit inside the markers. Edit templates/justfile-common.just and the two vendored
# helpers, bump their versions, and propagate to the siblings in their own PRs.
# `just standard-check` runs the helpers' self-tests and `just check` depends on it.
# >>> COMMON (template v6)
# The interpreter is resolved ONCE per line, and a missing one is a hard error. The
# `python3 … 2>/dev/null || python …` idiom is deliberately NOT used: it retries on ANY
# failure, so a real error inside the script gets re-run and reported as if the
# interpreter were the problem.
PY := `command -v python3 || command -v python || echo PYTHON-NOT-FOUND`
# Install from this checkout: binary, man page(s) and completions.
#
# The dependencies are the point. `cargo install` alone replaces the binary and leaves the
# man page and completions at whatever version last ran their recipe — measured on a host
# whose page was ELEVEN releases stale with nothing reporting it.
install: install-man install-completions
cargo install --path .
# Install a RELEASED tag: binary, man page(s) and completions, all three FROM THAT TAG.
#
# **It deliberately does NOT depend on `install-man`/`install-completions`**, because those
# work from the checkout. Reusing them would pair a tag's binary with the worktree's man
# page and completions — on a checkout one release ahead, a v0.2.22 binary with a v0.2.23
# page. Mismatched artefacts that each look fine is the failure class this standard exists
# to remove, so the three sources are made to agree: binary from the tag, completions from
# THE INSTALLED BINARY (`--from-path`), man page from the tag (`--from-tag`).
#
# Never `--path`: on a Syncthing-shared checkout that builds from a directory other
# machines write into. Takes a bare version and normalises a leading `v`.
install-tag VERSION:
#!/usr/bin/env bash
set -euo pipefail
V="{{VERSION}}"; V="${V#v}"
[ -n "$V" ] || { echo "error: install-tag needs a version, e.g. just install-tag 0.2.22" >&2; exit 1; }
git rev-parse -q --verify "refs/tags/v${V}" >/dev/null || {
echo "error: tag v${V} is not in this clone. Run: git fetch --tags" >&2; exit 1; }
REPO=$(git config --get remote.origin.url)
echo "Installing from tag v${V} of ${REPO}"
cargo install --git "$REPO" --tag "v${V}" --locked --force
# POST-CONDITION: cargo prints a replacement line, but only a version query proves which
# binary is on PATH now.
for b in {{BINS}}; do
command -v "$b" >/dev/null 2>&1 || { echo "error: $b is not on PATH after install" >&2; exit 1; }
echo " $b -> $("$b" --version)"
done
"{{PY}}" scripts/install_man.py {{MAN_PAGES}} --from-tag "v${V}"
"{{PY}}" scripts/install_completions.py {{BINS}} --from-path
# Install the man page(s) to the XDG man directory.
#
# Installs the COMMITTED page(s) and deliberately does NOT depend on `man` (v6). Every repo
# using this block commits its pages and its gate refuses a stale one, so rebuilding here
# only made `just install` require mandown -- a documentation build tool -- to install a
# finished page, and rewrote a tracked file in the user's checkout. Edited the page source?
# Run `just man` first; the gate would refuse the stale page at PR time anyway.
install-man:
@"{{PY}}" scripts/install_man.py {{MAN_PAGES}}
# Generate and install shell completions for every binary.
#
# Python rather than a just recipe, which is retch's finding and the more portable
# mechanism: no `sh`, no `cygpath`, no coreutils, nothing from Git's `usr\bin` on Windows.
# A `bash` shebang recipe cannot run on Windows without `cygpath` at all, and even a plain
# `sh` recipe still needs an `sh` on PATH.
install-completions: build
@"{{PY}}" scripts/install_completions.py {{BINS}}
# Prove the vendored helpers still behave the way the standard requires.
#
# **This runs the helpers' own self-tests rather than diffing text**, and that is the whole
# point: three separate repositories cannot diff each other's files, but each can prove its
# copy still behaves correctly — which is the property that was actually violated when two
# repos quietly shipped the pre-fix nushell path for months. A text diff would also have
# passed happily on a repo that had never adopted the standard at all.
standard-check:
@"{{PY}}" scripts/install_completions.py --self-test
@"{{PY}}" scripts/install_man.py --self-test
@"{{PY}}" scripts/gate_conformance.py --self-test
@"{{PY}}" scripts/gate_conformance.py "{{justfile()}}"
# <<< COMMON
LOG_FILE := `echo "${XDG_STATE_HOME:-$HOME/.local/state}/etr/etrs.log"`
TMUX_SESS := "etr_test"
# List available recipes
default:
@just --list
# ── Code quality ──────────────────────────────────────────────────────────────
# Format source files
fmt:
cargo fmt
# Check formatting without modifying files
fmt-check:
cargo fmt --check
# Run Clippy (deny warnings, check all targets)
clippy:
cargo clippy --all-targets -- -D warnings
# Run unit and integration tests
test:
cargo test
# Run performance benchmarks
bench:
cargo bench
# Run security audit on dependencies (installs cargo-audit if absent)
audit:
#!/usr/bin/env bash
set -euo pipefail
if ! cargo audit --version >/dev/null 2>&1; then
echo "==> Installing cargo-audit..."
cargo install cargo-audit
fi
cargo audit
# Run all static checks: fmt + clippy (suitable as a pre-push gate)
check: fmt-check clippy standard-check man-check packaging-check text-check wip-check
@echo "All checks passed."
# Every packaging guard, all offline.
#
# Wired into `check` deliberately: etr now publishes to five channels, each restating the
# summary and licence in its own vocabulary, and nothing else would notice one drifting. The
# sibling repo's AUR package reached ELEVEN releases of drift with every CI run green,
# because no check was looking. This is the check that looks.
#
# Both scripts carry their own `--self-test`, run first: a guard whose own tests are not run
# is a guard nobody has watched fail.
# Refuse control characters and carriage returns in tracked text.
#
# Wired into `check` for the same reason `packaging-check` is: nothing else looks at bytes.
# Both classes it catches had already shipped here -- two collapsed backslashes that sat in
# NOTES.md for eight releases (one of them splitting a paragraph in half), and a CRLF worktree
# copy of a file that is supposed to be vendored byte-identically across three repos.
#
# git does NOT hide that second class outright, though it comes close enough that it went
# unnoticed here. With .gitattributes pinning eol=lf, planting CRLF in a tracked file shows as
# a bare ` M` in `git status` with NO diff behind it, and the first `git add` clears the status
# while leaving every carriage return on disk -- so the signal appears exactly once and reads
# as noise. `git ls-files --eol` (`i/lf w/crlf`) is the oracle that answers properly.
# (`scripts/text_check.py`'s own docstring still overstates this as "git status CANNOT report
# it"; it is vendored to two sibling repos and needs a coordinated bump -- see NOTES.md.)
#
# WIP.md is gitignored, so `git ls-files` never offers it and this guard cannot reach it.
# That is a gap rather than a decision -- `wip-check` is what supplies the same guarantee.
text-check:
#!/usr/bin/env bash
set -euo pipefail
[ "{{PY}}" != "PYTHON-NOT-FOUND" ] || { echo "error: no python3/python on PATH" >&2; exit 1; }
"{{PY}}" scripts/text_check.py --self-test
"{{PY}}" scripts/text_check.py
# LF is the base model for every non-binary file in this tree, and `WIP.md` is the one file
# with nothing enforcing it.
#
# `.gitattributes` pins `* text=auto eol=lf` and `text-check` refuses a carriage return in
# tracked text -- but both work through git, and `git ls-files` never offers a gitignored
# path. `WIP.md` is gitignored (it is the Syncthing-synced cross-machine handoff file that
# AGENTS.md Part 1 section 3 mandates), and `scripts/reset_wip.py` rewrites it on every
# `just merge-pr`, so it is exactly the file most likely to pick up the wrong bytes and least
# likely to be noticed doing it.
#
# The rewrite deliberately PRESERVES whatever terminator it finds rather than hardcoding LF:
# a hardcoded terminator converts a file as a side effect of a merge, which the sibling repo
# `retch` shipped as an accident in its v0.17.12. So the decision gets a guard of its own
# instead of falling out of an I/O default.
#
# Two halves, and both are needed. `--self-test` is about the CODE (it rewrites exactly one
# state block, refuses on any other match count, and round-trips CRLF *and* LF unchanged);
# `--check-endings` is about the FILE as it stands on this machine, and passes when `WIP.md`
# is absent, since it is per-machine and untracked.
# Prove reset_wip.py touches only the state block, and that WIP.md is still LF (offline)
wip-check:
@"{{PY}}" scripts/reset_wip.py --self-test
@"{{PY}}" scripts/reset_wip.py --check-endings
# Offline packaging guards: templates intact, every channel agrees
packaging-check:
#!/usr/bin/env bash
set -euo pipefail
[ "{{PY}}" != "PYTHON-NOT-FOUND" ] || { echo "error: no python3/python on PATH" >&2; exit 1; }
"{{PY}}" scripts/render_packaging.py --self-test
"{{PY}}" scripts/packaging_check.py --self-test
"{{PY}}" scripts/packaging_check.py
# Pre-PR gate: run all automated checks and print manual checklist before opening a PR.
# All items must pass before calling `gh pr create`. See AGENTS.md Part 2 §4.
pr:
#!/usr/bin/env bash
set -euo pipefail
BOLD='\033[1m'; GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
pass() { echo -e "${GREEN}[✓]${NC} $1"; }
fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
info() { echo -e "${YELLOW}[→]${NC} $1"; }
echo -e "\n${BOLD}=== Pre-PR Gate ===${NC}\n"
# 1. Must be on a feature branch
BRANCH=$(git rev-parse --abbrev-ref HEAD)
[ "$BRANCH" = "main" ] && fail "On main — create a feature branch first"
pass "Feature branch: $BRANCH"
# 2. Version must be bumped past the last tag
CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "none")
[ "$LAST_TAG" = "v$CARGO_VER" ] && fail "Version not bumped — Cargo.toml is still $CARGO_VER (matches last tag)"
pass "Version bumped: $CARGO_VER (last tag: $LAST_TAG)"
# 3. NOTES.md "Current state" header must match
grep -q "^## Current state: v$CARGO_VER" NOTES.md \
|| fail "NOTES.md 'Current state' header not updated to v$CARGO_VER"
pass "NOTES.md Current state header: v$CARGO_VER"
# 4. Man pages must build cleanly (man/build/ is gitignored, so there is nothing to
# diff — this just proves mandown still succeeds and the version header is live).
info "Building man pages..."
just man
pass "Man pages build cleanly"
# 5. cargo check — updates Cargo.lock; verify it was committed
info "Running cargo check..."
cargo check -q 2>&1
LOCK_DIRTY=$(git diff --name-only Cargo.lock)
[ -n "$LOCK_DIRTY" ] && fail "Cargo.lock was updated but not committed — stage and commit it first"
pass "Cargo.lock is current and committed"
# 6. fmt + clippy
info "Running just check..."
just check
pass "fmt + clippy passed"
# 7. Tests
info "Running cargo test..."
cargo test -q 2>&1
pass "All tests passed"
# Manual checklist
echo -e "\n${BOLD}Automated checks passed.${NC}\n"
echo -e "${BOLD}Manual checklist — confirm each before proceeding:${NC}"
echo " [ ] etr --help / etrs --help reviewed and shell completions regenerate cleanly"
echo " [ ] Config file docs updated (config.toml comments + NOTES.md example) if a config key changed"
echo " [ ] PROTOCOL.md updated if the wire protocol changed"
echo " [ ] README.md reviewed and updated (new features, install steps, platform notes)"
echo " [ ] NOTES.md: Current state, known gaps, hard-won lessons, test-coverage count"
echo " [ ] WIP.md reflects what is in flight (not a session log -- see its own header)"
echo " [ ] GitHub wiki cloned and updated (etr.wiki.git — see AGENTS.md §4.11 for page list)"
echo ""
# A bare `read` makes this gate unanswerable by anything that is not a human at a
# terminal: a script, CI job or agent blocks on a stdin that will never answer, or dies
# without saying why -- and that reads as the gate REFUSING the change rather than asking
# a question nobody could hear. Three sources of an answer, in order:
#
# 1. PR_CONFIRM in the environment -- the explicit answer for a non-interactive caller.
# NOT a bypass: setting it is the same act of confirmation as typing y, just recorded
# where a script can supply it. Answer it AFTER checking each item.
# 2. An interactive stdin -- a human, prompted exactly as before.
# 3. Neither, so read piped input under a timeout. `echo y | just pr` keeps working, and
# a stdin that never answers costs ten seconds rather than hanging.
#
# The failure names PR_CONFIRM: a gate that cannot be satisfied from the context it failed
# in is a wall, not a gate.
if [ -n "${PR_CONFIRM:-}" ]; then
CONFIRM="$PR_CONFIRM"
echo "All manual items confirmed? [y/N] $CONFIRM (answered by PR_CONFIRM)"
elif [ -t 0 ]; then
echo -n "All manual items confirmed? [y/N] "
read -r CONFIRM
else
echo -n "All manual items confirmed? [y/N] "
read -r -t 10 CONFIRM || CONFIRM=""
echo "$CONFIRM"
[ -n "$CONFIRM" ] || { echo -e "${RED}Aborted.${NC} No terminal to confirm the checklist on, and nothing on stdin. Re-run with PR_CONFIRM=y once each item above is actually checked."; exit 1; }
fi
[ "$CONFIRM" = "y" ] || [ "$CONFIRM" = "Y" ] \
|| { echo -e "${RED}Aborted.${NC} Complete the checklist first."; exit 1; }
echo -e "\n${GREEN}Gate passed. You may now run: gh pr create${NC}\n"
# Run the pre-PR gate, then gh pr create -- always use this, never gh pr create directly
open-pr *ARGS:
#!/usr/bin/env bash
set -euo pipefail
# just open-pr --title "..." --body-file body.md # at a terminal
# PR_CONFIRM=y just open-pr --title "..." --fill # script, CI or agent
#
# This recipe is the only thing that can gate PR creation: neither `gh` nor `git` has a
# hook for "a PR is about to open". Being a justfile recipe rather than editor or agent
# configuration, it binds every contributor and tool identically -- AGENTS.md Part 1 §4.
# This repo previously asked for that discipline in prose instead, which binds nobody.
just pr
# Push the branch if it has no upstream yet. Without this, on a never-pushed branch
# `gh pr create` has no remote branch to open from and fails non-interactively -- AFTER
# the gate printed "Gate passed", which reads as the gate rejecting work it just approved.
#
# Deliberately ONLY when there is no upstream: pushing unconditionally would silently
# publish existing commits on a branch that already has one. pre-push runs `just check`,
# so the push is inside the gate rather than around it.
if ! git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null 2>&1; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[ "$BRANCH" != HEAD ] || { echo "detached HEAD -- check out a branch first" >&2; exit 1; }
echo "no upstream for $BRANCH -- pushing it so gh has a remote branch to open from"
git push -u origin "$BRANCH"
fi
# Drop the empty argument just passes when *ARGS is unset.
ARGS=()
for a in "$@"; do [ -n "$a" ] && ARGS+=("$a"); done
# With no arguments and no terminal, gh fails with "must provide --title and --body";
# --fill uses the commit messages instead so the recipe finishes cleanly.
if [ ${#ARGS[@]} -eq 0 ] && [ ! -t 0 ]; then
ARGS=(--fill)
fi
gh pr create "${ARGS[@]}"
# Install this repo's tracked git hooks (pre-push runs `just check`)
install-hooks:
@"{{PY}}" scripts/install_hooks.py
# Merge the active PR, switch to main, pull, delete the branch, and reset WIP.md (requires gh)
merge-pr:
#!/usr/bin/env bash
set -euo pipefail
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ]; then
echo "Error: You are already on main."
exit 1
fi
# Refuse to merge over a failing check.
#
# `gh pr merge` happily merges a red PR when the repository has no branch protection, and
# "wait for the checks to settle" is not "wait for them to pass". This repo never had the
# gate at all, so every merge here has been ungated -- safe only because whoever merged
# happened to look at CI first.
echo "Checking CI on this branch..."
STATES=$(gh pr view --json statusCheckRollup --jq '[.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | .conclusion]' 2>/dev/null || echo '[]')
# NO checks at all is not "green", and the arm below cannot tell the difference: an empty
# rollup matches neither "" nor FAILURE, so without this the recipe would report green and
# merge a commit CI has never seen. Not hypothetical -- it happened in a sibling repo when
# GitHub stopped creating runs for pushed commits.
#
# Compared as a string rather than through `jq -e length`: `gh --jq` is gh's BUILT-IN jq,
# but an external `jq` is not on a default Windows PATH, and a gate that silently degrades
# where its dependency is missing is the thing being fixed, not a way to fix it.
if [ "$(printf '%s' "$STATES" | tr -d '[:space:]')" = "[]" ]; then
echo "Error: no checks have reported for this commit at all."
echo " That is not the same as passing. GitHub sometimes fails to create a run;"
echo " force one with: gh workflow run ci.yml --ref $BRANCH"
exit 1
fi
if echo "$STATES" | grep -q '""'; then
echo "Error: checks are still running. Wait for them, or merge deliberately with gh."
exit 1
fi
if echo "$STATES" | grep -qE 'FAILURE|TIMED_OUT|CANCELLED|ACTION_REQUIRED'; then
echo "Error: CI is not green on this branch:"
gh pr view --json statusCheckRollup --jq '.statusCheckRollup[]? | select(.conclusion != "SKIPPED" and .conclusion != "SUCCESS") | " \(.conclusion) \(.name)"'
echo "Fix it, or merge deliberately with gh if you have a reason."
exit 1
fi
echo "CI is green."
echo "Merging PR for branch $BRANCH..."
gh pr merge --squash --delete-branch
echo "Switching to main and pulling..."
git checkout main
git pull
echo "Deleting local branch $BRANCH..."
git branch -D "$BRANCH" 2>/dev/null || true
python3 scripts/reset_wip.py
# Publish the CURRENT version to crates.io, the AUR and the Homebrew tap.
#
# COPR is deliberately absent: .github/workflows/copr.yml rebuilds it from the tag itself, so
# by the time this runs COPR is already building. GitHub releases are likewise built by
# release.yml on the tag. This recipe covers the three channels that need a push from a
# workstation.
#
# ORDER MATTERS: crates.io first (it is the only irreversible one), then the two that depend
# on the GitHub release assets existing.
# Publish the current version to crates.io, the AUR and the Homebrew tap
publish:
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
# REFUSE UNLESS HEAD IS THE TAG FOR THE VERSION ABOUT TO BE UPLOADED.
#
# `cargo publish` uploads whatever the worktree says, and a crates.io version can be
# YANKED BUT NEVER DELETED. The sibling repo came one command away from putting an
# untagged 0.17.4 on the index this way -- Cargo.toml had moved ahead of the last tag and
# nothing checked. A clean working tree does not help: the tree was clean, it was simply
# a version nobody had released.
#
# Override deliberately with PUBLISH_ANY_REF=1. That is for a genuine exception, not for
# getting past a surprise.
if [ "${PUBLISH_ANY_REF:-}" != "1" ]; then
HEAD_SHA=$(git rev-parse HEAD)
TAG_SHA=$(git rev-parse -q --verify "refs/tags/v${VERSION}^{commit}" || true)
if [ -z "$TAG_SHA" ]; then
echo "ERROR: Cargo.toml is ${VERSION} but there is no tag v${VERSION} in this clone." >&2
echo " Tag and push the release first (git fetch --tags if it was tagged elsewhere)." >&2
exit 1
fi
if [ "$HEAD_SHA" != "$TAG_SHA" ]; then
echo "ERROR: HEAD is not v${VERSION}." >&2
echo " HEAD $HEAD_SHA" >&2
echo " v${VERSION} $TAG_SHA" >&2
echo " Publishing here would upload source that is not what the tag names." >&2
exit 1
fi
echo "==> HEAD is v${VERSION} (${HEAD_SHA})"
else
echo "==> PUBLISH_ANY_REF=1: skipping the HEAD-is-the-tag check"
fi
echo "==> Verifying working tree is clean..."
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "ERROR: working tree has uncommitted changes. Commit or discard them first." >&2
exit 1
fi
echo "==> Running cargo publish --dry-run..."
if ! cargo publish --dry-run; then
echo "ERROR: dry-run failed — not publishing." >&2
exit 1
fi
echo "==> Dry-run passed. Publishing to crates.io..."
cargo publish
echo "==> Published ${VERSION} to crates.io."
echo "==> Publishing AUR package..."
just publish-aur
echo "==> Publishing Homebrew formula..."
just brew-publish "${VERSION}"
echo "==> crates.io, AUR and Homebrew done. COPR builds from the tag via copr.yml."
# Publish/update the AUR package (etr-terminal-bin) from the current version's GitHub release
publish-aur:
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
AUR_PKG="etr-terminal-bin"
AUR_REMOTE="ssh://aur@aur.archlinux.org/${AUR_PKG}.git"
BASE_URL="https://github.com/l1a/etr/releases/download/v${VERSION}"
# etr-extras.tar.gz carries the man pages and completions -- see packaging/aur/PKGBUILD.in.
# Listing it here is load-bearing: the verification loop below refuses to render anything
# if the `extras` job did not publish it, rather than pushing a PKGBUILD whose `source=`
# points at an asset that does not exist.
ASSETS=(etr-linux-x86_64 etrs-linux-x86_64 etr-linux-aarch64 etrs-linux-aarch64 etr-extras.tar.gz)
# The AUR package points at GitHub release assets, so the v$VERSION release
# must be fully built before this can run (tag → release.yml → here).
echo "==> Verifying GitHub release v${VERSION} assets exist..."
for a in "${ASSETS[@]}"; do
if ! curl -fsIL "${BASE_URL}/${a}" >/dev/null 2>&1; then
echo "ERROR: ${BASE_URL}/${a} not found." >&2
echo "The v${VERSION} GitHub release must exist before publishing to the AUR:" >&2
echo " git tag v${VERSION} && git push origin v${VERSION}" >&2
echo "then wait for the Release workflow to finish and re-run: just publish-aur" >&2
exit 1
fi
done
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Checksums are COMPUTED from the assets that will actually be downloaded by makepkg,
# never copied from a committed field -- nothing in this repo records one.
echo "==> Downloading release assets and computing sha256 checksums..."
declare -A SHA
for a in "${ASSETS[@]}"; do
curl -fsSL -o "${WORK}/${a}" "${BASE_URL}/${a}"
SHA[$a]=$(sha256sum "${WORK}/${a}" | cut -d' ' -f1)
echo " ${a} ${SHA[$a]}"
done
# PKGBUILD and .SRCINFO are rendered from the same five values by the SAME renderer, so
# the two cannot disagree -- the classic AUR footgun, and the reason .SRCINFO is never
# hand-written. The renderer refuses a placeholder checksum and refuses to emit a file
# with a surviving sentinel, so neither can reach the AUR.
SHA_ARGS=(
--sha256 "SHA_ETR_X86_64=${SHA[etr-linux-x86_64]}"
--sha256 "SHA_ETRS_X86_64=${SHA[etrs-linux-x86_64]}"
--sha256 "SHA_ETR_AARCH64=${SHA[etr-linux-aarch64]}"
--sha256 "SHA_ETRS_AARCH64=${SHA[etrs-linux-aarch64]}"
--sha256 "SHA_EXTRAS=${SHA[etr-extras.tar.gz]}"
)
echo "==> Cloning ${AUR_REMOTE}..."
# Cloned fresh each time rather than kept as a working copy: a long-lived clone is how
# the sibling repo's AUR checkout drifted eleven releases out of date.
git clone "${AUR_REMOTE}" "${WORK}/aur"
"{{PY}}" scripts/render_packaging.py --target aur-pkgbuild --version "${VERSION}" \
"${SHA_ARGS[@]}" --out "${WORK}/aur/PKGBUILD"
"{{PY}}" scripts/render_packaging.py --target aur-srcinfo --version "${VERSION}" \
"${SHA_ARGS[@]}" --out "${WORK}/aur/.SRCINFO"
# Stage FIRST, then ask the index whether anything changed. `git diff --quiet` compares
# the worktree to the index and does NOT see untracked files, so on an empty repo it
# reports "no changes" and this would exit 0 having published nothing.
git -C "${WORK}/aur" add PKGBUILD .SRCINFO
if git -C "${WORK}/aur" diff --cached --quiet; then
echo "==> AUR package already up to date (v${VERSION}); nothing to push."
exit 0
fi
git -C "${WORK}/aur" commit -m "Update to v${VERSION}"
git -C "${WORK}/aur" push
echo "==> Published ${AUR_PKG} v${VERSION} to the AUR."
# ── Homebrew ──────────────────────────────────────────────────────────────────
#
# packaging/homebrew/etr.rb is the SOURCE, not a reference copy. `brew-publish` renders it and
# pushes exactly that file to the tap; `packaging-check` guards it. The sibling repo's AUR
# pair spent eleven releases as an inert reference copy nothing rendered or checked -- this
# does not repeat that.
# Render the formula for a released tag and push it to the tap at l1a/homebrew-etr.
#
# Takes the version rather than reading Cargo.toml, for the same reason `install-tag` does:
# the formula is a template, and this is the moment the released version and its checksum come
# into existence. Nothing in the repo holds a value to bump.
# Render the Homebrew formula for a released tag and push it to the tap
brew-publish VERSION:
#!/usr/bin/env bash
set -euo pipefail
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
pass() { echo -e "${GREEN}[✓]${NC} $1"; }
fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
info() { echo -e "${YELLOW}[→]${NC} $1"; }
TAP_REPO="git@github.com:l1a/homebrew-etr.git"
VER="{{VERSION}}"; VER="${VER#v}"
[ -n "$VER" ] || fail "brew-publish needs a version, e.g. just brew-publish 0.9.0"
"{{PY}}" scripts/packaging_check.py || fail "packaging checks failed — not publishing"
pass "packaging checks pass"
# The checksum is COMPUTED from the tarball Homebrew will actually download, never copied
# from a committed field. Written to a file rather than piped, so the byte count is
# inspectable if the hash ever looks wrong.
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
URL="https://github.com/l1a/etr/archive/refs/tags/v${VER}.tar.gz"
info "Downloading and checksumming the v$VER tarball..."
curl -sfL -o "$WORK/src.tar.gz" "$URL" \
|| fail "no release tarball at $URL — tag and release v$VER first"
SHA=$("{{PY}}" -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$WORK/src.tar.gz")
pass "v$VER tarball: $(wc -c < "$WORK/src.tar.gz" | tr -d ' ') bytes, sha256 $SHA"
"{{PY}}" scripts/render_packaging.py --target brew --version "$VER" --sha256 "SHA256=$SHA" \
--out "$WORK/etr.rb"
pass "rendered formula for v$VER"
echo
echo -e "${BOLD}About to publish etr $VER to the Homebrew tap.${NC}"
echo "This is public and immediate: $TAP_REPO"
echo ""
# Takes its answer from BREW_CONFIRM, an interactive stdin, or piped input under a bound --
# mirroring `pr`'s PR_CONFIRM and `clean-procs`'s CLEAN_CONFIRM, for the v0.7.2 reason: a
# bare `read` can only be answered by a human at a terminal, so a script or agent either
# blocks on a stdin that will never answer or dies without saying why, and that failure
# reads as the gate REFUSING the publish rather than as a question nobody could hear. This
# widens who can answer, not what counts as an answer.
#
# `y` IS ACCEPTED HERE, and that is the point of this block rather than an afterthought.
# Until v0.10.3 this alone of the three required the literal `yes`, so `BREW_CONFIRM=y` --
# the spelling the other two take, and the one anybody who has used them will reach for --
# aborted the publish. It did exactly that during the v0.10.1 release, at the Homebrew leg,
# AFTER crates.io and the AUR had already published: the worst moment to discover it, and a
# partially-released version to recover from. Three variables doing one job must not take
# two different answers.
#
# Still not a bypass: every path requires an explicit affirmative and there is no default,
# so an empty answer, a stray newline or an unset variable all still refuse.
#
# The PROMPT says `[y/N]` on every path, like `pr` and `clean-procs`. Until v0.10.9 it still
# printed "Type 'yes'" (and the no-terminal hint said BREW_CONFIRM=yes) after v0.10.3 had
# widened the accepted answers -- the text contradicted the behaviour it sat next to, and
# the piped-stdin path printed no question at all.
if [ -n "${BREW_CONFIRM:-}" ]; then
CONFIRM="$BREW_CONFIRM"
echo "Publish to the tap? [y/N] $CONFIRM (answered by BREW_CONFIRM)"
elif [ -t 0 ]; then
echo -n "Publish to the tap? [y/N] "; read -r CONFIRM
else
echo -n "Publish to the tap? [y/N] "
read -r -t 10 CONFIRM || CONFIRM=""
echo "$CONFIRM"
[ -n "$CONFIRM" ] || fail "no terminal and nothing on stdin. Re-run with BREW_CONFIRM=y"
fi
case "$CONFIRM" in
y|Y|yes|YES) ;;
*) echo -e "${RED}Aborted.${NC}"; exit 1 ;;
esac
info "Cloning the tap..."
git clone -q "$TAP_REPO" "$WORK/tap" \
|| fail "could not clone $TAP_REPO — does the tap exist, and is the SSH key registered?"
mkdir -p "$WORK/tap/Formula"
cp "$WORK/etr.rb" "$WORK/tap/Formula/etr.rb"
# A brand-new tap has no commits and therefore no branch, and which name git invents
# depends on the host's `init.defaultBranch` -- unset on at least one machine in this
# fleet, which would make the tap's default branch differ per publisher. Pin it, but ONLY
# when the repo is genuinely empty: doing this unconditionally would move HEAD on a
# populated tap without touching the index, which is a quiet way to lose work.
if ! git -C "$WORK/tap" rev-parse --verify -q HEAD >/dev/null 2>&1; then
git -C "$WORK/tap" symbolic-ref HEAD refs/heads/main
info "empty tap: default branch pinned to main"
fi
# A fresh clone does not inherit this repo's commit identity, and GitHub rejects a push
# authored with a private email.
git -C "$WORK/tap" config user.name "$(git -C "{{justfile_directory()}}" config user.name)"
git -C "$WORK/tap" config user.email "$(git -C "{{justfile_directory()}}" config user.email)"
# Stage FIRST, then ask the index. See the same note in publish-aur: `git diff --quiet`
# cannot see untracked files, so on a brand-new empty tap it reports "no changes" and this
# would exit 0 having published nothing -- the worst available outcome.
git -C "$WORK/tap" add Formula/etr.rb
if git -C "$WORK/tap" diff --cached --quiet; then
pass "tap already has this exact formula — nothing to push"
exit 0
fi
git -C "$WORK/tap" commit -q -m "etr $VER"
git -C "$WORK/tap" push -q origin HEAD
pass "published etr $VER to $TAP_REPO"
# Render the COPR spec as .copr/Makefile will and print it (no network, no rpm tooling).
#
# There is no `copr-bump`, because there is nothing to bump: the spec's Version: is @VERSION@
# and .copr/Makefile renders it from Cargo.toml when COPR builds the SRPM. This recipe exists
# so a human can see what COPR will be handed without running rpmbuild.
# Print the COPR spec as .copr/Makefile will render it (offline)
copr-render VERSION="":
@"{{PY}}" scripts/render_packaging.py --target copr {{ if VERSION != "" { "--version " + VERSION } else { "" } }}
# Set the GitHub repository description and topics from packaging/metadata.toml, then read
# them back. Runs the packaging guards first, so text that fails them is never pushed. Pass
# --dry-run to see the difference without changing anything.
# Push the GitHub About-box description and topics from metadata.toml
github-metadata *ARGS:
@"{{PY}}" scripts/packaging_check.py --sync-github "$@"
# ── Build ─────────────────────────────────────────────────────────────────────
# Build debug binaries
build:
cargo build
# Build optimised release binaries
build-release:
cargo build --release
# Build the stress-test helper binary (TCP/UDP echo servers + pumps)
build-stress:
cargo build --release --manifest-path tools/stress/Cargo.toml
# ── Install ───────────────────────────────────────────────────────────────────
# ── Man pages ────────────────────────────────────────────────────────────────
# Build man pages from man/*.md using mandown.
#
# THE RENDERED PAGES ARE TRACKED (man/etr.1, man/etrs.1), and that is a packaging
# requirement rather than a preference. A GitHub tag tarball contains only tracked files,
# and both the COPR spec and the Homebrew formula install a man page OUT OF that tarball —
# so while the pages lived in the gitignored man/build/ neither channel could ship one, and
# `just install-tag` had to report them "not tracked at that tag". Committing them is also
# what retch does, for the further reason that regenerating at package-build time makes the
# packaged page depend on which mandown build happened to run.
#
# The consequence to remember: the .TH line embeds the version, so EVERY version bump
# dirties these two files. That is why AGENTS.md §4.10 says to re-run `just man` after the
# bump, and why `man-check` below is wired into `just check` — a stale committed page is now
# a failing gate rather than an invisible wart.
# Build man pages from man/*.md with mandown (output is TRACKED)
man:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v mandown >/dev/null 2>&1; then
echo "ERROR: mandown is required to build man pages (cargo install mandown)" >&2
exit 1
fi
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
mandown man/etr.1.md ETR 1 | sed "1s|.*|.TH \"ETR\" \"1\" \"\" \"etr $VERSION\" \"User Commands\"|" > man/etr.1
mandown man/etrs.1.md ETRS 1 | sed "1s|.*|.TH \"ETRS\" \"1\" \"\" \"etr $VERSION\" \"User Commands\"|" > man/etrs.1
echo "Built man/etr.1 and man/etrs.1 (version $VERSION)"
# Fail if the man pages are not what `just man` produces -- checked TWICE, against two trees.
#
# Run by `just check`, so a version bump that forgets `just man` cannot reach a PR. Skips
# (rather than fails) where mandown is absent: refusing `just check` on a contributor's
# machine for a tool that only maintainers need would make the repo harder to work on, not
# safer -- the same reasoning scripts/hooks/pre-push already applies to a missing `just`.
#
# 1. WORKTREE: the pages on disk against a rebuild from the sources on disk. Compares BYTES
# via cmp, not `git diff --quiet`: on a Syncthing-shared tree checked out on three OSes the
# worktree is the thing that gets packaged.
# 2. HEAD: the COMMITTED pages against a rebuild from the COMMITTED sources and the COMMITTED
# Cargo.toml version. Check 1 alone cannot see a commit that disagrees with itself: on #81
# an amend without `git add` committed Cargo.toml at 0.10.6 while the committed `.TH` still
# said 0.10.5, and `just check` passed because the worktree was correct. The tag tarball --
# what COPR and Homebrew install from -- carries the commit, not the worktree.
# HEAD is compared with ITSELF, never with the worktree, so uncommitted work in progress
# cannot fail it; only a commit that is internally inconsistent can.
#
# Rebuilds go to a temp dir so a failing check never leaves a half-written page behind.
man-check:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v mandown >/dev/null 2>&1; then
echo "man-check: mandown not installed — skipping (cargo install mandown)"
exit 0
fi
# Temp files inside the destination directory, never /tmp: on this Syncthing-synced tree
# a cross-filesystem `mv` from tmpfs carries the user_tmp_t SELinux label into ~/Sync and
# wedges the whole folder (~/AGENTS.md §12). Nothing is moved here, but the same rule
# keeps the pattern correct if anyone adds a `mv` later.
TMP=$(mktemp -d "man/.man-check.XXXXXX")
trap 'rm -rf "$TMP"' EXIT
cargo_version() { grep '^version' | head -1 | sed 's/.*"\(.*\)"/\1/'; }
# render SRC_MD NAME VERSION OUT -- must stay identical to what `just man` does.
render() {
mandown "$1" "$2" 1 | sed "1s|.*|.TH \"$2\" \"1\" \"\" \"etr $3\" \"User Commands\"|" > "$4"
}
# 1. Worktree.
VERSION=$(cargo_version < Cargo.toml)
mkdir "$TMP/wt"
render man/etr.1.md ETR "$VERSION" "$TMP/wt/etr.1"
render man/etrs.1.md ETRS "$VERSION" "$TMP/wt/etrs.1"
for p in etr.1 etrs.1; do
if ! cmp -s "$TMP/wt/$p" "man/$p"; then
echo "error: man/$p is stale — run 'just man' and commit the result." >&2
echo " (the .TH line embeds the version, so a version bump always changes it)" >&2
diff -u "man/$p" "$TMP/wt/$p" | head -20 >&2 || true
exit 1
fi
done
# 2. HEAD, against itself.
if ! git rev-parse -q --verify HEAD >/dev/null 2>&1; then
echo "man pages are current (version $VERSION); no HEAD commit, committed-tree check skipped"
exit 0
fi
HEAD_VERSION=$(git show HEAD:Cargo.toml | cargo_version)
mkdir "$TMP/head"
for pair in etr.1:ETR etrs.1:ETRS; do
p=${pair%%:*}; name=${pair##*:}
git show "HEAD:man/$p.md" > "$TMP/head/$p.md"
git show "HEAD:man/$p" > "$TMP/head/$p.committed"
render "$TMP/head/$p.md" "$name" "$HEAD_VERSION" "$TMP/head/$p"
if ! cmp -s "$TMP/head/$p" "$TMP/head/$p.committed"; then
echo "error: the COMMITTED man/$p (HEAD $(git rev-parse --short HEAD)) is stale for the" >&2
echo " committed Cargo.toml version $HEAD_VERSION, although the worktree copy is current." >&2
echo " Usually a commit or --amend made without staging man/. Fix:" >&2
echo " git add man/etr.1 man/etrs.1 && git commit --amend --no-edit" >&2
diff -u "$TMP/head/$p.committed" "$TMP/head/$p" | head -20 >&2 || true
exit 1
fi
done
echo "man pages are current (worktree $VERSION, HEAD $HEAD_VERSION)"
# ── Local end-to-end testing ─────────────────────────────────────────────────
# Verify tools needed for e2e-local (tmux, ssh, passwordless localhost access)
check-tools:
#!/usr/bin/env bash
set -euo pipefail
missing=()
for cmd in cargo tmux ssh; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
if [[ ${#missing[@]} -gt 0 ]]; then
echo "ERROR: missing required tools: ${missing[*]}" >&2
echo " cargo — install from https://rustup.rs" >&2
echo " tmux — install via your package manager (e.g. brew install tmux / dnf install tmux)" >&2
echo " ssh — install openssh-clients" >&2
exit 1
fi
# Verify SSH can reach localhost in batch mode (no password prompt)
if ! ssh -q -o BatchMode=yes -o ConnectTimeout=3 localhost true 2>/dev/null; then
echo "WARNING: SSH to localhost failed." >&2
echo " etr's SSH bootstrap requires passwordless SSH to the target host." >&2
echo " Run: ssh-copy-id localhost (or append ~/.ssh/id_*.pub to ~/.ssh/authorized_keys)" >&2
exit 1
fi
echo "All required tools present and SSH to localhost is functional."
# Run the full local end-to-end test (happy path + reconnect)
#
# etr SSHes to localhost, starts etrs on the fly (no pre-running daemon),
# etrs forks and orphans its child, which handles the session.
#
# Reconnect is tested by SIGSTOP-ing the etrs daemon. etrs has no controlling
# terminal, so SIGSTOP is safe (no SIGHUP risk). etr keeps running, notices
# the missing heartbeat after 15 s, and starts reconnecting. QUIC Initial
# packets accumulate in the OS UDP socket buffer while etrs is stopped; when
# etrs resumes (SIGCONT) it processes them and the session is restored.
#
# (Stopping etr instead would not work on macOS: etr is the tmux pane command
# and is attached to a PTY; when stopped, the PTY hangup delivers SIGHUP+SIGCONT
# which kills the process before we can resume it.)
#
# etr is launched as the tmux session command (not via send-keys) to avoid
# the .zshrc startup race.
e2e-local: check-tools install
#!/usr/bin/env bash
set -euo pipefail
# Reap only what THIS test starts -- never a live session on this machine.
source scripts/e2e_procs.sh
ETRS_PRE=$(procs_snapshot etrs)
CLIENT_LOG="${XDG_STATE_HOME:-$HOME/.local/state}/etr/etr.log"
cleanup() {
echo ""
echo "--- cleanup ---"
tmux kill-session -t "{{TMUX_SESS}}" 2>/dev/null && echo "killed tmux session {{TMUX_SESS}}" || true
procs_reap etrs "${ETRS_PRE:-}"
}
trap cleanup EXIT
mkdir -p "$(dirname "$CLIENT_LOG")"
# Truncate the client log so session-ready detection isn't confused by
# a "[etr] Connected." line left over from a previous run.
> "$CLIENT_LOG"
# ── 1. Launch etr directly as the tmux session command ───────────────────
# Running etr as the session command (not via send-keys) avoids the .zshrc
# startup race and makes #{pane_pid} == etr's PID.
echo "==> Launching etr client in tmux session '{{TMUX_SESS}}'..."
tmux new-session -d -s "{{TMUX_SESS}}" -x 200 -y 50 -- \
"{{INSTALL}}/etr" -v localhost
# ── 2. Wait for "[etr] Connected." in the client log ─────────────────────
echo " waiting for etr to connect..."
READY=0
for i in $(seq 1 30); do
sleep 1
grep -q '\[etr\] Connected\.' "$CLIENT_LOG" 2>/dev/null && { READY=1; break; }
done
if [[ $READY -eq 0 ]]; then
echo "ERROR: '[etr] Connected.' not seen in $CLIENT_LOG within 30 s" >&2
cat "$CLIENT_LOG" >&2
exit 1
fi
# Send a sentinel to the remote shell and wait for it to echo back,
# confirming the PTY stream is live end-to-end.
SENTINEL="ETR_TEST_READY_$$"
tmux send-keys -t "{{TMUX_SESS}}" "echo ${SENTINEL}" Enter
echo " waiting for remote shell sentinel..."
READY=0
for i in $(seq 1 20); do
sleep 1
tmux capture-pane -t "{{TMUX_SESS}}" -p -S - 2>/dev/null \
| grep -q "${SENTINEL}" && { READY=1; break; }
done
if [[ $READY -eq 0 ]]; then
echo "ERROR: remote shell sentinel not seen within 20 s" >&2
tmux capture-pane -t "{{TMUX_SESS}}" -p -S - >&2
exit 1
fi
echo " session up."
# ── 3. Happy-path test ───────────────────────────────────────────────────
echo "==> Sending test commands..."
tmux send-keys -t "{{TMUX_SESS}}" "echo HELLO_FROM_ETR && hostname && date" Enter
sleep 2
OUTPUT=$(tmux capture-pane -t "{{TMUX_SESS}}" -p -S -)
if echo "$OUTPUT" | grep -q "HELLO_FROM_ETR"; then
echo " PASS: test command output received through etr session."
else
echo "FAIL: expected 'HELLO_FROM_ETR' in tmux pane output." >&2
echo "--- pane output ---" >&2
echo "$OUTPUT" >&2
exit 1
fi
# ── 4. Reconnect test ────────────────────────────────────────────────────
ETRS_PID=$(pgrep -x etrs 2>/dev/null | head -1 || true)
if [[ -z "$ETRS_PID" ]]; then
echo "SKIP: etrs PID not found; skipping reconnect test" >&2
else
echo "==> Reconnect test: suspending etrs (pid $ETRS_PID) for 17 s..."
kill -STOP "$ETRS_PID"
echo " etrs suspended. etr will hit 15-s heartbeat timeout and reconnect..."
sleep 17
kill -CONT "$ETRS_PID"
echo " etrs resumed. Waiting for reconnect..."
sleep 8
tmux send-keys -t "{{TMUX_SESS}}" "echo RECONNECT_OK && uptime" Enter
sleep 2
OUTPUT2=$(tmux capture-pane -t "{{TMUX_SESS}}" -p -S -)
if echo "$OUTPUT2" | grep -q "RECONNECT_OK"; then
echo " PASS: session resumed after reconnect."
else
echo "FAIL: expected 'RECONNECT_OK' after reconnect." >&2
echo "--- pane output ---" >&2
echo "$OUTPUT2" >&2
exit 1
fi
fi
echo ""
echo "==> All tests passed."
# Run the local E2E test for --env variable forwarding to the remote shell
e2e-env-local: check-tools install
#!/usr/bin/env bash
set -euo pipefail
# Reap only what THIS test starts -- never a live session on this machine.
source scripts/e2e_procs.sh
ETRS_PRE=$(procs_snapshot etrs)
CLIENT_LOG="${XDG_STATE_HOME:-$HOME/.local/state}/etr/etr.log"
TMUX_SESS_ENV="etr_env_test"
cleanup() {
echo ""
echo "--- cleanup ---"
tmux kill-session -t "$TMUX_SESS_ENV" 2>/dev/null && echo "killed tmux session $TMUX_SESS_ENV" || true
procs_reap etrs "${ETRS_PRE:-}"
}
trap cleanup EXIT
mkdir -p "$(dirname "$CLIENT_LOG")"
> "$CLIENT_LOG"
# ── 1. Launch etr with --env KEY=VALUE and --env KEY (bare forward) ───────
echo "==> Launching etr with --env ETR_TEST_SET=hello_env --env ETR_TEST_FWD..."
export ETR_TEST_FWD="forwarded_value_$$"
tmux new-session -d -s "$TMUX_SESS_ENV" -x 200 -y 50 -- \
"{{INSTALL}}/etr" -v \
--env "ETR_TEST_SET=hello_env" \
--env "ETR_TEST_FWD" \
localhost
# ── 2. Wait for "[etr] Connected." ────────────────────────────────────────
echo " waiting for etr to connect..."
READY=0
for i in $(seq 1 30); do
sleep 1
grep -q '\[etr\] Connected\.' "$CLIENT_LOG" 2>/dev/null && { READY=1; break; }
done
if [[ $READY -eq 0 ]]; then
echo "ERROR: '[etr] Connected.' not seen within 30 s" >&2
cat "$CLIENT_LOG" >&2
exit 1
fi
# Send a sentinel to confirm the PTY stream is live.
SENTINEL="ETR_ENV_READY_$$"
tmux send-keys -t "$TMUX_SESS_ENV" "echo ${SENTINEL}" Enter
READY=0
for i in $(seq 1 20); do
sleep 1
tmux capture-pane -t "$TMUX_SESS_ENV" -p -S - 2>/dev/null \