yah-cloud 0.8.46

Declarative cloud substrate for yah-managed camps: .yah/cloud/ config schema, MachineProvider drivers (Hetzner + local containerd), cloud-init rendering, and the pond/mesofact reconcilers.
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
//! R2 publish step — upload a built `dist/` tree to Cloudflare R2 via the
//! S3-compatible API, then optionally purge the CDN cache tags.
//!
//! ## Credentials
//!
//! Two distinct credential types:
//! - **R2 S3 keys** (`cloudflare-r2-access-key-id` / `cloudflare-r2-secret-key`)
//!   — sourced from CF Dashboard → R2 → Manage API tokens → S3 keys.
//!   Env fallbacks: `CF_R2_ACCESS_KEY_ID` / `CF_R2_SECRET_KEY`.
//! - **API token** (`cloudflare-api-token`) — only needed for the CDN cache-tag
//!   purge step; publish itself uses the S3 keys only. Omit purge opts to skip.
//!
//! ## Key layout
//!
//! Mirrors the pond flat layout: `html/` prefix is stripped, everything
//! else passes through unchanged. Production atomic-swap (`/<build_id>/…`
//! prefix + manifest pointer swap) is deferred to a follow-up.
//!
//! @yah:ticket(R320-F6, "R2 S3 credential slots in keystore (access-key-id + secret)")
//! @yah:at(2026-05-26T00:42:35Z)
//! @yah:status(review)
//! @yah:assignee(agent:claude)
//! @yah:phase(P2)
//! @yah:parent(R320)
//! @yah:handoff("R2 S3 credential slots defined in r2_publish.rs: R2_ACCESS_KEY_SLOT=cloudflare-r2-access-key-id, R2_SECRET_KEY_SLOT=cloudflare-r2-secret-key, R2_ACCESS_KEY_ENV=CF_R2_ACCESS_KEY_ID, R2_SECRET_KEY_ENV=CF_R2_SECRET_KEY. Consumed by up_cloudflare_r2 in mesofact_static.rs via keys::get_or_env. No keystore UI registration yet (yah keys set works for CLI; Settings→API Keys in desktop is out of scope for this relay).")
//! F7 (R2 publish) and F9 (CDN purge) are annotated in pond_publish.rs.
//!
//!
//! @yah:ticket(R320-F13, "R2 incremental upload: skip unchanged objects instead of full re-PUT every publish")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-26T16:33:51Z)
//! @yah:status(review)
//! @yah:parent(R320)
//! @yah:next("publish_to_r2 currently PUTs every file unconditionally (r2_publish.rs:113) — no skip for unchanged objects. Add a per-object guard: HEAD the key (or list the bucket once) and compare against a stored content hash before PUT.")
//! @yah:next("This is the prerequisite for cadence-driven publish (almanac) and CI publish (qed): without it every sync full-re-uploads dist/ and needlessly churns CDN cache tags.")
//! @yah:next("Decide v1 mechanism: incremental per-object PUT-skip vs the deferred build-id atomic-swap (/<build_id>/ prefix + manifest pointer) noted in the r2_publish.rs module header.")
//! @yah:gotcha("R2 ETag for a non-multipart PUT is the MD5 hex of the body, not sha256 — don't compare it against the existing sha256_hex. Either compare MD5, or store a content hash in object metadata at upload time and compare that.")
//! @yah:assumes("The Worker deploy is already hash-gated (worker-script-hashes.json); only the R2 object upload lacks an idempotency guard.")
//! @yah:handoff("Incremental upload implemented via a manifest sidecar file (_yah-manifest.json stored in the R2 bucket). Each publish run loads the manifest, computes sha256 of each local file, skips PUT if hash matches, then writes an updated manifest back. Avoids XML/ETag complexity entirely — manifest stores sha256 (not MD5), so the ETag gotcha is irrelevant.")
//! @yah:verify("cargo test -p cloud --lib reconciler::r2_publish -- 2>&1 | grep -E 'ok|FAILED'")
//! @yah:verify("Run `yah cloud mirror up dev-yah --env prod` twice in succession — second run should log `skipped=N uploaded=0` (after first run populates the manifest)")
//!
//! @yah:ticket(R498-F5, "refactor r2_publish::publish_to_r2 onto R2ObjectStore")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-06-09T03:29:43Z)
//! @yah:status(review)
//! @yah:parent(R498)
//! @yah:next("Replace inline sign_s3_put_object loop in publish_to_r2 with R2ObjectStore::put calls")
//! @yah:next("Manifest sidecar logic (_yah-manifest.json) preserved — load/save still happens here, just goes through object-store put/get")
//! @yah:next("Delete now-unused direct s3_sign imports from r2_publish.rs once R2ObjectStore wraps them")
//! @yah:next("Parallel-friendly: independent of F3/T4/F6, only depends on F2")
//! @yah:verify("cargo test -p cloud --lib reconciler::r2_publish ok")
//! @yah:verify("Round-trip yah cloud mirror up dev-yah --env prod twice: second run logs skipped=N uploaded=0")
//! @yah:depends_on(R498-F2)
//! @yah:tier(Cleric)
//!
//! @yah:ticket(R703-T10, "mesofact static publish still uploads every site file with no Cache-Control — classify hashed assets vs HTML")
//! @yah:status(review)
//! @yah:at(2026-08-09T20:09:04Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:parent(R703)
//! @yah:next("The upload loop in publish_to_r2 (the spawn_blocking s.put near r2_publish.rs:220) still calls plain ObjectStore::put, so every HTML page and asset in a mesofact static publish lands in R2 with no Cache-Control. R703-B8 built the seam (ObjectStore::put_cached plus CACHE_CONTROL_IMMUTABLE / CACHE_CONTROL_NO_CACHE) and used it for the publish beacon only.")
//! @yah:next("Missing piece is the classification rule, which is why B8 stopped short: an .html at a fixed route is no-cache, a content-hashed asset is immutable, everything else needs a defensible default. Get HTML wrong and yah.dev serves a stale page from browser caches with no way to bust it. Decide against what the mesofact builder actually emits (hashed filenames or not), do not assume.")
//! @yah:next("MANIFEST_KEY (_yah-manifest.json) is also a mutable pointer written by plain put, but nothing outside the reconciler fetches it, so it is cosmetic - fold it in while here.")
//! @yah:gotcha("Not urgent: cdn.yah.dev answers cf-cache-status DYNAMIC on this zone today, so Cloudflare's edge is not caching these objects regardless. The exposure is browser caches and any future edge-cache rule.")
//! @yah:handoff("Every object a mesofact static publish uploads now carries a Cache-Control. The rule is one function, cache_control_for(rel) in r2_publish.rs, and it is grounded rather than guessed: dist/hydrate/ is the ONLY output the builder content-hashes, via the <key>.[hash].js / <key>.chunk-[hash].js naming in mesofact-build's bundle.rs and its TS twin. Those get immutable; every other key - the prerendered .html pages, install.sh, illustrations/*.webp, manifest.json, tag-index.json - is a fixed key the next publish rewrites in place, so they get no-cache.")
//! @yah:handoff("The directory alone does not earn immutable: the filename must also carry a hash-shaped trailing segment (8+ base62 before the .js). A hand-dropped hydrate/app.js is not content-addressed and a year-long immutable cache on it would be unrecoverable. Unknown falls to no-cache in every branch - guessing 'cacheable' fails much worse than guessing 'not'.")
//! @yah:handoff("MANIFEST_KEY (_yah-manifest.json) folded in as no-cache too. Cosmetic, since nothing outside the reconciler fetches it, but leaving one directive-less mutable pointer behind invites the next reader to copy the pattern.")
//! @yah:handoff("I overstated this ticket's uncertainty when I filed it an hour ago. 'Needs a rule, not a guess' implied an operator call; it was a fact readable from the four built dist trees and the bundler's naming config.")
//! @yah:verify("cargo test -p yah-cloud --lib r2_publish -> 10 pass, 0 fail (3 new: hydrate bundles immutable, fixed keys no-cache, non-hash-named hydrate files stay no-cache)")
//! @yah:verify("cargo test -p yah-cloud --lib -> 744 pass, 0 fail (was 741)")
//! @yah:verify("cargo check --workspace clean on root and oss/yubaba")
//! @yah:verify("Classifier test inputs are REAL filenames read from the built dist trees on 2026-08-09 - hydrate/issues.ervzmehh.js and hydrate/rigs_id.uxTuqN9d.js from marketing/dashboard, plus index.html, install.sh, illustrations/yah-table-dark.webp, manifest.json, tag-index.json - not invented ones")

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use tracing::{debug, info};
use yah_mesofact_bundle::assets::OutputPolicy;
use yah_object_store::{ObjectStore as _, R2ObjectStore};

