Skip to content

Commit 0eeebb5

Browse files
authored
Spec-correct publication metadata refresh + imgproxy env fix (#406)
1 parent 269eb6c commit 0eeebb5

3 files changed

Lines changed: 133 additions & 94 deletions

File tree

.github/workflows/bluesky.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ concurrency:
1919
env:
2020
SQLX_OFFLINE: true
2121
APP_BASE_URL: https://coreyja.com
22+
# Not actually secret — imgproxy is a public service that the blog itself
23+
# links into for OG card rasterization. Hardcoding here keeps `init` from
24+
# having to read a repo secret that didn't exist on first deploy.
25+
IMGPROXY_URL: https://img.coreyja.com
2226

2327
jobs:
2428
publish:
@@ -101,7 +105,6 @@ jobs:
101105
env:
102106
BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }}
103107
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
104-
IMGPROXY_URL: ${{ secrets.IMGPROXY_URL }}
105108
run: ./target/release/server publish-standard-site init blog
106109

107110
- name: Sync standard.site

posts/src/blog.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ pub struct BlogFrontMatter {
247247
/// means the document record has been created, so subsequent syncs use
248248
/// `putRecord` against the same rkey rather than creating a new record.
249249
pub atproto_uri: Option<String>,
250+
/// Content-hash CID of the `site.standard.publication` record this
251+
/// document's `publication: StrongRef` field was pinned to at last sync.
252+
/// When the publication record changes (cover swap, metadata edit),
253+
/// its CID bumps and this field drifts from `publications.toml`'s
254+
/// current `at_cid` — sync detects the drift and re-puts the document
255+
/// with the refreshed strong reference. Spec-correct cascade: change
256+
/// the referent, re-emit every referrer.
257+
pub atproto_pub_cid: Option<String>,
250258
/// Publication this post belongs to. Defaults to `"blog"` and corresponds
251259
/// to an entry in `publications.toml`.
252260
#[serde(default = "default_publication")]
@@ -341,6 +349,7 @@ mod test {
341349
tags: vec![],
342350
author: None,
343351
atproto_uri: None,
352+
atproto_pub_cid: None,
344353
publication: "blog".to_string(),
345354
};
346355
let post = BlogPost {
@@ -380,6 +389,7 @@ mod test {
380389
tags: vec![],
381390
author: None,
382391
atproto_uri: None,
392+
atproto_pub_cid: None,
383393
publication: "blog".to_string(),
384394
},
385395
}

server/src/commands/standard_site.rs

Lines changed: 119 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -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`.
222245
async 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

Comments
 (0)