@@ -80,6 +80,17 @@ pub struct PublicationConfig {
8080 pub at_uri : Option < String > ,
8181 #[ serde( skip_serializing_if = "Option::is_none" ) ]
8282 pub at_cid : Option < String > ,
83+ /// `true` once the publication's branded OG cover blob has been
84+ /// successfully uploaded and attached to the publication record. The
85+ /// init step short-circuits when this is `true` AND `at_uri`/`at_cid`
86+ /// are cached — so deploys don't churn the publication record.
87+ ///
88+ /// Flip back to `false` (or remove the line) to trigger an in-place
89+ /// refresh on the next deploy: init re-puts the publication with a
90+ /// new cid, sync detects the per-document `atproto_pub_cid` drift,
91+ /// and every document gets re-put with the refreshed strong-ref.
92+ #[ serde( default ) ]
93+ pub cover_synced : bool ,
8394}
8495
8596#[ derive( Debug , PartialEq , Eq ) ]
@@ -214,16 +225,26 @@ fn publication_cover_imgproxy_url(app_base_url: &str, imgproxy_url: &str, key: &
214225 )
215226}
216227
228+ /// Read a required env var, treating both "unset" and "set-to-empty-string"
229+ /// as missing. Without the empty-string check, an unset CI secret like
230+ /// `IMGPROXY_URL: ${{ secrets.IMGPROXY_URL }}` produces an empty string
231+ /// rather than a missing var, which silently feeds an empty base into URL
232+ /// construction and yields a malformed relative URL at fetch time.
233+ fn required_env ( name : & str ) -> cja:: Result < String > {
234+ match std:: env:: var ( name) {
235+ Ok ( v) if !v. is_empty ( ) => Ok ( v) ,
236+ _ => Err ( eyre ! ( "{name} must be set (and non-empty)" ) ) ,
237+ }
238+ }
239+
217240/// Fetch the publication's branded OG card as a PNG via imgproxy.
218241///
219242/// Requires `APP_BASE_URL` (so we know where the deployed SVG endpoint lives)
220243/// and `IMGPROXY_URL` (so the SVG gets rasterized). Returns `(bytes, mime)`
221244/// suitable for `upload_blob`.
222245async fn fetch_publication_cover_png ( key : & str ) -> cja:: Result < ( Vec < u8 > , String ) > {
223- let app_base_url =
224- std:: env:: var ( "APP_BASE_URL" ) . map_err ( |_| eyre ! ( "APP_BASE_URL must be set" ) ) ?;
225- let imgproxy_url =
226- std:: env:: var ( "IMGPROXY_URL" ) . map_err ( |_| eyre ! ( "IMGPROXY_URL must be set" ) ) ?;
246+ let app_base_url = required_env ( "APP_BASE_URL" ) ?;
247+ let imgproxy_url = required_env ( "IMGPROXY_URL" ) ?;
227248 let png_url = publication_cover_imgproxy_url ( & app_base_url, & imgproxy_url, key) ;
228249
229250 let resp = reqwest:: get ( & png_url)
@@ -312,13 +333,14 @@ async fn init_publication(
312333 . find ( |p| p. key == key)
313334 . ok_or_else ( || eyre ! ( "Publication '{key}' not found in {}" , config_path. display( ) ) ) ?;
314335
315- // Idempotent fast path: when both fields are cached and the caller didn't
316- // ask for a refresh, do nothing. This lets the workflow run `init` on
317- // every deploy without thrashing the PDS or producing churn commits
318- // (`put_publication` bumps `createdAt`/`cid` on every call).
319- if !force && pub_cfg. at_uri . is_some ( ) && pub_cfg. at_cid . is_some ( ) {
336+ // Idempotent fast path: when the publication is cached AND its cover
337+ // has been confirmed uploaded, do nothing. Without the `cover_synced`
338+ // gate we'd skip a repair-and-recover case (publication was created in
339+ // a previous deploy where cover fetch failed; `at_uri`/`at_cid` are
340+ // populated but the publication record on the PDS has `cover: None`).
341+ if !force && pub_cfg. at_uri . is_some ( ) && pub_cfg. at_cid . is_some ( ) && pub_cfg. cover_synced {
320342 println ! (
321- "Publication '{key}' already initialized (uri={}); skipping. Use --force to refresh ." ,
343+ "Publication '{key}' already initialized with cover (uri={}); skipping." ,
322344 pub_cfg. at_uri. as_deref( ) . unwrap_or( "" )
323345 ) ;
324346 return Ok ( ( ) ) ;
@@ -328,26 +350,27 @@ async fn init_publication(
328350 // itself is served by the deployed app at `/og/publication/{key}.svg`;
329351 // imgproxy rasterizes and caches the 1200×630 PNG that the cover blob
330352 // points at. Best-effort: if the fetch fails we proceed without a cover
331- // rather than aborting init.
332- let cover: Option < Blob > = match fetch_publication_cover_png ( & pub_cfg. key ) . await {
333- Ok ( ( bytes, mime) ) => match client. upload_blob ( bytes, & mime) . await {
334- Ok ( blob) => Some ( blob) ,
353+ // — `cover_synced` stays `false` so the next deploy retries.
354+ let ( cover, cover_synced) : ( Option < Blob > , bool ) =
355+ match fetch_publication_cover_png ( & pub_cfg. key ) . await {
356+ Ok ( ( bytes, mime) ) => match client. upload_blob ( bytes, & mime) . await {
357+ Ok ( blob) => ( Some ( blob) , true ) ,
358+ Err ( e) => {
359+ eprintln ! (
360+ "Warning: cover upload failed for '{}': {e}. Proceeding without cover." ,
361+ pub_cfg. key
362+ ) ;
363+ ( None , false )
364+ }
365+ } ,
335366 Err ( e) => {
336367 eprintln ! (
337- "Warning: cover upload failed for '{}': {e}. Proceeding without cover." ,
338- pub_cfg. key
339- ) ;
340- None
341- }
342- } ,
343- Err ( e) => {
344- eprintln ! (
345368 "Warning: could not fetch generated cover for '{}': {e}. Proceeding without cover." ,
346369 pub_cfg. key
347370 ) ;
348- None
349- }
350- } ;
371+ ( None , false )
372+ }
373+ } ;
351374
352375 let record = PublicationRecord {
353376 record_type : "site.standard.publication" . to_string ( ) ,
@@ -368,10 +391,11 @@ async fn init_publication(
368391
369392 pub_cfg. at_uri = Some ( response. uri . clone ( ) ) ;
370393 pub_cfg. at_cid = Some ( response. cid . clone ( ) ) ;
394+ pub_cfg. cover_synced = cover_synced;
371395 save_config ( config_path, & cfg) ?;
372396
373397 println ! (
374- "Publication '{key}' synced: uri={} cid={}" ,
398+ "Publication '{key}' synced: uri={} cid={} cover_synced={cover_synced} " ,
375399 response. uri, response. cid
376400 ) ;
377401 Ok ( ( ) )
@@ -433,26 +457,38 @@ async fn sync_one(
433457 let fm: BlogFrontMatter =
434458 serde_yaml:: from_str ( yaml) . map_err ( |e| eyre ! ( "Invalid frontmatter: {e}" ) ) ?;
435459
436- // Document syncing has no date cutoff — every historical post gets a
437- // `site.standard.document` on first deploy. The cutoff below only gates
438- // the Bluesky post: historical posts publish to standard.site only,
439- // recent posts also get a Bluesky post.
440- let outcome = classify_blog_post ( & fm) ;
441- if matches ! ( outcome, SyncOutcome :: Skip ) {
442- return Ok ( SyncOutcome :: Skip ) ;
443- }
444-
445460 // Filter by `publication` field — only sync posts that belong to this publication.
446461 if fm. publication != pub_cfg. key {
447462 return Ok ( SyncOutcome :: Skip ) ;
448463 }
449464
450- let is_historical = fm. date < bsky_post_cutoff ( ) ;
465+ let pub_cid = pub_cfg
466+ . at_cid
467+ . as_deref ( )
468+ . ok_or_else ( || eyre ! ( "publication at_cid missing" ) ) ?;
469+ let pub_uri = pub_cfg
470+ . at_uri
471+ . as_deref ( )
472+ . ok_or_else ( || eyre ! ( "publication at_uri missing" ) ) ?;
473+ let pub_ref = StrongRef {
474+ uri : pub_uri. to_string ( ) ,
475+ cid : pub_cid. to_string ( ) ,
476+ } ;
451477
452- // Historical post that already has its document synced — terminal state.
453- // (`BskyOnly` means atproto_uri set + bsky_url unset; for historical posts
454- // we never want to write a bsky_url, so this IS the desired final state.)
455- if is_historical && matches ! ( outcome, SyncOutcome :: BskyOnly ) {
478+ let is_historical = fm. date < bsky_post_cutoff ( ) ;
479+ let doc_exists = fm. atproto_uri . is_some ( ) ;
480+ let doc_pinned_to_current_pub = fm. atproto_pub_cid . as_deref ( ) == Some ( pub_cid) ;
481+ let bsky_exists = fm. bsky_url . is_some ( ) ;
482+
483+ // What needs doing for this post?
484+ // - Document put: any time the doc doesn't exist OR its pinned pub cid has drifted.
485+ // - Bsky post: only when the post is recent (>= cutoff) and we don't have a bsky_url.
486+ // When we DO need a bsky post but the doc is already current, we still re-put the
487+ // doc so we have a fresh strong-ref to attach to the bsky post.
488+ let need_bsky_post = !bsky_exists && !is_historical;
489+ let need_doc_put = !doc_exists || !doc_pinned_to_current_pub || need_bsky_post;
490+
491+ if !need_doc_put && !need_bsky_post {
456492 return Ok ( SyncOutcome :: Skip ) ;
457493 }
458494
@@ -464,66 +500,54 @@ async fn sync_one(
464500 let ast = MarkdownAst :: from_str ( & content) ?;
465501 let description: String = ast. 0 . plain_text ( ) . chars ( ) . take ( 100 ) . collect ( ) ;
466502
467- let pub_ref = StrongRef {
468- uri : pub_cfg
469- . at_uri
470- . clone ( )
471- . ok_or_else ( || eyre ! ( "publication at_uri missing" ) ) ?,
472- cid : pub_cfg
473- . at_cid
474- . clone ( )
475- . ok_or_else ( || eyre ! ( "publication at_cid missing" ) ) ?,
476- } ;
477-
503+ // Re-put the document with the current publication strong-ref. Idempotent
504+ // on rkey; produces a fresh doc cid we attach to the bsky post if needed.
478505 let record = build_document_record ( & fm, & pub_ref, post_url. clone ( ) , description. clone ( ) ) ;
479506 let doc_response = client. put_document ( & blog_rkey, record) . await ?;
480507 let doc_ref = StrongRef {
481508 uri : doc_response. uri . clone ( ) ,
482509 cid : doc_response. cid . clone ( ) ,
483510 } ;
484511
485- match outcome {
486- SyncOutcome :: Both if is_historical => {
487- // Document-only for historical posts. Write atproto_uri so future
488- // runs short-circuit via the BskyOnly + is_historical check above.
489- let updated =
490- frontmatter:: append_frontmatter_keys ( & content, & [ ( "atproto_uri" , & doc_ref. uri ) ] ) ;
491- write_back ( post_path, & updated) ?;
492- Ok ( SyncOutcome :: Both )
493- }
494- SyncOutcome :: Both => {
495- let bsky_response = client
496- . create_blog_post (
497- & fm. title ,
498- & post_url,
499- & description,
500- vec ! [ pub_ref. clone( ) , doc_ref. clone( ) ] ,
501- )
502- . await ?;
503- let bsky_web_url = at_uri_to_web_url ( & bsky_response. uri ) ?;
504- let updated = frontmatter:: append_frontmatter_keys (
505- & content,
506- & [ ( "atproto_uri" , & doc_ref. uri ) , ( "bsky_url" , & bsky_web_url) ] ,
507- ) ;
508- write_back ( post_path, & updated) ?;
509- Ok ( SyncOutcome :: Both )
510- }
511- SyncOutcome :: BskyOnly => {
512- let bsky_response = client
513- . create_blog_post (
514- & fm. title ,
515- & post_url,
516- & description,
517- vec ! [ pub_ref. clone( ) , doc_ref. clone( ) ] ,
518- )
519- . await ?;
520- let bsky_web_url = at_uri_to_web_url ( & bsky_response. uri ) ?;
521- let updated =
522- frontmatter:: append_frontmatter_keys ( & content, & [ ( "bsky_url" , & bsky_web_url) ] ) ;
523- write_back ( post_path, & updated) ?;
524- Ok ( SyncOutcome :: BskyOnly )
525- }
526- SyncOutcome :: Skip => Ok ( SyncOutcome :: Skip ) ,
512+ // Conditionally publish a Bluesky post (only for recent posts without one).
513+ let bsky_web_url_opt = if need_bsky_post {
514+ let bsky_response = client
515+ . create_blog_post (
516+ & fm. title ,
517+ & post_url,
518+ & description,
519+ vec ! [ pub_ref. clone( ) , doc_ref. clone( ) ] ,
520+ )
521+ . await ?;
522+ Some ( at_uri_to_web_url ( & bsky_response. uri ) ?)
523+ } else {
524+ None
525+ } ;
526+
527+ // Frontmatter updates: atproto_uri (if first put), atproto_pub_cid (every
528+ // doc put), bsky_url (if we just created a bsky post). We always write
529+ // atproto_pub_cid since the doc was just put against the current pub_cid.
530+ let mut new_keys: Vec < ( & str , & str ) > = Vec :: new ( ) ;
531+ if !doc_exists {
532+ new_keys. push ( ( "atproto_uri" , & doc_ref. uri ) ) ;
533+ }
534+ new_keys. push ( ( "atproto_pub_cid" , pub_cid) ) ;
535+ let bsky_web_url = bsky_web_url_opt. unwrap_or_default ( ) ;
536+ if !bsky_web_url. is_empty ( ) {
537+ new_keys. push ( ( "bsky_url" , & bsky_web_url) ) ;
538+ }
539+ let updated = frontmatter:: append_frontmatter_keys ( & content, & new_keys) ;
540+ write_back ( post_path, & updated) ?;
541+
542+ // Outcome reporting: distinguish between "both sides changed" and
543+ // "only bsky changed" for the summary printout. A doc-only re-put
544+ // (drift refresh) reports as Both since we did write doc-side state.
545+ if need_bsky_post && ( !doc_exists || !doc_pinned_to_current_pub) {
546+ Ok ( SyncOutcome :: Both )
547+ } else if need_bsky_post {
548+ Ok ( SyncOutcome :: BskyOnly )
549+ } else {
550+ Ok ( SyncOutcome :: Both )
527551 }
528552}
529553
@@ -574,6 +598,7 @@ mod tests {
574598 collection : "site.standard.document" . to_string ( ) ,
575599 at_uri : None ,
576600 at_cid : None ,
601+ cover_synced : false ,
577602 }
578603 }
579604
@@ -590,6 +615,7 @@ mod tests {
590615 tags : vec ! [ ] ,
591616 author : None ,
592617 atproto_uri : None ,
618+ atproto_pub_cid : None ,
593619 publication : "blog" . to_string ( ) ,
594620 }
595621 }
0 commit comments