use super::publish_beacon::{PublishBeacon, BEACON_KEY};
use crate::provider::cloudflare::CloudflareClient;

/// Bucket key for the per-bucket publish manifest. Starts with `_` to avoid
/// collisions with typical dist/ output (index.html, assets/*, etc.).
const MANIFEST_KEY: &str = "_yah-manifest.json";

/// Map of `bucket_key → sha256_hex` written to R2 after each successful
/// publish. Loaded at the start of the next run to skip unchanged objects.
type PublishManifest = HashMap<String, String>;

// Re-export credential constants from the object-store crate so callers
// that import them from this module continue to compile unchanged.
pub use yah_object_store::r2::{
    R2_ACCESS_KEY_ENV, R2_ACCESS_KEY_SLOT, R2_SECRET_KEY_ENV, R2_SECRET_KEY_SLOT,
};

/// `Cache-Control` for one published object, keyed off the dist-relative path
/// (R703-T10 — the rest of R703-B8's seam).
///
/// Only ONE class of file in a mesofact dist is safe to cache forever, and it
/// is not guessable from the extension: `dist/hydrate/` is the sole output the
/// builder content-hashes, via the `<key>.[hash].js` / `<key>.chunk-[hash].js`
/// naming in `mesofact-build`'s `bundle.rs` (and its TS twin). Everything else
/// — the prerendered `.html` pages, `install.sh`, the `illustrations/*.webp`,
/// `manifest.json`, `tag-index.json` — sits at a FIXED key that the next
/// publish rewrites in place, so an immutable directive there is a promise the
/// bytes cannot keep: yah.dev would serve a stale page out of browser caches
/// with no way to bust it short of renaming the route.
///
/// The hash-shaped-filename check is belt and braces on top of the directory:
/// a plain `hydrate/app.js` (someone bypassing the bundler, a hand-dropped
/// polyfill) is NOT content-addressed, and a year-long immutable cache on it
/// would be unrecoverable. Unknown ⇒ `no-cache`, always — the failure mode of
/// guessing "cacheable" is much worse than the failure mode of guessing "not".
///
/// MFT-R825-F1: the rule itself now lives in
/// [`yah_mesofact_bundle::assets::OutputPolicy`], shared with the bundle path
/// that `mesofact serve` answers from, and it gained the component's DECLARED
/// patterns (`[build] immutable` in its `mesofact.config.toml`) beside the
/// hydrate rule above. One rule, two publish paths; this function only maps
/// its verdict to the object-store directives. `rel` is relative to the
/// publish's `dist_dir`, BEFORE the `html/` strip — the policy needs the
/// prefix to tell a page from a hydrate bundle.
fn cache_control_for(rel: &str, policy: &OutputPolicy) -> &'static str {
    if policy.is_immutable(rel) {
        yah_object_store::CACHE_CONTROL_IMMUTABLE
    } else {
        yah_object_store::CACHE_CONTROL_NO_CACHE
    }
}

