From 251974d4c0b23d3ab84b205cedd130188b1ada65 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 13:36:58 -0600 Subject: [PATCH 1/4] feat(core): expose paired time and borrowed outbound readiness --- crates/edgezero-core/src/outbound.rs | 9 ++ crates/edgezero-core/src/outbound/progress.rs | 80 +++++++++++- crates/edgezero-core/tests/outbound_time.rs | 118 ++++++++++++++++++ 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 crates/edgezero-core/tests/outbound_time.rs diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs index fd54c396..def73b5d 100644 --- a/crates/edgezero-core/src/outbound.rs +++ b/crates/edgezero-core/src/outbound.rs @@ -792,6 +792,15 @@ impl HttpClient { .start_batch_until(OutboundPreparedBatch::new(context, slots)) } + /// Returns the provider's paired clock and timer factory without creating a + /// timer or starting an exchange. Waiting retains `OutboundTime`'s existing + /// deadline, failure and cancellation semantics. + #[must_use] + #[inline] + pub fn time(&self) -> OutboundTime { + self.inner.time() + } + #[inline] pub fn with_client(client: Client) -> Self where diff --git a/crates/edgezero-core/src/outbound/progress.rs b/crates/edgezero-core/src/outbound/progress.rs index fb663a9d..9584670d 100644 --- a/crates/edgezero-core/src/outbound/progress.rs +++ b/crates/edgezero-core/src/outbound/progress.rs @@ -105,7 +105,7 @@ impl OutboundPending { #[inline] pub fn collect(mut self) -> OutboundFuture { Box::pin(async move { - poll_fn(|context| self.observe(context, PendingState::drive)).await; + poll_fn(|context| self.poll_ready(context)).await; match &mut self.state { PendingStorage::Owned(state) => state.disarm_success(), PendingStorage::Scoped(state) => { @@ -179,6 +179,16 @@ impl OutboundPending { } } } + /// Polls readiness with the caller's waker, retaining the original result for + /// `collect`. Success and failure are both ready; repeated polls reuse the cache. + /// + /// Unlike `is_ready`, this may drive provider progress. Callers stop polling + /// when their observation cutoff wins. Dropping a borrowed readiness wait + /// does not cancel this handle; dropping or cancelling the handle still does. + #[inline] + pub fn poll_ready(&mut self, context: &mut Context<'_>) -> Poll<()> { + self.observe(context, PendingState::drive) + } } impl PendingState { @@ -271,7 +281,7 @@ mod tests { use bytes::Bytes; use futures::channel::oneshot; use futures::executor::block_on; - use futures_util::future::pending; + use futures_util::future::{pending, poll_fn}; use futures_util::stream; use futures_util::task::{ArcWake, waker_ref}; @@ -680,6 +690,54 @@ mod tests { } } + #[test] + fn borrowed_readiness_wait_registers_real_waker_and_keeps_original_result() { + let (sender, mut receiver) = oneshot::channel(); + let calls = Rc::new(Cell::new(0)); + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let completion = poll_fn(move |cx| { + observed.set(observed.get().saturating_add(1)); + Pin::new(&mut receiver) + .poll(cx) + .map(|result| result.map_err(EdgeError::internal)) + }); + let mut pending = OutboundPending::new(Box::pin(completion), owner(&calls)); + let count = Arc::new(WakeCount::default()); + let waker = waker_ref(&count); + let mut context = Context::from_waker(&waker); + let mut borrowed = Box::pin(poll_fn(|cx| pending.poll_ready(cx))); + assert!(borrowed.as_mut().poll(&mut context).is_pending()); + drop(borrowed); + assert_eq!( + calls.get(), + 0, + "dropping only the borrow must not cancel work" + ); + sender + .send(response(Body::from("retained"))) + .expect("receiver alive"); + assert!( + count.0.load(Ordering::SeqCst) > 0, + "the original real waker is registered" + ); + assert!(pending.poll_ready(&mut context).is_ready()); + let completed_polls = polls.get(); + for _ in 0..3_u8 { + assert!(pending.poll_ready(&mut context).is_ready()); + assert!(pending.is_ready()); + } + assert_eq!(polls.get(), completed_polls); + let result = block_on(pending.collect()).expect("same cached response"); + assert_eq!(result.body().as_bytes(), Some(b"retained".as_slice())); + assert_eq!(polls.get(), completed_polls); + assert_eq!( + calls.get(), + 0, + "successful collection retains existing disarm semantics" + ); + } + #[test] fn pending_collection_registers_real_waker_after_readiness_probe() { let (sender, receiver) = oneshot::channel(); @@ -729,10 +787,20 @@ mod tests { .bind(&scope, time, entered_at) .expect("scope registration"); assert!(retained.is_ready()); + assert!( + retained + .poll_ready(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); assert_eq!(body_drops.get(), 0); callback.close(); assert_eq!(body_drops.get(), 1); assert_eq!(cancelled.get(), 1); + assert!( + retained + .poll_ready(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); let error = block_on(retained.collect()).expect_err("closed scope is not success"); assert!(matches!(error, EdgeError::Internal { .. })); assert_eq!(body_drops.get(), 1); @@ -760,12 +828,18 @@ mod tests { Poll::Ready(chunk.take().map(Ok)) })); let cancel_body = Rc::clone(&aborted); - let pending = OutboundPending::new( + let mut pending = OutboundPending::new( Box::pin(async move { Ok(response(body)) }), OutboundExchangeOwner::new(move || cancel_body.set(true)), ) .bind(&scope, time, entered_at) .expect("scope registration"); + assert!( + pending + .poll_ready(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); + assert!(pending.is_ready()); let collected = block_on(pending.collect()).expect("stream handoff"); callback.close(); assert!(!aborted.get()); diff --git a/crates/edgezero-core/tests/outbound_time.rs b/crates/edgezero-core/tests/outbound_time.rs new file mode 100644 index 00000000..2d231743 --- /dev/null +++ b/crates/edgezero-core/tests/outbound_time.rs @@ -0,0 +1,118 @@ +#[cfg(test)] +mod tests { + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll, Waker}; + use std::time::Duration; + + use edgezero_core::{ + EdgeError, HttpClient, IndependentOutboundProgress, MonotonicClock, MonotonicInstant, + OutboundBatch, OutboundDispatchRequest, OutboundFuture, OutboundHead, OutboundHttpClient, + OutboundPending, OutboundPreparedBatch, OutboundProgressRuntime, OutboundRequest, + OutboundSendMode, OutboundTime, + }; + use futures::executor::block_on; + use futures_util::future::{poll_fn, ready}; + + struct Client(OutboundTime); + struct DropCount(Arc); + + impl OutboundHttpClient for Client { + fn begin(&self, _request: OutboundDispatchRequest) -> Result { + Err(EdgeError::bad_request("unused fixture dispatch")) + } + fn dispatch_head(&self, _request: OutboundDispatchRequest) -> OutboundFuture { + Box::pin(ready(Err(EdgeError::bad_request( + "unused fixture dispatch", + )))) + } + fn progress(&self) -> Rc { + Rc::new(IndependentOutboundProgress) + } + fn start_batch_until(&self, batch: OutboundPreparedBatch) -> OutboundBatch { + OutboundBatch::cutoff(batch.into_slots().0) + } + fn time(&self) -> OutboundTime { + self.0.clone() + } + fn validate_supported_options( + &self, + _request: &OutboundRequest, + _mode: OutboundSendMode, + ) -> Result<(), EdgeError> { + Ok(()) + } + } + + #[expect(clippy::missing_trait_methods, reason = "Drop::pin_drop is unstable")] + impl Drop for DropCount { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn facade_time_preserves_custom_clock_factory_and_cancellation_without_eager_work() { + let start = MonotonicInstant::now(); + let samples = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&samples); + let clock = MonotonicClock::new(move || { + observed.fetch_add(1, Ordering::SeqCst); + start + }); + let deadline = clock.deadline_after(Duration::from_secs(1)); + samples.store(0, Ordering::SeqCst); + let registrations = Arc::new(AtomicUsize::new(0)); + let cancellations = Arc::new(AtomicUsize::new(0)); + let registered = Arc::clone(®istrations); + let cancelled = Arc::clone(&cancellations); + let time = OutboundTime::new(clock, move |actual, paired_clock| { + assert_eq!(actual.instant(), deadline.instant()); + assert_eq!(paired_clock.now(), start); + registered.fetch_add(1, Ordering::SeqCst); + let guard = DropCount(Arc::clone(&cancelled)); + Box::pin(poll_fn(move |_cx| { + let _retained = &guard; + Poll::Pending + })) + }); + let client = HttpClient::with_client(Client(time)); + let pair = client.time(); + assert_eq!(samples.load(Ordering::SeqCst), 0); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + assert_eq!(pair.clock().now(), start); + let mut wait = pair.wait_until(deadline); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + assert!( + wait.as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending() + ); + assert_eq!(registrations.load(Ordering::SeqCst), 1); + drop(wait); + assert_eq!(cancellations.load(Ordering::SeqCst), 1); + assert!(samples.load(Ordering::SeqCst) > 1); + } + + #[test] + fn facade_time_preserves_timer_error_and_expired_wait_behavior() { + let start = MonotonicInstant::now(); + let calls = Arc::new(AtomicUsize::new(0)); + let captured = Arc::clone(&calls); + let time = OutboundTime::new(MonotonicClock::new(move || start), move |_, _| { + captured.fetch_add(1, Ordering::SeqCst); + Box::pin(ready(Err(EdgeError::bad_request("fixture timer failure")))) + }); + let client = HttpClient::with_client(Client(time)); + let pair = client.time(); + let expired = edgezero_core::Deadline::at_instant(start); + block_on(pair.wait_until(expired)).expect("already expired wait"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + let future = pair.clock().deadline_after(Duration::from_secs(1)); + let error = block_on(pair.wait_until(future)).expect_err("timer error"); + assert_eq!(error.message(), "fixture timer failure"); + assert_eq!(error.status().as_u16(), 400); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } +} From dc2960739229eea05d42d32dc4a9e4d9e5eaba28 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 13:52:01 -0600 Subject: [PATCH 2/4] feat(outbound): preserve request-local surrogate cache keys --- .../edgezero-adapter-axum/tests/contract.rs | 39 ++++--- .../src/outbound.rs | 55 +++++++++ .../edgezero-adapter-fastly/src/outbound.rs | 105 ++++++++++++++++-- .../src/outbound/testing.rs | 44 +++++++- crates/edgezero-adapter-spin/src/outbound.rs | 21 ++++ crates/edgezero-core/src/outbound.rs | 20 +++- .../src/outbound/image_optimizer.rs | 63 +++++++++++ crates/edgezero-core/src/outbound/redirect.rs | 2 +- .../src/outbound/redirect_tests.rs | 51 +++++++++ .../tests/outbound_prepared_client.rs | 86 +++++++++++++- .../tests/outbound_public_api.rs | 66 ++++++++++- docs/guide/proxying.md | 37 ++++++ .../specs/2026-05-21-outbound-http-design.md | 22 +++- 13 files changed, 575 insertions(+), 36 deletions(-) diff --git a/crates/edgezero-adapter-axum/tests/contract.rs b/crates/edgezero-adapter-axum/tests/contract.rs index 8a33b646..00549fe2 100644 --- a/crates/edgezero-adapter-axum/tests/contract.rs +++ b/crates/edgezero-adapter-axum/tests/contract.rs @@ -1105,26 +1105,33 @@ async fn authority_override_changes_host_only_and_cache_bypass_adds_no_origin_he .get(CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .unwrap_or("absent"); - format!("{host}|{cache_control}") + let tag = headers.get("surrogate-key").map_or("absent", |_| "present"); + format!("{host}|{cache_control}|{tag}") }), )) .await; let client = HttpClient::with_client(AxumOutboundClient::try_new().expect("client")); - let request = OutboundRequest::get(format!("{origin}/")) - .expect("request") - .host_authority_override("virtual.example:8443") - .expect("authority override") - .cache_policy(OutboundCachePolicy::Bypass); - - let body = client - .send(request) - .await - .expect("response") - .into_bytes_bounded(1_024) - .await - .expect("response body"); - - assert_eq!(body, "virtual.example:8443|absent"); + for policy in [ + OutboundCachePolicy::Bypass, + OutboundCachePolicy::PlatformDefault, + OutboundCachePolicy::PlatformDefaultWithSurrogateKey(HeaderValue::from_static( + "example-all example-reader-0123", + )), + ] { + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .host_authority_override("virtual.example.com:8443") + .expect("authority override") + .cache_policy(policy); + let body = client + .send(request) + .await + .expect("response") + .into_bytes_bounded(1_024) + .await + .expect("response body"); + assert_eq!(body, "virtual.example.com:8443|absent|absent"); + } } #[tokio::test] diff --git a/crates/edgezero-adapter-cloudflare/src/outbound.rs b/crates/edgezero-adapter-cloudflare/src/outbound.rs index 788b37ce..5e6b55af 100644 --- a/crates/edgezero-adapter-cloudflare/src/outbound.rs +++ b/crates/edgezero-adapter-cloudflare/src/outbound.rs @@ -2231,6 +2231,39 @@ mod worker_impl { assert_eq!(before.body().as_bytes(), after.body().as_bytes()); } + #[wasm_bindgen_test] + fn tagged_default_keeps_worker_default_cache_mode_without_wire_tags() { + for (policy, expected) in [ + (OutboundCachePolicy::PlatformDefault, "default"), + ( + OutboundCachePolicy::PlatformDefaultWithSurrogateKey(HeaderValue::from_static( + "example-all example-reader-0123", + )), + "default", + ), + (OutboundCachePolicy::Bypass, "no-store"), + ] { + let request = build_worker_request( + &Method::GET, + "https://origin.example.com/resource", + &HeaderMap::new(), + None, + policy, + ) + .expect("Workers request"); + let mode = Reflect::get(request.inner().as_ref(), &JsValue::from_str("cache")) + .expect("cache mode"); + assert_eq!(mode.as_string().as_deref(), Some(expected)); + assert!( + request + .headers() + .get("surrogate-key") + .expect("header") + .is_none() + ); + } + } + #[wasm_bindgen_test] fn provider_diagnostics_preserve_preparation_errors_without_changing_sources() { let mut headers = HeaderMap::new(); @@ -3763,6 +3796,28 @@ mod header_bridge_tests { use super::*; + #[cfg(feature = "test-utils")] + #[test] + fn tagged_default_is_an_accepted_cache_noop() { + use edgezero_core::outbound::validate_for_dispatch; + let request = edgezero_core::OutboundRequest::get("https://origin.example.com/resource") + .expect("request") + .cache_policy( + edgezero_core::OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all"), + ), + ); + super::validate_supported_options(&request).expect("metadata ignored on Workers"); + for mode in [ + edgezero_core::OutboundSendMode::Send, + edgezero_core::OutboundSendMode::Begin, + edgezero_core::OutboundSendMode::Batch, + ] { + validate_for_dispatch(&request, mode).expect("valid metadata"); + } + assert!(!request.headers().contains_key("surrogate-key")); + } + #[derive(Default)] struct AppendSink(BTreeMap>); diff --git a/crates/edgezero-adapter-fastly/src/outbound.rs b/crates/edgezero-adapter-fastly/src/outbound.rs index 3e83a14f..dd98cc13 100644 --- a/crates/edgezero-adapter-fastly/src/outbound.rs +++ b/crates/edgezero-adapter-fastly/src/outbound.rs @@ -1129,8 +1129,12 @@ mod fastly_impl { for (name, value) in headers { request.append_header(name.as_str(), value.as_bytes()); } - if cache_policy == OutboundCachePolicy::Bypass { - request.set_pass(true); + match cache_policy { + OutboundCachePolicy::Bypass => request.set_pass(true), + OutboundCachePolicy::PlatformDefault => {} + OutboundCachePolicy::PlatformDefaultWithSurrogateKey(value) => { + request.set_surrogate_key(value); + } } request } @@ -2033,9 +2037,80 @@ mod fastly_impl { } } + #[test] + fn tagged_cache_metadata_reaches_sdk_from_send_begin_and_batch_without_ttl() { + let trace = NativeSdkTrace::new(Duration::ZERO); + for mode in [ + OutboundSendMode::Send, + OutboundSendMode::Begin, + OutboundSendMode::Batch, + ] { + let request = + OutboundRequest::get("https://origin.example.com/resource?signature=a%2Fb") + .expect("request") + .header("authorization", "fixture-signature") + .expect("signature") + .cache_policy(OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all example-reader-0123"), + )); + let dispatch = match mode { + OutboundSendMode::Send => testing::dispatch(request, trace.time()), + OutboundSendMode::Begin => testing::begin_dispatch(request, trace.time()), + OutboundSendMode::Batch => testing::batch_dispatch( + request, + trace.time(), + Deadline::after(Duration::from_secs(1)), + ), + OutboundSendMode::Follow => panic!("covered by redirect replay tests"), + }; + let (parts, context) = dispatch.into_parts(); + assert_eq!(context.mode(), mode); + let native = build_fastly_request( + &parts.method, + &parts.uri, + &parts.headers, + parts.cache_policy, + ); + assert!(native.get_header("surrogate-key").is_none()); + assert_eq!( + native + .get_header("authorization") + .expect("signature") + .as_bytes(), + b"fixture-signature" + ); + let rendered = format!("{native:?}"); + assert!(rendered.contains("cache_override: Override"), "{rendered}"); + assert!( + rendered.contains("example-all example-reader-0123"), + "{rendered}" + ); + assert!(rendered.contains("ttl: None"), "{rendered}"); + assert!( + rendered.contains("stale_while_revalidate: None"), + "{rendered}" + ); + assert!(rendered.contains("stale_if_error: None"), "{rendered}"); + assert!(!rendered.contains("cache_override: Pass"), "{rendered}"); + } + for (policy, expected) in [ + (OutboundCachePolicy::Bypass, "cache_override: Pass"), + (OutboundCachePolicy::PlatformDefault, "cache_override: None"), + ] { + let native = build_fastly_request( + &Method::HEAD, + &"https://origin.example.com/".parse().expect("URI"), + &HeaderMap::new(), + policy, + ); + assert!(format!("{native:?}").contains(expected)); + assert!(native.get_header("surrogate-key").is_none()); + } + } + fn sdk_cache_selection( mode: OutboundSendMode, - policy: OutboundCachePolicy, + policy: &OutboundCachePolicy, method: Method, ) { use edgezero_core::OutboundScopeOwner; @@ -2053,7 +2128,7 @@ mod fastly_impl { .expect("target"), ) .expect("request") - .cache_policy(policy) + .cache_policy(policy.clone()) .timeout(Duration::from_secs(1)); let dispatch = callback.with(|| match mode { OutboundSendMode::Batch => testing::batch_dispatch( @@ -2078,7 +2153,7 @@ mod fastly_impl { &prepared.parts.method, &prepared.parts.uri, &prepared.parts.headers, - policy, + policy.clone(), ); native.set_before_send(|_| Ok(())); native.set_body(Vec::::new()); @@ -2097,7 +2172,8 @@ mod fastly_impl { cause: FastlyFailureCause::HttpCacheApiUnsupported, }); match policy { - OutboundCachePolicy::PlatformDefault => { + OutboundCachePolicy::PlatformDefault + | OutboundCachePolicy::PlatformDefaultWithSurrogateKey(_) => { let error = outcome .expect("SDK rejects unsupported guest caching") .expect_err("cache API unavailable in pinned Viceroy"); @@ -2127,7 +2203,18 @@ mod fastly_impl { fn buffered_send_and_batch_preserve_sdk_cache_selection() { for mode in [OutboundSendMode::Send, OutboundSendMode::Batch] { for method in [Method::HEAD, Method::GET] { - sdk_cache_selection(mode, OutboundCachePolicy::PlatformDefault, method); + sdk_cache_selection( + mode, + &OutboundCachePolicy::PlatformDefault, + method.clone(), + ); + sdk_cache_selection( + mode, + &OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all"), + ), + method, + ); } } } @@ -2136,7 +2223,7 @@ mod fastly_impl { fn buffered_send_and_batch_bypass_unsupported_guest_cache_hooks() { for mode in [OutboundSendMode::Send, OutboundSendMode::Batch] { for method in [Method::HEAD, Method::GET] { - sdk_cache_selection(mode, OutboundCachePolicy::Bypass, method); + sdk_cache_selection(mode, &OutboundCachePolicy::Bypass, method); } } } @@ -4306,7 +4393,7 @@ mod fastly_impl { &plan.parts.method, &plan.parts.uri, &plan.parts.headers, - plan.parts.cache_policy, + plan.parts.cache_policy.clone(), ); assert_eq!(native.get_method(), &method); assert_eq!(native.get_url_str(), target); diff --git a/crates/edgezero-adapter-fastly/src/outbound/testing.rs b/crates/edgezero-adapter-fastly/src/outbound/testing.rs index c65dba14..be62b27d 100644 --- a/crates/edgezero-adapter-fastly/src/outbound/testing.rs +++ b/crates/edgezero-adapter-fastly/src/outbound/testing.rs @@ -1,5 +1,6 @@ //! Obtain opaque test inputs through the real facade, never by reconstructing core policies. +use futures_util::FutureExt as _; use futures_util::future::pending; use std::cell::RefCell; use std::iter::repeat_with; @@ -9,6 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Waker}; use std::time::Duration; +use edgezero_core::http::HeaderValue; use edgezero_core::{ Deadline, EdgeError, HttpClient, IndependentOutboundProgress, MonotonicClock, MonotonicInstant, OutboundBatch, OutboundBatchContext, OutboundDispatchRequest, OutboundExchangeOwner, @@ -25,6 +27,7 @@ std::thread_local! { enum CaptureMode { BatchContext, BatchRequest, + BeginRequest, Dispatch, } @@ -50,7 +53,10 @@ impl Capture { } impl OutboundHttpClient for Capture { - fn begin(&self, _: OutboundDispatchRequest) -> Result { + fn begin(&self, request: OutboundDispatchRequest) -> Result { + if matches!(self.mode, CaptureMode::BeginRequest) { + DISPATCH.with(|saved| saved.replace(Some(request))); + } Err(EdgeError::internal(anyhow::anyhow!( "capture fixture does not begin exchanges" ))) @@ -71,7 +77,9 @@ impl OutboundHttpClient for Capture { fn start_batch_until(&self, batch: OutboundPreparedBatch) -> OutboundBatch { let (context, mut requests) = batch.into_slots(); match self.mode { - CaptureMode::Dispatch => return OutboundBatch::cutoff(context), + CaptureMode::BeginRequest | CaptureMode::Dispatch => { + return OutboundBatch::cutoff(context); + } CaptureMode::BatchRequest => { assert_eq!(requests.len(), 1, "one captured request"); DISPATCH @@ -120,6 +128,20 @@ pub(super) fn dispatch(request: OutboundRequest, time: OutboundTime) -> Outbound }) } +pub(super) fn begin_dispatch( + request: OutboundRequest, + time: OutboundTime, +) -> OutboundDispatchRequest { + let client = HttpClient::with_client(Capture::new(time, CaptureMode::BeginRequest)); + assert!(matches!(client.begin(request).now_or_never(), Some(Err(_)))); + DISPATCH.with(|saved| { + saved + .borrow_mut() + .take() + .expect("facade prepared begun request") + }) +} + pub(crate) fn batch_context(slot_count: usize) -> OutboundBatchContext { batch_context_at(slot_count, instant(), Duration::from_secs(60)) } @@ -141,6 +163,24 @@ pub(super) fn batch_dispatch( }) } +#[test] +fn captured_begun_request_keeps_tag_metadata_and_mode() { + let request = OutboundRequest::get("https://origin.example.com/resource") + .expect("request") + .cache_policy( + edgezero_core::OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all"), + ), + ); + let captured = begin_dispatch(request, batch_context(1).time().clone()); + let (parts, context) = captured.into_parts(); + assert_eq!(context.mode(), OutboundSendMode::Begin); + assert!( + matches!(parts.cache_policy, edgezero_core::OutboundCachePolicy::PlatformDefaultWithSurrogateKey(value) if value.as_bytes() == b"example-all") + ); + assert!(!parts.headers.contains_key("surrogate-key")); +} + #[test] fn captured_batch_request_keeps_the_facade_mode_and_budget() { let start = instant(); diff --git a/crates/edgezero-adapter-spin/src/outbound.rs b/crates/edgezero-adapter-spin/src/outbound.rs index 4547c62a..46eee01b 100644 --- a/crates/edgezero-adapter-spin/src/outbound.rs +++ b/crates/edgezero-adapter-spin/src/outbound.rs @@ -1572,6 +1572,27 @@ mod exchange_tests { request_timeouts, run_exchange, settle_reset_content, should_emit_response_chunk, }; + #[cfg(feature = "test-utils")] + #[test] + fn tagged_default_is_an_accepted_cache_noop() { + use edgezero_core::http::HeaderValue; + let request = edgezero_core::OutboundRequest::get("https://origin.example.com/resource") + .expect("request") + .cache_policy( + edgezero_core::OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all"), + ), + ); + for mode in [ + edgezero_core::OutboundSendMode::Send, + edgezero_core::OutboundSendMode::Begin, + edgezero_core::OutboundSendMode::Batch, + ] { + super::validate_request(&request, mode).expect("metadata ignored on Spin"); + } + assert!(!request.headers().contains_key("surrogate-key")); + } + struct ScriptedFuture { drops: Rc>, polls: Rc>, diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs index def73b5d..5e097575 100644 --- a/crates/edgezero-core/src/outbound.rs +++ b/crates/edgezero-core/src/outbound.rs @@ -408,11 +408,22 @@ pub struct ResponseBodyPolicy { } /// Controls use of an adapter-managed intermediary cache for one request. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +/// +/// A surrogate key is mutually exclusive with bypass: Fastly's SDK setter clears +/// pass mode. Tagging does not override origin freshness or make private responses +/// cacheable. Adapters without surrogate-key metadata retain platform-default +/// behavior without emulating tagged caching or purge. +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum OutboundCachePolicy { Bypass, #[default] PlatformDefault, + /// Fastly cache metadata, not an outbound `Surrogate-Key` request header. + /// + /// The value may contain space-separated keys. Fastly ignores individual + /// keys outside printable ASCII 0x21..=0x7e; no additional key policy is imposed. + /// Image Optimizer cannot retain this metadata and rejects this variant. + PlatformDefaultWithSurrogateKey(HeaderValue), } #[derive(Clone)] @@ -1084,6 +1095,13 @@ impl OutboundRequest { self } + /// Borrows the request-local cache selection without cloning tag metadata. + #[must_use] + #[inline] + pub fn cache_policy_setting(&self) -> &OutboundCachePolicy { + &self.cache_policy + } + #[must_use] #[inline] pub fn cert_host(&self) -> Option<&str> { diff --git a/crates/edgezero-core/src/outbound/image_optimizer.rs b/crates/edgezero-core/src/outbound/image_optimizer.rs index e6f9aeda..c256ed50 100644 --- a/crates/edgezero-core/src/outbound/image_optimizer.rs +++ b/crates/edgezero-core/src/outbound/image_optimizer.rs @@ -256,6 +256,69 @@ mod tests { } } + #[test] + fn tagged_optimizer_rejection_never_polls_uploads_or_changes_signing() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + + use futures_util::stream::poll_fn; + + use crate::Body; + use crate::http::{HeaderValue, Method}; + use crate::outbound::{OutboundCachePolicy, OutboundSendMode, validate_for_dispatch}; + + let polls = Arc::new(AtomicUsize::new(0)); + let options = + OutboundImageOptimizerOptions::new().region(OutboundImageOptimizerRegion::Asia); + for mode in [ + OutboundSendMode::Send, + OutboundSendMode::Begin, + OutboundSendMode::Batch, + OutboundSendMode::Follow, + ] { + let body_polls = Arc::clone(&polls); + let request = OutboundRequest::get("https://origin.example.com/image?signature=a%2Fb") + .expect("request") + .header("authorization", "fixture-signature") + .expect("signature") + .cache_policy(OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all example-reader-0123"), + )) + .image_optimizer(options.clone()) + .body(Body::from_stream(poll_fn(move |_cx| { + body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }))); + let error = + validate_for_dispatch(&request, mode).expect_err("optimizer cannot retain tags"); + assert_eq!(error.status().as_u16(), 400); + assert_eq!(request.headers()["authorization"], "fixture-signature"); + assert_eq!(request.uri().query(), Some("signature=a%2Fb")); + } + assert_eq!(polls.load(Ordering::SeqCst), 0); + let signed = |method| { + OutboundRequest::new( + method, + "https://origin.example.com/image?signature=a%2Fb" + .parse() + .expect("URI"), + ) + .expect("request") + .header("authorization", "fixture-signature") + .expect("signature") + }; + let bypass_head = signed(Method::HEAD).cache_policy(OutboundCachePolicy::Bypass); + validate_for_dispatch(&bypass_head, OutboundSendMode::Send).expect("signed bypass HEAD"); + let optimized_get = signed(Method::GET).image_optimizer(options); + validate_for_dispatch(&optimized_get, OutboundSendMode::Send) + .expect("signed default optimized GET"); + assert_eq!( + optimized_get.cache_policy_setting(), + &OutboundCachePolicy::PlatformDefault + ); + } + #[test] fn image_optimizer_defaults_leave_every_selector_unset() { let options = OutboundImageOptimizerOptions::new(); diff --git a/crates/edgezero-core/src/outbound/redirect.rs b/crates/edgezero-core/src/outbound/redirect.rs index 0db3d366..07634203 100644 --- a/crates/edgezero-core/src/outbound/redirect.rs +++ b/crates/edgezero-core/src/outbound/redirect.rs @@ -448,7 +448,7 @@ fn replay_request(request: &OutboundRequest) -> (OutboundRequest, bool) { ( OutboundRequest { body: replayed_body, - cache_policy: *cache_policy, + cache_policy: cache_policy.clone(), connect_timeout: *connect_timeout, deadline: *deadline, headers: headers.clone(), diff --git a/crates/edgezero-core/src/outbound/redirect_tests.rs b/crates/edgezero-core/src/outbound/redirect_tests.rs index 2083490c..9c029cae 100644 --- a/crates/edgezero-core/src/outbound/redirect_tests.rs +++ b/crates/edgezero-core/src/outbound/redirect_tests.rs @@ -1428,6 +1428,57 @@ fn policy_authorized_cross_origin_credentials_are_not_stripped_again() { ); } +#[test] +fn redirect_replay_preserves_tagged_default_and_bypass_without_a_wire_tag() { + for policy in [ + OutboundCachePolicy::Bypass, + OutboundCachePolicy::PlatformDefaultWithSurrogateKey(HeaderValue::from_static( + "example-all example-reader-0123", + )), + ] { + let fixture = Fixture::new([ + Reply::bytes(StatusCode::FOUND, b"") + .location("/next") + .step(), + Reply::bytes(StatusCode::OK, b"done").step(), + ]); + let initial = OutboundRequest::get("https://origin.example.com/start?signature=a%2Fb") + .expect("request") + .header("authorization", "fixture-signature") + .expect("signature") + .host_authority_override("virtual.example.com:8443") + .expect("authority") + .cache_policy(policy.clone()); + success(block_on(fixture.client.follow( + initial, + limit(2), + |context| { + Ok::<_, ()>(OutboundRedirectAction::Follow( + context.take_prepared_request().expect("prepared request"), + )) + }, + ))); + let recording = fixture.state.borrow(); + assert_eq!(recording.records.len(), 2); + for record in &recording.records { + assert_eq!(record.parts.cache_policy, policy); + assert_eq!(record.parts.uri.scheme_str(), Some("https")); + assert_eq!(record.parts.uri.host(), Some("origin.example.com")); + assert_eq!(record.parts.headers["authorization"], "fixture-signature"); + assert!(!record.parts.headers.contains_key("surrogate-key")); + assert_eq!( + record + .parts + .host_authority_override + .as_ref() + .expect("authority") + .as_str(), + "virtual.example.com:8443" + ); + } + } +} + fn configured_request(time: &ManualTime) -> OutboundRequest { request(Method::GET) .timeout(Duration::from_millis(120)) diff --git a/crates/edgezero-core/tests/outbound_prepared_client.rs b/crates/edgezero-core/tests/outbound_prepared_client.rs index 22936021..32fd3e1b 100644 --- a/crates/edgezero-core/tests/outbound_prepared_client.rs +++ b/crates/edgezero-core/tests/outbound_prepared_client.rs @@ -9,11 +9,11 @@ mod tests { use std::task::{Context, Poll, Waker}; use std::time::Duration; - use edgezero_core::http::{HeaderMap, StatusCode, Uri}; + use edgezero_core::http::{HeaderMap, HeaderValue, StatusCode, Uri}; use edgezero_core::{ BudgetSource, Deadline, DispatchBudget, EdgeError, HttpClient, MonotonicClock, MonotonicInstant, OutboundBatch, OutboundBatchCheck, OutboundBatchDriverEvent, - OutboundBatchNext, OutboundBatchTermination, OutboundCollectedSlot, + OutboundBatchNext, OutboundBatchTermination, OutboundCachePolicy, OutboundCollectedSlot, OutboundDispatchRequest, OutboundExchangeOwner, OutboundFuture, OutboundHead, OutboundHeaderFidelity, OutboundHeaderInput, OutboundHttpClient, OutboundPending, OutboundPreparedBatch, OutboundProxyMarker, OutboundRequest, OutboundResponse, @@ -21,6 +21,7 @@ mod tests { }; use futures::executor::block_on; use futures_util::future::ready; + use futures_util::stream::poll_fn; use futures_util::task::{ArcWake, waker_ref}; use futures_util::{FutureExt as _, StreamExt as _, stream}; @@ -55,9 +56,12 @@ mod tests { } struct RequestRecord { + body: Option>, budget: DispatchBudget, + cache_policy: OutboundCachePolicy, connect_timeout: Option, entered_at: MonotonicInstant, + headers: HeaderMap, mode: OutboundSendMode, uri: Uri, } @@ -164,7 +168,10 @@ mod tests { ))); } let record = RequestRecord { + body: parts.body.as_bytes().map(<[u8]>::to_vec), budget, + cache_policy: parts.cache_policy, + headers: parts.headers, connect_timeout, entered_at, mode: context.mode(), @@ -393,6 +400,81 @@ mod tests { } } + #[test] + fn tagged_metadata_survives_send_begin_and_batch_preparation() { + let fixture = Fixture::new(); + let client = fixture.client(false); + let policy = OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all example-reader-0123"), + ); + let request = || { + OutboundRequest::post("https://origin.example.com/resource?signature=literal%2Fvalue") + .expect("request") + .header("authorization", "fixture-signature") + .expect("signature") + .body("payload") + .cache_policy(policy.clone()) + }; + drop(block_on(client.send(request())).expect("send")); + let pending = block_on(client.begin(request())).expect("begin"); + drop(block_on(pending.collect()).expect("begun response")); + let cutoff = fixture.clock.clock().deadline_after(Duration::from_secs(1)); + drop(block_on(client.send_all_until(vec![request()], cutoff)).expect("batch")); + let recording = fixture.state.lock().expect("recording"); + assert_eq!(recording.requests.len(), 3); + for (record, mode) in recording.requests.iter().zip([ + OutboundSendMode::Send, + OutboundSendMode::Begin, + OutboundSendMode::Batch, + ]) { + assert_eq!(record.mode, mode); + assert_eq!(record.cache_policy, policy); + assert_eq!(record.headers["authorization"], "fixture-signature"); + assert!(!record.headers.contains_key("surrogate-key")); + assert_eq!(record.body.as_deref(), Some(b"payload".as_slice())); + assert_eq!(record.uri.scheme_str(), Some("https")); + assert_eq!( + record.uri.authority().expect("authority").as_str(), + "origin.example.com" + ); + assert_eq!(record.uri.query(), Some("signature=literal%2Fvalue")); + } + assert_eq!(recording.exchanges_released, 3); + } + + #[test] + fn tagged_optimizer_preflight_has_no_upload_or_provider_io_in_any_mode() { + let fixture = Fixture::new(); + let client = fixture.client(false); + let polls = Arc::new(AtomicUsize::new(0)); + let request = || { + let observed = Arc::clone(&polls); + OutboundRequest::get("https://origin.example.com/image") + .expect("request") + .cache_policy(OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all"), + )) + .image_optimizer( + edgezero_core::OutboundImageOptimizerOptions::new() + .region(edgezero_core::OutboundImageOptimizerRegion::Asia), + ) + .body(edgezero_core::Body::from_stream(poll_fn(move |_cx| { + observed.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }))) + }; + block_on(client.send(request())).expect_err("tagged optimizer send"); + block_on(client.begin(request())).expect_err("tagged optimizer begin"); + let cutoff = fixture.clock.clock().deadline_after(Duration::from_secs(1)); + let batch = + block_on(client.send_all_until(vec![request()], cutoff)).expect("batch outcomes"); + assert!( + matches!(&batch.slots()[0], OutboundCollectedSlot::Resolved(slot) if slot.outcome.is_err()) + ); + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert!(fixture.state.lock().expect("recording").requests.is_empty()); + } + #[test] fn batch_producer_uses_prepared_context() { for shared_constructor in [false, true] { diff --git a/crates/edgezero-core/tests/outbound_public_api.rs b/crates/edgezero-core/tests/outbound_public_api.rs index 1da10f69..425639d4 100644 --- a/crates/edgezero-core/tests/outbound_public_api.rs +++ b/crates/edgezero-core/tests/outbound_public_api.rs @@ -1,8 +1,72 @@ +use edgezero_core::http::HeaderValue; use edgezero_core::{ - OutboundCropFocus, OutboundImageFormat, OutboundImageOptimizerOptions, + OutboundCachePolicy, OutboundCropFocus, OutboundImageFormat, OutboundImageOptimizerOptions, OutboundImageOptimizerRegion, OutboundRequest, OutboundResizeFilter, }; +#[test] +#[expect( + clippy::tests_outside_test_module, + reason = "external supplied-client API integration test" +)] +fn tagged_default_cache_policy_round_trips_and_replaces_bypass() { + let tagged = OutboundCachePolicy::PlatformDefaultWithSurrogateKey(HeaderValue::from_static( + "example-all example-reader-0123", + )); + let request = OutboundRequest::get("https://example.com/resource?signature=literal%2Fvalue") + .expect("request") + .cache_policy(OutboundCachePolicy::Bypass) + .cache_policy(tagged.clone()); + let policy = request.cache_policy_setting(); + assert_eq!(policy, &tagged); + assert!(!request.headers().contains_key("surrogate-key")); + let parts = request.into_parts(); + assert_eq!(parts.cache_policy, tagged); + let restored = OutboundRequest::from_parts(parts).expect("parts"); + assert_eq!(restored.cache_policy_setting(), &tagged); + assert_eq!( + restored.uri().to_string(), + "https://example.com/resource?signature=literal%2Fvalue" + ); + let bypass = restored.cache_policy(OutboundCachePolicy::Bypass); + assert_eq!(bypass.cache_policy_setting(), &OutboundCachePolicy::Bypass); + let mut bypass_parts = bypass.into_parts(); + bypass_parts.cache_policy = tagged.clone(); + let tagged_again = OutboundRequest::from_parts(bypass_parts).expect("tagged parts"); + assert_eq!(tagged_again.cache_policy_setting(), &tagged); + let cleared = tagged_again.cache_policy(OutboundCachePolicy::PlatformDefault); + assert_eq!( + cleared.cache_policy_setting(), + &OutboundCachePolicy::PlatformDefault + ); +} + +#[test] +#[expect( + clippy::tests_outside_test_module, + reason = "external supplied-client API integration test" +)] +fn tag_metadata_adds_no_ascii_policy_beyond_header_value() { + use edgezero_core::OutboundSendMode; + use edgezero_core::outbound::validate_for_dispatch; + + HeaderValue::from_bytes(b"bad\nkey").expect_err("caller must reject an invalid header"); + let value = HeaderValue::from_bytes(b"example-all opaque-\xff").expect("valid opaque header"); + let policy = OutboundCachePolicy::PlatformDefaultWithSurrogateKey(value); + let request = OutboundRequest::get("https://origin.example.com/resource") + .expect("request") + .cache_policy(policy.clone()); + for mode in [ + OutboundSendMode::Send, + OutboundSendMode::Begin, + OutboundSendMode::Batch, + OutboundSendMode::Follow, + ] { + validate_for_dispatch(&request, mode).expect("SDK owns individual key interpretation"); + } + assert_eq!(request.cache_policy_setting(), &policy); +} + #[test] #[expect( clippy::tests_outside_test_module, diff --git a/docs/guide/proxying.md b/docs/guide/proxying.md index 84db0a63..005854fd 100644 --- a/docs/guide/proxying.md +++ b/docs/guide/proxying.md @@ -134,6 +134,43 @@ boilerplate. A closed canonical scope never falls back to caller-owned work. Exi caps, source errors and provider-side cancellation guarantees are unchanged; hosted qualification remains separate. +## Request-local cache metadata + +`OutboundCachePolicy::PlatformDefaultWithSurrogateKey(HeaderValue)` preserves Fastly +surrogate-key purge metadata while leaving origin freshness unchanged. The value can contain +space-separated keys, such as an all-scope key and a URL-specific key. It is SDK cache metadata, +not an origin-visible `Surrogate-Key` request header. + +```rust +use edgezero_core::http::HeaderValue; +use edgezero_core::{OutboundCachePolicy, OutboundRequest}; + +let request = OutboundRequest::get("https://origin.example.com/article")? + .cache_policy(OutboundCachePolicy::PlatformDefaultWithSurrogateKey( + HeaderValue::from_static("example-all example-reader-0123"), + )); +let policy = request.cache_policy_setting(); +``` + +Fastly extends origin response surrogate keys without a TTL/stale override. Origin +`private`/`no-store` remains origin policy; tags are not permission to cache a private response. +Fastly ignores invalid individual keys outside printable ASCII `0x21..=0x7e`; this API adds no +key-size/count or extra ASCII policy. Parse application strings into `HeaderValue` at their +boundary. A caller whose purge contract requires a tag must fail closed, such as selecting +`Bypass`, if that conversion fails rather than silently sending an untagged default request. + +The enum cannot combine a tag with `Bypass`, because Fastly's tag setter clears pass mode. +Replacing a cache policy replaces that whole selection. The enum is `Clone`, not `Copy`; +`cache_policy_setting()` borrows it and parts/redirect replay retain it explicitly. +Axum and Spin ignore the tag without changing their transport. Cloudflare retains default +Fetch caching and does not emulate Fastly purge; its `Bypass` mapping remains `NoStore`. +Existing capability declarations are unchanged. + +Image Optimizer still requires exactly `PlatformDefault`. SDK 0.13.1 overrides custom cache +metadata on that path, so tagged-default plus optimizer is rejected before upload polling or +provider I/O, not accepted with a silently discarded purge tag. Signed HEAD bypass and a +separate signed optimized GET retain their independent existing policies. + ## Following Redirects `HttpClient::follow` is an explicit per-call choice with a mandatory target policy and hop limit. diff --git a/docs/superpowers/specs/2026-05-21-outbound-http-design.md b/docs/superpowers/specs/2026-05-21-outbound-http-design.md index 4e3ec84f..48f029ee 100644 --- a/docs/superpowers/specs/2026-05-21-outbound-http-design.md +++ b/docs/superpowers/specs/2026-05-21-outbound-http-design.md @@ -596,7 +596,7 @@ impl RequestContext { ```rust pub struct OutboundRequest { body: Body, // buffered or streamed - cache_policy: OutboundCachePolicy, // PlatformDefault (default) | Bypass + cache_policy: OutboundCachePolicy, // PlatformDefault (default) | Bypass | PlatformDefaultWithSurrogateKey deadline: Option, // shared absolute cap; copy one value into every target request, do not recompute per request (see ยง3.3.2) headers: HeaderMap, host_authority_override: Option, // validated wire authority only @@ -614,11 +614,12 @@ pub struct OutboundRequest { uri: Uri, // validated + canonicalized; see below } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum OutboundCachePolicy { + Bypass, #[default] PlatformDefault, - Bypass, + PlatformDefaultWithSurrogateKey(HeaderValue), } // **All OUTBOUND public byte caps and byte-accounting counters are `u64`, NOT `usize`.** @@ -813,6 +814,7 @@ impl OutboundRequest { pub fn timeout(self, duration: Duration) -> Self; pub fn deadline(self, deadline: Deadline) -> Self; pub fn cache_policy(self, policy: OutboundCachePolicy) -> Self; + pub fn cache_policy_setting(&self) -> &OutboundCachePolicy; /// Sets a validated DNS name, IPv4 address, or bracketed IPv6 literal with an optional /// port. Schemes, userinfo, paths, queries, fragments, whitespace, controls, malformed /// brackets, and invalid ports are rejected as `bad_request`. @@ -1157,7 +1159,19 @@ adapter-managed intermediary response cache. It does not synthesize `cache-contr origin-visible request header. Fastly maps it to request pass; Cloudflare maps it to Fetch `NoStore`; Axum and Spin have no adapter-managed intermediary response cache, so the requested semantics are already satisfied without transport mutation. Redirect hops are new requests and -must copy the policy deliberately. +must clone the policy deliberately. + +`PlatformDefaultWithSurrogateKey(HeaderValue)` adds request-local Fastly SDK cache metadata, +not an outbound request header or TTL/freshness override. It extends origin response surrogate +keys without making private/no-store responses cacheable. The typed enum cannot also select +Bypass, whose pass mode would be cleared by Fastly's tag setter. Space-separated keys are +permitted; SDK 0.13.1 ignores invalid individual keys outside printable ASCII 0x21..=0x7e. +No extra key-size/count/ASCII restriction is added. Callers preserve their own fail-closed +policy if a string cannot become HeaderValue. Axum/Spin ignore this metadata and Cloudflare +keeps platform-default caching without purge emulation; other capabilities remain unchanged. +Image Optimizer still requires exactly PlatformDefault because the pinned SDK overrides tags +on its optimizer path. Tagged+optimizer fails preflight before body polling/provider I/O. +The enum loses Copy; the borrow-returning cache_policy_setting getter avoids readback clones. **Authority separation.** The canonical URI remains the only source for DNS/backend target, TLS SNI, and certificate verification. `host_authority_override` controls only HTTP `Host` or From 683d5584a626b290897b63ea2b8f268306d7ad71 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 13:16:36 -0600 Subject: [PATCH 3/4] ci: run ordinary checks on every pull request base --- .github/workflows/codeql.yml | 1 - .github/workflows/format.yml | 1 - .github/workflows/test.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 66288800..6d7be765 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,7 +15,6 @@ on: push: branches: ["main"] pull_request: - branches: ["main"] schedule: - cron: "40 17 * * 3" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index c2bbc375..cd2164ad 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5fc52126..e115e2d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read From cec19886598c61e944e861c0515bb0f3ff4bb800 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 14:21:46 -0600 Subject: [PATCH 4/4] test(ci): require ordinary checks on stacked PR bases --- scripts/ci_tooling.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci_tooling.test.mjs b/scripts/ci_tooling.test.mjs index 928e2a35..3ee1035e 100644 --- a/scripts/ci_tooling.test.mjs +++ b/scripts/ci_tooling.test.mjs @@ -53,7 +53,7 @@ const yamlContractName = test(yamlContractName, () => { assert.deepEqual(tests.on, { push: { branches: ["main"] }, - pull_request: { branches: ["main"] }, + pull_request: null, }); assert.equal(host.strategy["fail-fast"], false); const checkout = host.steps.find(