From 4f3d2f8471ff6b4a2932a3913eb99525e6b2e8a5 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 14 May 2026 15:27:58 +0000 Subject: [PATCH 1/5] rekey: apply symmetric jitter to desynchronize dual-initiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-session signed jitter offset (uniform [-15, +15] seconds) to the rekey timer triggers in check_rekey (FMP) and check_session_rekey (FSP). The configured `node.rekey.after_secs` becomes the nominal interval rather than a floor; mean is preserved. Desynchronizes both endpoints in symmetric-start meshes so the dual-initiation race stops occurring rather than being resolved after the fact by the smaller-NodeAddr tie-breaker. Per-session storage means each rekey cutover reconstructs the session and redraws the jitter naturally — successive cycles get independent offsets, preventing drift back into sync. --- CHANGELOG.md | 6 ++++ src/node/handlers/rekey.rs | 12 +++++-- src/node/mod.rs | 7 ++++ src/node/session.rs | 26 +++++++++++++++ src/node/tests/session.rs | 62 +++++++++++++++++++++++++++++++++++ src/peer/active.rs | 67 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e158d41..e6d60df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Apply ±15s symmetric jitter per session to the FMP and FSP rekey + timer trigger. Eliminates the steady-state dual-initiation race + in symmetric-start meshes; previously the smaller-NodeAddr + tie-breaker resolved correctness after every cycle's collision. + `node.rekey.after_secs` becomes the nominal interval rather than + a floor; mean is preserved. - Rekey integration test (`testing/static/scripts/rekey-test.sh`): bumped Phase 1 baseline-convergence headroom from 36s to 60s. Eliminates the intermittent GitHub-runner Phase 1 timeout that diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index 44f4c8b..35cb3fe 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -74,7 +74,11 @@ impl Node { .map(|s| s.current_send_counter()) .unwrap_or(0); - if elapsed >= rekey_after_secs || counter >= rekey_after_messages { + // Apply per-session symmetric jitter to desynchronize + // dual-initiation in symmetric-start meshes. + let effective_after_secs = + rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs()); + if elapsed >= effective_after_secs || counter >= rekey_after_messages { peers_to_rekey.push(*node_addr); } } @@ -323,7 +327,11 @@ impl Node { let elapsed_secs = now_ms.saturating_sub(entry.session_start_ms()) / 1000; let counter = entry.send_counter(); - if elapsed_secs >= rekey_after_secs || counter >= rekey_after_messages { + // Apply per-session symmetric jitter to desynchronize + // dual-initiation in symmetric-start meshes. + let effective_after_secs = + rekey_after_secs.saturating_add_signed(entry.rekey_jitter_secs()); + if elapsed_secs >= effective_after_secs || counter >= rekey_after_messages { sessions_to_rekey.push(*node_addr); } } diff --git a/src/node/mod.rs b/src/node/mod.rs index 284db6e..cb64298 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -24,6 +24,13 @@ pub(crate) mod wire; use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter}; use self::rate_limit::HandshakeRateLimiter; use self::routing_error_rate_limit::RoutingErrorRateLimiter; + +/// Half-range of the symmetric jitter applied to the per-session rekey timer. +/// Each session draws an offset uniformly from `[-REKEY_JITTER_SECS, +/// +REKEY_JITTER_SECS]` seconds at construction. Desynchronizes +/// dual-initiation in symmetric-start meshes; the configured +/// `node.rekey.after_secs` remains the nominal interval (mean preserved). +pub(crate) const REKEY_JITTER_SECS: i64 = 15; use self::wire::{ FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header, prepend_inner_header, diff --git a/src/node/session.rs b/src/node/session.rs index e596cf2..d78e701 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -10,9 +10,16 @@ use std::time::Instant; use crate::NodeAddr; use crate::config::SessionMmpConfig; use crate::mmp::MmpSessionState; +use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState, NoiseSession}; +use rand::RngExt; use secp256k1::PublicKey; +/// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. +fn draw_rekey_jitter() -> i64 { + rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS) +} + /// State machine for an end-to-end session. /// /// `Established` is intentionally larger than the handshake variants: @@ -121,6 +128,12 @@ pub(crate) struct SessionEntry { /// When the FSP rekey handshake completed (initiator sent msg3, Unix ms). /// Used to defer cutover until msg3 has time to reach the responder. rekey_completed_ms: u64, + /// Per-session symmetric jitter applied to the rekey timer trigger. + /// Drawn once at construction (and at each cutover) uniformly from + /// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Desynchronizes + /// dual-initiation in symmetric-start meshes; mean interval is + /// preserved. + rekey_jitter_secs: i64, } impl SessionEntry { @@ -157,6 +170,7 @@ impl SessionEntry { rekey_initiator: false, last_peer_rekey_ms: 0, rekey_completed_ms: 0, + rekey_jitter_secs: draw_rekey_jitter(), } } @@ -398,6 +412,16 @@ impl SessionEntry { self.rekey_completed_ms } + /// Per-session symmetric rekey-timer jitter offset (seconds). + /// + /// Drawn at session construction and at each rekey cutover; uniform + /// over `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Callers add this + /// to the configured `node.rekey.after_secs` to obtain the effective + /// trigger interval for this session. + pub(crate) fn rekey_jitter_secs(&self) -> i64 { + self.rekey_jitter_secs + } + /// Record when the FSP rekey handshake completed (initiator side). pub(crate) fn set_rekey_completed_ms(&mut self, ms: u64) { self.rekey_completed_ms = ms; @@ -443,6 +467,7 @@ impl SessionEntry { self.rekey_state = None; self.rekey_initiator = false; self.rekey_completed_ms = 0; + self.rekey_jitter_secs = draw_rekey_jitter(); // Reset MMP counters to avoid metric discontinuity let now = Instant::now(); @@ -471,6 +496,7 @@ impl SessionEntry { self.session_start_ms = now_ms; self.rekey_state = None; self.rekey_initiator = false; + self.rekey_jitter_secs = draw_rekey_jitter(); // Reset MMP counters to avoid metric discontinuity let now = Instant::now(); diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index decab10..40907aa 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -67,6 +67,68 @@ fn test_session_entry_new_initiating() { assert_eq!(entry.last_activity(), 1000); } +#[test] +fn test_session_entry_rekey_jitter_in_range() { + use crate::node::REKEY_JITTER_SECS; + use crate::noise::HandshakeState; + + // Every newly constructed SessionEntry's jitter must lie in the + // symmetric range [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]. + for _ in 0..100 { + let identity_a = Identity::generate(); + let identity_b = Identity::generate(); + let handshake = + HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full()); + let entry = crate::node::session::SessionEntry::new( + *identity_b.node_addr(), + identity_b.pubkey_full(), + EndToEndState::Initiating(handshake), + 1000, + true, + ); + let j = entry.rekey_jitter_secs(); + assert!( + (-REKEY_JITTER_SECS..=REKEY_JITTER_SECS).contains(&j), + "jitter {} outside [-{}, +{}]", + j, + REKEY_JITTER_SECS, + REKEY_JITTER_SECS + ); + } +} + +#[test] +fn test_session_entry_rekey_jitter_mean_near_zero() { + use crate::noise::HandshakeState; + + // Sanity check that the distribution is roughly symmetric and not + // stuck at one extreme. With N=200 draws from a uniform ~30-second + // range, the empirical mean should be well under 5 in absolute value. + let mut sum: i64 = 0; + let n: i64 = 200; + for _ in 0..n { + let identity_a = Identity::generate(); + let identity_b = Identity::generate(); + let handshake = + HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full()); + let entry = crate::node::session::SessionEntry::new( + *identity_b.node_addr(), + identity_b.pubkey_full(), + EndToEndState::Initiating(handshake), + 1000, + true, + ); + sum += entry.rekey_jitter_secs(); + } + let mean = sum / n; + assert!( + mean.abs() < 5, + "empirical mean {} not within 5 of 0 over {} samples", + mean, + n + ); +} + #[test] fn test_session_entry_touch() { use crate::noise::HandshakeState; diff --git a/src/peer/active.rs b/src/peer/active.rs index 942c278..2ef0c2d 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -5,15 +5,22 @@ use crate::bloom::BloomFilter; use crate::mmp::{MmpConfig, MmpPeerState}; +use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession}; use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId}; use crate::tree::{ParentDeclaration, TreeCoordinate}; use crate::utils::index::SessionIndex; use crate::{FipsAddress, NodeAddr, PeerIdentity}; +use rand::RngExt; use secp256k1::XOnlyPublicKey; use std::fmt; use std::time::Instant; +/// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. +fn draw_rekey_jitter() -> i64 { + rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS) +} + /// Connectivity state for an active peer. /// /// This is simpler than the full PeerState since authentication is complete. @@ -156,6 +163,12 @@ pub struct ActivePeer { // === Rekey (Key Rotation) === /// When the current Noise session was established (for rekey timer). session_established_at: Instant, + /// Per-session symmetric jitter applied to the rekey timer trigger. + /// Drawn once at construction (and at each cutover) uniformly from + /// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Desynchronizes + /// dual-initiation in symmetric-start meshes; mean interval is + /// preserved. + rekey_jitter_secs: i64, /// Current K-bit epoch value (alternates each rekey). current_k_bit: bool, /// Previous session kept alive during drain window after cutover. @@ -220,6 +233,7 @@ impl ActivePeer { replay_suppressed_count: 0, consecutive_decrypt_failures: 0, session_established_at: now, + rekey_jitter_secs: draw_rekey_jitter(), current_k_bit: false, previous_session: None, previous_our_index: None, @@ -300,6 +314,7 @@ impl ActivePeer { replay_suppressed_count: 0, consecutive_decrypt_failures: 0, session_established_at: now, + rekey_jitter_secs: draw_rekey_jitter(), current_k_bit: false, previous_session: None, previous_our_index: None, @@ -783,6 +798,16 @@ impl ActivePeer { self.session_established_at } + /// Per-session symmetric rekey-timer jitter offset (seconds). + /// + /// Drawn at session construction and at each rekey cutover; uniform + /// over `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Callers add this + /// to the configured `node.rekey.after_secs` to obtain the effective + /// trigger interval for this session. + pub fn rekey_jitter_secs(&self) -> i64 { + self.rekey_jitter_secs + } + /// Current K-bit epoch value. pub fn current_k_bit(&self) -> bool { self.current_k_bit @@ -887,6 +912,7 @@ impl ActivePeer { self.session_established_at = Instant::now(); self.session_start = Instant::now(); self.rekey_in_progress = false; + self.rekey_jitter_secs = draw_rekey_jitter(); self.reset_replay_suppressed(); // Reset MMP counters to avoid metric discontinuity @@ -922,6 +948,7 @@ impl ActivePeer { self.session_established_at = Instant::now(); self.session_start = Instant::now(); self.rekey_in_progress = false; + self.rekey_jitter_secs = draw_rekey_jitter(); self.reset_replay_suppressed(); // Reset MMP counters to avoid metric discontinuity @@ -1252,4 +1279,44 @@ mod tests { assert_eq!(peer.increment_decrypt_failures(), 1); assert_eq!(peer.consecutive_decrypt_failures(), 1); } + + #[test] + fn test_rekey_jitter_in_range() { + // Every newly constructed peer's jitter must lie in the + // symmetric range [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]. + for _ in 0..100 { + let identity = make_peer_identity(); + let peer = ActivePeer::new(identity, LinkId::new(1), 1000); + let j = peer.rekey_jitter_secs(); + assert!( + (-REKEY_JITTER_SECS..=REKEY_JITTER_SECS).contains(&j), + "jitter {} outside [-{}, +{}]", + j, + REKEY_JITTER_SECS, + REKEY_JITTER_SECS + ); + } + } + + #[test] + fn test_rekey_jitter_mean_near_zero() { + // Sanity check that the distribution is roughly symmetric and + // not stuck at one extreme. With N=200 draws from a uniform + // ~30-second-wide range, the empirical mean should be well + // under 5 in absolute value with overwhelming probability. + let mut sum: i64 = 0; + let n: i64 = 200; + for _ in 0..n { + let identity = make_peer_identity(); + let peer = ActivePeer::new(identity, LinkId::new(1), 1000); + sum += peer.rekey_jitter_secs(); + } + let mean = sum / n; + assert!( + mean.abs() < 5, + "empirical mean {} not within 5 of 0 over {} samples", + mean, + n + ); + } } From e9dd3167f231cd8df12809334d780fbcbb878bd1 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 14 May 2026 17:07:32 +0000 Subject: [PATCH 2/5] test(acl-allowlist): poll log assertion to absorb XX-handshake timing race Convert assert_log_contains from a one-shot grep snapshot into a bounded poll that retries until the pattern appears or the timeout elapses (default 15s). Same wait-with-timeout shape as wait_for_peers_exact above it in the file. The pre-existing flake on next-branch CI is structural: under XX handshake, the cross-connection tie-breaker selects which side reaches its ACL-check point first. When container-a wins the tie-breaker on the first attempt against c and d, only the outbound-handshake-context rejection fires immediately, and the inbound-handshake-context rejection only emits on a later retry when c or d's msg1 lands while a has no pending outbound. On one 2026-05-14 run the inbound rejection appeared 63ms after the test had given up. The race window is small but real. Polling the log instead of one-shot reading absorbs the millisecond-to-second variance without slowing the success path (the helper returns as soon as the pattern appears). --- testing/acl-allowlist/test.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/testing/acl-allowlist/test.sh b/testing/acl-allowlist/test.sh index 2bf8565..f56438c 100755 --- a/testing/acl-allowlist/test.sh +++ b/testing/acl-allowlist/test.sh @@ -98,13 +98,25 @@ wait_for_peers_exact() { assert_log_contains() { local container="$1" local pattern="$2" + local timeout="${3:-15}" local logs - logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)" - if ! printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then - echo "FAIL: missing log pattern in $container: $pattern" >&2 - exit 1 - fi - echo "PASS: $container logs contain expected ACL rejection" + + # Poll docker logs instead of one-shot reading: under XX handshake, + # the cross-connection tie-breaker determines which side reaches + # its ACL-check point first, so the inbound-handshake-context + # rejection may not emit until a later retry. Same wait-with-timeout + # shape as wait_for_peers_exact above. + for _ in $(seq 1 "$timeout"); do + logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)" + if printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then + echo "PASS: $container logs contain expected ACL rejection" + return 0 + fi + sleep 1 + done + + echo "FAIL: missing log pattern in $container: $pattern (waited ${timeout}s)" >&2 + exit 1 } if [ "$SKIP_BUILD" = false ]; then From 80fb086071c1006a80e03c60709adc03885f2173 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 14 May 2026 17:54:22 +0000 Subject: [PATCH 3/5] test(rekey): add Phase 5 settle window for post-second-rekey convergence Phase 5's per-pair connectivity check ran immediately after the second rekey cycle, with no settle for routing reconvergence. Under GitHub-runner CPU contention, post-rekey parent-switches and coord-cache flushes can take longer than the per-ping 5s timeout for a small fraction of pairs (1-3 of 20 typically), even though the rekey mechanism itself completes cleanly. Phase 6 log analysis stays all-green on these failed runs; the failure is purely connectivity timing. Mirror Phase 3's existing 12-second settle pattern: reuse REKEY_SETTLE and emit the same banner shape. Two-line change. Cost on the success path is a fixed 12s per suite run. --- CHANGELOG.md | 7 +++++++ testing/static/scripts/rekey-test.sh | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d60df..d284e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 previously required `gh run rerun --failed`. Cost on the success path is unchanged because the wait loop returns as soon as all 20 pairs converge. +- Rekey integration test (`testing/static/scripts/rekey-test.sh`): + added a post-second-rekey settle window in Phase 5, mirroring + Phase 3's existing 12-second pattern. Closes the intermittent + GitHub-runner Phase 5 per-pair-ping flake caused by post-rekey + routing convergence exceeding the per-ping 5-second timeout under + runner CPU contention. Cost on the success path is a fixed 12s per + suite run. ## [0.3.0] - 2026-05-11 diff --git a/testing/static/scripts/rekey-test.sh b/testing/static/scripts/rekey-test.sh index e63ee2f..6465b39 100755 --- a/testing/static/scripts/rekey-test.sh +++ b/testing/static/scripts/rekey-test.sh @@ -368,7 +368,8 @@ echo "Phase 4: Second rekey cycle (waiting ${SECOND_REKEY_WAIT}s)" sleep "$SECOND_REKEY_WAIT" # Verify connectivity after second rekey (back-to-back) -echo "Phase 5: Post-second-rekey connectivity" +echo "Phase 5: Post-second-rekey connectivity (settling ${REKEY_SETTLE}s)" +sleep "$REKEY_SETTLE" ping_all phase_result "Post-second-rekey (all 20 pairs)" echo "" From 7f518731c89d84d0fd667ee4ec4b9fda3be68a38 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 14 May 2026 18:15:22 +0000 Subject: [PATCH 4/5] ci: cancel in-progress runs on same-ref pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a top-level concurrency block to ci.yml keyed on (workflow, ref) with cancel-in-progress: true. Pushes (including force-pushes) to the same ref now retire any in-flight run for that ref rather than letting the superseded and current-tip runs both burn runner minutes. Motivated by a 2026-05-14 force-push experience where two CI runs ran concurrently against a feature branch — the original push at c76ec99 continued for ~17 minutes alongside the amended push at 927ef47, despite only the latter being the live tip. Scope deliberately limited to ci.yml. Tag-triggered release-build workflows (package-*.yml, aur-publish-*.yml) are untouched — they operate on per-tag refs that already form distinct concurrency groups and release artifact builds generally should not be cancellable by unrelated activity. --- .github/workflows/ci.yml | 4 ++++ CHANGELOG.md | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7361f04..ff9c276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: type: boolean default: false +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: checks: write contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index d284e61..14bc997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 assistant policy, and project communication channels. Added `docs/branching.md` as the long-form companion covering the release workflow, version conventions, and merge-direction rationale. +- CI workflow (`.github/workflows/ci.yml`) now declares a top-level + `concurrency` block keyed on `(workflow, ref)` with + `cancel-in-progress: true`. Force-pushes and rapid successive + pushes to the same ref now retire the in-flight run rather than + letting superseded and current-tip runs both consume runner minutes. + Tag-triggered release-build workflows are deliberately untouched. ### Fixed From ab1e248ff4884ed76234819b319e86d919d699ff Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 14 May 2026 18:17:50 +0000 Subject: [PATCH 5/5] changelog: add acl-allowlist + AUR-publish coverage, merge CI entries Bring [Unreleased] into sync with all maint commits since v0.3.0: - Add a Fixed entry for the acl-allowlist test-script poll-assertion conversion (commit e9dd316) that was previously missing. - Add the AUR-publish workflow rewrite and new fips-git VCS workflow (commit 9bf9701) which had no entry, and merge them with the ci.yml cancel-in-progress block under a single "CI and release-publish workflows hardened" entry so the three workflow changes read as one operational theme. Pure changelog content reshuffle. No code touched. --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bc997..5a6d592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,12 +22,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 assistant policy, and project communication channels. Added `docs/branching.md` as the long-form companion covering the release workflow, version conventions, and merge-direction rationale. -- CI workflow (`.github/workflows/ci.yml`) now declares a top-level - `concurrency` block keyed on `(workflow, ref)` with - `cancel-in-progress: true`. Force-pushes and rapid successive - pushes to the same ref now retire the in-flight run rather than - letting superseded and current-tip runs both consume runner minutes. - Tag-triggered release-build workflows are deliberately untouched. +- CI and release-publish workflows hardened: + - `ci.yml` declares a top-level `concurrency` block keyed on + `(workflow, ref)` with `cancel-in-progress: true`. Force-pushes + and rapid successive pushes to the same ref now retire any + in-flight run rather than letting superseded and current-tip runs + both burn runner minutes. + - `aur-publish.yml` rewritten to fetch the upstream source tarball + and compute its `b2sum` in CI, then patch `pkgver` and the + `b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously + `updpkgsums: true` downloaded the tarball into the AUR working + tree, where it was rejected by AUR's 488 KiB max-blob hook — + silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` / + `fips.tmpfiles` asset b2sums are recomputed in the same step to + stay in sync with the local files. `workflow_dispatch` gains a + tag input so historical release tags can be re-published + manually, and `continue-on-error: true` is dropped so future + regressions surface in CI. + - New `aur-publish-git.yml` workflow for the `fips-git` VCS + PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or + companion files plus `workflow_dispatch`. `pkgver` is computed at + build time by the PKGBUILD's `pkgver()` function, so this workflow + is not tied to release tags. + - Tag-triggered `package-*` release-build workflows remain + untouched. ### Fixed @@ -50,6 +68,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 routing convergence exceeding the per-ping 5-second timeout under runner CPU contention. Cost on the success path is a fixed 12s per suite run. +- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`): + converted `assert_log_contains` from a one-shot `docker logs | grep` + snapshot into a bounded poll with the same wait-with-timeout shape + as `wait_for_peers_exact`. Absorbs the millisecond-to-second + variance in the XX-handshake cross-connection tie-breaker: the + inbound-handshake-context rejection can land tens of milliseconds + after the test's previous one-shot grep gave up, producing a + pre-existing flake on next-branch CI. Success-path cost is + unchanged — the helper returns as soon as the pattern appears. ## [0.3.0] - 2026-05-11