/// Options for the CDN cache-tag purge step after upload.
/// Omit to skip purge (publish only, no CDN invalidation).
pub struct R2PurgeOpts {
    /// Cloudflare zone name (e.g. `"yah.dev"`). Resolved to a zone ID
    /// via the management API before calling purge.
    pub zone_name: String,
    /// Cloudflare API token with `Zone: Cache Purge` scope.
    pub api_token: String,
}

/// Summary of a completed R2 publish.
///
/// Not `Default`: a report with an empty [`PublishBeacon`] would describe a
/// publish that never happened, and the beacon's whole job is to be
/// impossible to confuse with one.
#[derive(Debug, Clone)]
pub struct R2PublishReport {
    /// Object keys successfully uploaded to R2.
    pub uploaded: Vec<String>,
    /// Object keys skipped because their sha256 matched the stored manifest.
    pub skipped: Vec<String>,
    /// Cache tags purged from the CDN after upload. Empty when no purge opts
    /// were provided or when `dist/tag-index.json` has no tags.
    pub purged_tags: Vec<String>,
    /// R703-B4 — what this publish claims it put in the bucket, written to
    /// `<prefix>/.well-known/yah-publish.json` as the *last* object of the
    /// run. Fetch it back through a front door to find out whether that front
    /// door is serving this publish or something older; see
    /// [`super::publish_beacon`].
    pub beacon: PublishBeacon,
}

/// Upload every file in `dist_dir` to `<bucket>/` in the given R2 account,
/// then optionally purge CDN cache tags.
///
/// Credentials are the R2 S3 access key + secret (distinct from the
/// Cloudflare API token used for management operations). Obtain them from
/// CF Dashboard → R2 → Manage API tokens → S3 API Tokens.
/// Upload every file in `dist_dir` to `<bucket>/` in the given R2 account.
///
/// `key_prefix`: when `Some`, all keys are prefixed with `<prefix>/`. Use
/// `"<service>/<env>"` to isolate two mirrors that share the same R2 bucket.
///
/// `policy` decides each object's `Cache-Control` ([`cache_control_for`]);
/// pass [`OutputPolicy::load`] for a component's build output, or
/// `OutputPolicy::default()` for a tree no component declared anything about
/// (everything revalidates). Files a bundle assembly's finalize step wrote
/// into the tree — the asset index and `.br`/`.gz` variants — are skipped:
/// Cloudflare negotiates encodings at its own edge, and uploading them would
/// only publish raw compressed bytes at extra URLs.
#[allow(clippy::too_many_arguments)]
pub async fn publish_to_r2(
    dist_dir: &Path,
    account_id: &str,
    bucket: &str,
    access_key: &str,
    secret_key: &str,
    key_prefix: Option<&str>,
    purge: Option<R2PurgeOpts>,
    policy: &OutputPolicy,
) -> Result<R2PublishReport> {
    if !tokio::fs::try_exists(dist_dir)
        .await
        .with_context(|| format!("checking {}", dist_dir.display()))?
    {
        anyhow::bail!("dist_dir not found: {}", dist_dir.display());
    }

    // R2ObjectStore holds a reqwest::blocking::Client whose internal runtime
    // panics if constructed (or dropped) inside an async context — off-load
    // both endpoints to a blocking worker thread. Every method call below is
    // already spawn_blocking-wrapped; matching Drop is handled in the store's
    // impl.
    let account_id_owned = account_id.to_string();
    let bucket_owned = bucket.to_string();
    let access_key_owned = access_key.to_string();
    let secret_key_owned = secret_key.to_string();
    let store = Arc::new(
        tokio::task::spawn_blocking(move || {
            R2ObjectStore::new(
                account_id_owned,
                bucket_owned,
                access_key_owned,
                secret_key_owned,
            )
        })
        .await
        .context("R2ObjectStore construction task panicked")?
        .context("building R2ObjectStore")?,
    );

    // Load manifest non-fatally: empty map on first run or any transient error.
    let manifest: PublishManifest = {
        let s = Arc::clone(&store);
        tokio::task::spawn_blocking(move || s.get(MANIFEST_KEY))
            .await
            .context("manifest GET task panicked")?
            .ok()
            .flatten()
            .and_then(|b| serde_json::from_slice(&b).ok())
            .unwrap_or_default()
    };
    let mut new_manifest = PublishManifest::new();

    let files = walk_files(dist_dir).await.context("walking dist_dir")?;
    info!(
        bucket,
        account_id,
        files = files.len(),
        prior_tracked = manifest.len(),
        "publishing dist/ to R2"
    );

    let mut uploaded = Vec::new();
    let mut skipped = Vec::new();

    for path in &files {
        let rel = path
            .strip_prefix(dist_dir)
            .expect("walker yields paths under root");
        let rel_str = rel.to_string_lossy().replace('\\', "/");
        // server/ contains SSR bundles — not part of a static R2 deploy.
        if rel_str.starts_with("server/") {
            continue;
        }
        // Bundle-path serving metadata (MFT-R825-F1) — see the fn docs.
        let parent_dir = rel.parent().map(|p| dist_dir.join(p)).unwrap_or_else(|| dist_dir.to_path_buf());
        let file_name = rel.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
        if yah_mesofact_bundle::assets::is_finalize_artifact(&parent_dir, &file_name) {
            continue;
        }
        // Strip `html/` prefix; optionally prepend mirror key_prefix.
        let stripped = rel_str.strip_prefix("html/").unwrap_or(&rel_str);
        let key = match key_prefix {
            Some(p) => format!("{}/{}", p.trim_end_matches('/'), stripped),
            None => stripped.to_string(),
        };

        let body = tokio::fs::read(path)
            .await
            .with_context(|| format!("reading {}", path.display()))?;
        let body_hash = sha256_hex(&body);

        // Skip PUT when the stored manifest records an identical sha256.
        if manifest.get(&key).map(|s| s.as_str()) == Some(body_hash.as_str()) {
            debug!(key, "skipped (unchanged)");
            new_manifest.insert(key.clone(), body_hash);
            skipped.push(key);
            continue;
        }

        let s = Arc::clone(&store);
        let key_clone = key.clone();
        let cc = cache_control_for(&rel_str, policy);
        tokio::task::spawn_blocking(move || {
            // A site asset is fetched by a page, never saved by hand — no
            // download name.
            s.put_with(&key_clone, body, &yah_object_store::PutOptions {
                cache_control: Some(cc),
                download_name: None,
            })
        })
            .await
            .context("PUT task panicked")?
            .with_context(|| format!("PUT {key}"))?;

        debug!(key, cache_control = cc, "uploaded");
        uploaded.push(key.clone());
        new_manifest.insert(key, body_hash);
    }

    // Save the updated manifest. Non-fatal: files are already in R2; a
    // failed manifest write just means the next run re-uploads everything.
    if let Err(e) = {
        let s = Arc::clone(&store);
        let manifest_bytes =
            serde_json::to_vec(&new_manifest).context("serializing publish manifest")?;
        tokio::task::spawn_blocking(move || {
            // A fixed key rewritten by every publish (R703-T10). Nothing
            // outside this reconciler ever fetches it, so the directive is
            // cosmetic — but a mutable pointer with no directive is exactly
            // the pattern being removed, and leaving one behind invites the
            // next reader to copy it.
            s.put_with(
                MANIFEST_KEY,
                manifest_bytes,
                &yah_object_store::PutOptions {
                    cache_control: Some(yah_object_store::CACHE_CONTROL_NO_CACHE),
                    download_name: None,
                },
            )
        })
        .await
        .context("manifest PUT task panicked")?
    } {
        tracing::warn!(error = %e, "failed to save publish manifest (non-fatal) — next run will re-upload all objects");
    }

    info!(
        uploaded = uploaded.len(),
        skipped = skipped.len(),
        "R2 upload complete"
    );

    // R703-B4 — publish beacon, written LAST and unconditionally.
    //
    // Last, because its presence is then also evidence that the run reached
    // the end rather than dying halfway through the file loop. Unconditionally
    // (no manifest skip), because a run where every object was skipped as
    // unchanged still needs to refresh `published_at` — otherwise "the front
    // door is three weeks behind" and "nothing has changed in three weeks"
    // look identical from the outside.
    //
    // Fatal on failure, unlike the manifest write above: the manifest is an
    // optimisation, but a missing beacon disables the serving check that is
    // this object's entire reason to exist, and a publish whose verification
    // silently switched itself off is the exact failure being fixed here.
    let contents: BTreeMap<String, String> = new_manifest
        .iter()
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();
    let beacon = PublishBeacon::new(key_prefix.unwrap_or_default(), &contents);
    let beacon_key = match key_prefix {
        Some(p) => format!("{}/{}", p.trim_end_matches('/'), BEACON_KEY),
        None => BEACON_KEY.to_string(),
    };
    {
        let s = Arc::clone(&store);
        let bytes = serde_json::to_vec(&beacon).context("serializing publish beacon")?;
        let key = beacon_key.clone();
        // R703-B8 — `no-cache`, and of everything this function uploads it is
        // the object that most needs it. The beacon is the freshness oracle the
        // serving check reads back through the public front door; a cached copy
        // makes a stale site verify green, which is worse than having no check.
        // Every other object now carries one too — see `cache_control_for`.
        tokio::task::spawn_blocking(move || {
            s.put_with(
                &key,
                bytes,
                &yah_object_store::PutOptions {
                    cache_control: Some(yah_object_store::CACHE_CONTROL_NO_CACHE),
                    download_name: None,
                },
            )
        })
        .await
        .context("beacon PUT task panicked")?
        .with_context(|| format!("PUT {beacon_key}"))?;
    }
    info!(
        digest = %beacon.digest,
        files = beacon.files,
        key = %beacon_key,
        "publish beacon written"
    );

    // CDN cache-tag purge.
    let purged_tags = if let Some(opts) = purge {
        let tags = read_tag_names(dist_dir).await;
        if tags.is_empty() {
            debug!("no tags in dist/tag-index.json — skipping purge");
            Vec::new()
        } else {
            let cf = CloudflareClient::new(opts.api_token);
            let zone_id = cf
                .zone_id_for_name(&opts.zone_name)
                .await
                .with_context(|| format!("resolving zone id for {:?}", opts.zone_name))?;
            match cf.purge_cache_tags(&zone_id, &tags).await {
                Ok(()) => {
                    info!(
                        zone = opts.zone_name,
                        tags = tags.len(),
                        "CDN cache-tag purge complete"
                    );
                }
                Err(e) => {
                    // Purge failure is non-fatal: files are already in R2.
                    // Likely cause: api-token missing Zone:Cache Purge scope.
                    // Stale CDN content expires on its own; add the scope to
                    // avoid this warning on subsequent publishes.
                    tracing::warn!(
                        zone = opts.zone_name,
                        error = %e,
                        "CDN cache-tag purge failed (non-fatal) — \
                         ensure cloudflare-api-token has Zone:Cache Purge scope"
                    );
                }
            }
            tags
        }
    } else {
        Vec::new()
    };

    Ok(R2PublishReport {
        uploaded,
        skipped,
        purged_tags,
        beacon,
    })
}

// ---------- helpers ----------

fn sha256_hex(body: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(body);
    hex::encode(hasher.finalize())
}

/// Read `dist/tag-index.json` and extract the tag names. Non-fatal — returns
/// empty vec on any read or parse error.
async fn read_tag_names(dist_dir: &Path) -> Vec<String> {
    let path = dist_dir.join("tag-index.json");
    let Ok(bytes) = tokio::fs::read(&path).await else {
        return Vec::new();
    };
    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
        return Vec::new();
    };
    let Some(tags_obj) = value.get("tags").and_then(|v| v.as_object()) else {
        return Vec::new();
    };
    let mut tags: Vec<String> = tags_obj.keys().cloned().collect();
    tags.sort();
    tags
}

async fn walk_files(root: &Path) -> std::io::Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let mut rd = tokio::fs::read_dir(&dir).await?;
        while let Some(entry) = rd.next_entry().await? {
            let ft = entry.file_type().await?;
            let p = entry.path();
            if ft.is_dir() {
                stack.push(p);
            } else if ft.is_file() {
                out.push(p);
            }
        }
    }
    out.sort();
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn publish_fails_when_dist_missing() {
        let err = publish_to_r2(
            Path::new("/no/such/dist"),
            "acct123",
            "yah-dev",
            "key",
            "secret",
            None,
            None,
            &OutputPolicy::default(),
        )
        .await
        .unwrap_err();
        assert!(
            format!("{err:#}").contains("dist_dir not found"),
            "got: {err:#}"
        );
    }

    #[test]
    fn html_prefix_stripped() {
        let rel = "html/index.html";
        let key = rel.strip_prefix("html/").unwrap_or(rel);
        assert_eq!(key, "index.html");
    }

    #[test]
    fn assets_pass_through() {
        let rel = "assets/main.js";
        let key = rel.strip_prefix("html/").unwrap_or(rel);
        assert_eq!(key, "assets/main.js");
    }

    #[tokio::test]
    async fn read_tag_names_returns_sorted() {
        let tmp = TempDir::new().unwrap();
        let body = r#"{"build_id":"b1","tags":{"page:home":[],"site:yah-dev":[],"page:404":[]}}"#;
        tokio::fs::write(tmp.path().join("tag-index.json"), body)
            .await
            .unwrap();
        let tags = read_tag_names(tmp.path()).await;
        assert_eq!(tags, vec!["page:404", "page:home", "site:yah-dev"]);
    }

    #[test]
    fn manifest_roundtrip() {
        let mut m = PublishManifest::new();
        m.insert("index.html".to_string(), sha256_hex(b"hello"));
        m.insert("assets/main.js".to_string(), sha256_hex(b"world"));
        let json = serde_json::to_vec(&m).unwrap();
        let m2: PublishManifest = serde_json::from_slice(&json).unwrap();
        assert_eq!(m, m2);
    }

    #[test]
    fn manifest_skip_logic() {
        let content = b"hello world";
        let hash = sha256_hex(content);
        let mut manifest = PublishManifest::new();
        manifest.insert("index.html".to_string(), hash.clone());

        // Same content → should skip.
        assert_eq!(
            manifest.get("index.html").map(|s| s.as_str()),
            Some(hash.as_str())
        );
        // Different content → should upload.
        let other_hash = sha256_hex(b"different");
        assert_ne!(
            manifest.get("index.html").map(|s| s.as_str()),
            Some(other_hash.as_str())
        );
        // Missing key → should upload.
        assert_eq!(manifest.get("missing.html"), None);
    }

    #[test]
    fn load_manifest_empty_on_bad_json() {
        let bad: PublishManifest = serde_json::from_slice(b"not json").unwrap_or_default();
        assert!(bad.is_empty());
    }

    // ── Cache-Control classification (R703-T10, MFT-R825-F1) ────────────────

    /// A mesofact-build-shaped dist (it has `html/`), optionally with a
    /// declaration. Keys below are relative to that dist, before the `html/`
    /// strip — the same paths the publish loop hands `cache_control_for`.
    fn mesofact_policy(declared: Option<&str>) -> (TempDir, OutputPolicy) {
        let project = TempDir::new().unwrap();
        std::fs::create_dir_all(project.path().join("dist/html")).unwrap();
        if let Some(d) = declared {
            std::fs::write(project.path().join("mesofact.config.toml"), d).unwrap();
        }
        let policy = OutputPolicy::load(project.path(), &project.path().join("dist")).unwrap();
        (project, policy)
    }

    /// Pinned against REAL filenames read out of the built dist trees on
    /// 2026-08-09, not invented ones — the whole rule rests on which files the
    /// builder content-hashes, so a test over made-up names would prove nothing.
    #[test]
    fn only_content_hashed_hydrate_bundles_are_immutable() {
        let (_p, policy) = mesofact_policy(None);
        // app/yah/web/marketing/dist/hydrate/ and dashboard's, verbatim.
        for k in [
            "hydrate/issues.ervzmehh.js",
            "hydrate/issues.BdKFYN8Z.js",
            "hydrate/rigs_id.uxTuqN9d.js",
            "hydrate/index.CStDXFd1.js",
            "hydrate/index.chunk-CStDXFd1.js",
        ] {
            assert_eq!(
                cache_control_for(k, &policy),
                "public, max-age=31536000, immutable",
                "{k}"
            );
        }
    }

    /// Every fixed key the same publish uploads. These are rewritten in place
    /// by the next publish, so `immutable` here is a promise the bytes cannot
    /// keep — a stale yah.dev page with no way to bust it.
    #[test]
    fn fixed_keys_are_no_cache() {
        let (_p, policy) = mesofact_policy(None);
        for k in [
            "html/index.html",
            "html/releases.html",
            "html/404.html",
            "html/install.sh",
            "html/illustrations/yah-table-dark.webp",
            "manifest.json",
            "tag-index.json",
            ".well-known/yah-publish.json",
        ] {
            assert_eq!(cache_control_for(k, &policy), "no-cache, max-age=0", "{k}");
        }
    }

    /// The directory alone is not enough. A hand-dropped or non-bundler file
    /// under `hydrate/` is not content-addressed, and a year-long immutable
    /// cache on it would be unrecoverable — unknown must fall to `no-cache`.
    #[test]
    fn hydrate_files_that_are_not_hash_named_stay_no_cache() {
        let (_p, policy) = mesofact_policy(None);
        for k in [
            "hydrate/app.js",        // no hash segment at all
            "hydrate/app.min.js",    // a second dot, but `min` is not a hash
            "hydrate/app.abc.js",    // too short to be one either
            "hydrate/vendor.LICENSE",// not even a .js
            "hydrate/readme.md",
        ] {
            assert_eq!(cache_control_for(k, &policy), "no-cache, max-age=0", "{k}");
        }
    }

    /// MFT-R825-F1: the R2 path honours the SAME declaration the bundle path
    /// serves from — the gotcha that ticket carried was "do not add a third
    /// rule".
    #[test]
    fn declared_patterns_reach_the_r2_path() {
        let (_p, policy) =
            mesofact_policy(Some("[build]\nimmutable = [\"wasm/*_bg.wasm\"]\n"));
        assert_eq!(
            cache_control_for("html/wasm/demo-0123abcd_bg.wasm", &policy),
            "public, max-age=31536000, immutable"
        );
        assert_eq!(cache_control_for("html/index.html", &policy), "no-cache, max-age=0");

        // A flat (Trunk) output: the patterns match from the dist root.
        let flat = TempDir::new().unwrap();
        std::fs::create_dir_all(flat.path().join("dist")).unwrap();
        std::fs::write(flat.path().join("mesofact.config.toml"), "[build]\nimmutable = [\"*_bg.wasm\"]\n")
            .unwrap();
        let policy = OutputPolicy::load(flat.path(), &flat.path().join("dist")).unwrap();
        assert_eq!(cache_control_for("app-0123abcd_bg.wasm", &policy), "public, max-age=31536000, immutable");
        assert_eq!(cache_control_for("index.html", &policy), "no-cache, max-age=0");
    }
}