From ed2333e06a8d45c621e5aa44f3f38a7456ab6fbc Mon Sep 17 00:00:00 2001 From: Byte Date: Fri, 5 Jun 2026 09:02:42 -0400 Subject: [PATCH 1/3] Hardcode IMGPROXY_URL in bluesky workflow + treat empty env as missing The bluesky workflow's init step was pulling \`IMGPROXY_URL\` from a repo secret that didn't exist, expanding to an empty string. The Rust code read that empty string as a present-but-empty value, built a relative imgproxy URL, and reqwest refused with "relative URL without a base." Result: the publication record landed on the PDS without a cover blob. Two-part fix: 1. Drop the secret reference and hardcode \`IMGPROXY_URL: https://img.coreyja.com\` at the workflow level. imgproxy is a public service the blog itself links into; nothing about it needs to live in repo secrets. 2. Add \`required_env(name)\` that rejects both unset and empty-string values. Future missing/typoed env vars produce a clear error instead of a malformed URL further down the call chain. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/bluesky.yml | 5 ++++- server/src/commands/standard_site.rs | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bluesky.yml b/.github/workflows/bluesky.yml index ddc054f1..b3240ab3 100644 --- a/.github/workflows/bluesky.yml +++ b/.github/workflows/bluesky.yml @@ -19,6 +19,10 @@ concurrency: env: SQLX_OFFLINE: true APP_BASE_URL: https://coreyja.com + # Not actually secret — imgproxy is a public service that the blog itself + # links into for OG card rasterization. Hardcoding here keeps `init` from + # having to read a repo secret that didn't exist on first deploy. + IMGPROXY_URL: https://img.coreyja.com jobs: publish: @@ -101,7 +105,6 @@ jobs: env: BLUESKY_IDENTIFIER: ${{ secrets.BLUESKY_IDENTIFIER }} BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }} - IMGPROXY_URL: ${{ secrets.IMGPROXY_URL }} run: ./target/release/server publish-standard-site init blog - name: Sync standard.site diff --git a/server/src/commands/standard_site.rs b/server/src/commands/standard_site.rs index 688812a9..c10ae35b 100644 --- a/server/src/commands/standard_site.rs +++ b/server/src/commands/standard_site.rs @@ -214,16 +214,26 @@ fn publication_cover_imgproxy_url(app_base_url: &str, imgproxy_url: &str, key: & ) } +/// Read a required env var, treating both "unset" and "set-to-empty-string" +/// as missing. Without the empty-string check, an unset CI secret like +/// `IMGPROXY_URL: ${{ secrets.IMGPROXY_URL }}` produces an empty string +/// rather than a missing var, which silently feeds an empty base into URL +/// construction and yields a malformed relative URL at fetch time. +fn required_env(name: &str) -> cja::Result { + match std::env::var(name) { + Ok(v) if !v.is_empty() => Ok(v), + _ => Err(eyre!("{name} must be set (and non-empty)")), + } +} + /// Fetch the publication's branded OG card as a PNG via imgproxy. /// /// Requires `APP_BASE_URL` (so we know where the deployed SVG endpoint lives) /// and `IMGPROXY_URL` (so the SVG gets rasterized). Returns `(bytes, mime)` /// suitable for `upload_blob`. async fn fetch_publication_cover_png(key: &str) -> cja::Result<(Vec, String)> { - let app_base_url = - std::env::var("APP_BASE_URL").map_err(|_| eyre!("APP_BASE_URL must be set"))?; - let imgproxy_url = - std::env::var("IMGPROXY_URL").map_err(|_| eyre!("IMGPROXY_URL must be set"))?; + let app_base_url = required_env("APP_BASE_URL")?; + let imgproxy_url = required_env("IMGPROXY_URL")?; let png_url = publication_cover_imgproxy_url(&app_base_url, &imgproxy_url, key); let resp = reqwest::get(&png_url) From b31c4719a717f07e3b0f0b226bb3c912fe5c0eeb Mon Sep 17 00:00:00 2001 From: Byte Date: Fri, 5 Jun 2026 09:11:26 -0400 Subject: [PATCH 2/3] Spec-correct publication-metadata refresh: cover_synced + atproto_pub_cid drift detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new pieces of state and the sync logic that uses them to make publication-record updates cascade through every document that strong- references it — fully via the bluesky workflow, no manual ops steps. \`publications.toml\` gains \`cover_synced: bool\` (default \`false\`): - init's idempotent fast path now requires this in addition to cached \`at_uri\`/\`at_cid\`. Set to \`true\` only after a *successful* cover-blob upload; an upload failure leaves it \`false\` so the next deploy retries. - Self-heals our current state: the publication record was created on the prior deploy without a cover (\`IMGPROXY_URL\` was empty), so \`cover_synced\` is absent (= \`false\`) → next deploy re-puts the publication with cover → new cid in \`at_cid\`. \`BlogFrontMatter\` gains \`atproto_pub_cid: Option\`: - Records the publication CID this document's \`publication: StrongRef\` was pinned to at last sync. - Sync compares against current \`pub_cfg.at_cid\`; drift triggers a re-put of the document with the refreshed strong-ref. Spec-correct: when the referent's content changes, every referrer gets re-emitted. \`sync_one\` is rewritten around two booleans: - \`need_doc_put\` = no document yet OR pinned cid drifted OR we're going to put a bsky post (so we have a fresh doc cid for it) - \`need_bsky_post\` = no bsky_url AND post is recent (>= cutoff) - Skip when neither needs doing. Document re-put always re-writes \`atproto_pub_cid\` so future runs short-circuit until drift again. Net effect when the next bluesky workflow runs after this merges: 1. init re-puts the publication with cover → at_cid bumps → committed 2. sync walks 43 existing documents → all have atproto_pub_cid=None → drift detected → all re-put with new cid → frontmatter updated 3. Commit step pushes the new at_cid + 43 frontmatter updates back. Future cover updates: flip cover_synced=false in publications.toml, push, deploy. Same cascade runs again. No manual steps. Co-Authored-By: Claude Opus 4.7 --- posts/src/blog.rs | 10 ++ server/src/commands/standard_site.rs | 177 +++++++++++++++------------ 2 files changed, 108 insertions(+), 79 deletions(-) diff --git a/posts/src/blog.rs b/posts/src/blog.rs index 02be971b..41aa26c2 100644 --- a/posts/src/blog.rs +++ b/posts/src/blog.rs @@ -247,6 +247,14 @@ pub struct BlogFrontMatter { /// means the document record has been created, so subsequent syncs use /// `putRecord` against the same rkey rather than creating a new record. pub atproto_uri: Option, + /// Content-hash CID of the `site.standard.publication` record this + /// document's `publication: StrongRef` field was pinned to at last sync. + /// When the publication record changes (cover swap, metadata edit), + /// its CID bumps and this field drifts from `publications.toml`'s + /// current `at_cid` — sync detects the drift and re-puts the document + /// with the refreshed strong reference. Spec-correct cascade: change + /// the referent, re-emit every referrer. + pub atproto_pub_cid: Option, /// Publication this post belongs to. Defaults to `"blog"` and corresponds /// to an entry in `publications.toml`. #[serde(default = "default_publication")] @@ -341,6 +349,7 @@ mod test { tags: vec![], author: None, atproto_uri: None, + atproto_pub_cid: None, publication: "blog".to_string(), }; let post = BlogPost { @@ -380,6 +389,7 @@ mod test { tags: vec![], author: None, atproto_uri: None, + atproto_pub_cid: None, publication: "blog".to_string(), }, } diff --git a/server/src/commands/standard_site.rs b/server/src/commands/standard_site.rs index c10ae35b..92b0c2ff 100644 --- a/server/src/commands/standard_site.rs +++ b/server/src/commands/standard_site.rs @@ -80,6 +80,17 @@ pub struct PublicationConfig { pub at_uri: Option, #[serde(skip_serializing_if = "Option::is_none")] pub at_cid: Option, + /// `true` once the publication's branded OG cover blob has been + /// successfully uploaded and attached to the publication record. The + /// init step short-circuits when this is `true` AND `at_uri`/`at_cid` + /// are cached — so deploys don't churn the publication record. + /// + /// Flip back to `false` (or remove the line) to trigger an in-place + /// refresh on the next deploy: init re-puts the publication with a + /// new cid, sync detects the per-document `atproto_pub_cid` drift, + /// and every document gets re-put with the refreshed strong-ref. + #[serde(default)] + pub cover_synced: bool, } #[derive(Debug, PartialEq, Eq)] @@ -322,13 +333,14 @@ async fn init_publication( .find(|p| p.key == key) .ok_or_else(|| eyre!("Publication '{key}' not found in {}", config_path.display()))?; - // Idempotent fast path: when both fields are cached and the caller didn't - // ask for a refresh, do nothing. This lets the workflow run `init` on - // every deploy without thrashing the PDS or producing churn commits - // (`put_publication` bumps `createdAt`/`cid` on every call). - if !force && pub_cfg.at_uri.is_some() && pub_cfg.at_cid.is_some() { + // Idempotent fast path: when the publication is cached AND its cover + // has been confirmed uploaded, do nothing. Without the `cover_synced` + // gate we'd skip a repair-and-recover case (publication was created in + // a previous deploy where cover fetch failed; `at_uri`/`at_cid` are + // populated but the publication record on the PDS has `cover: None`). + if !force && pub_cfg.at_uri.is_some() && pub_cfg.at_cid.is_some() && pub_cfg.cover_synced { println!( - "Publication '{key}' already initialized (uri={}); skipping. Use --force to refresh.", + "Publication '{key}' already initialized with cover (uri={}); skipping.", pub_cfg.at_uri.as_deref().unwrap_or("") ); return Ok(()); @@ -338,16 +350,20 @@ async fn init_publication( // itself is served by the deployed app at `/og/publication/{key}.svg`; // imgproxy rasterizes and caches the 1200×630 PNG that the cover blob // points at. Best-effort: if the fetch fails we proceed without a cover - // rather than aborting init. - let cover: Option = match fetch_publication_cover_png(&pub_cfg.key).await { + // — `cover_synced` stays `false` so the next deploy retries. + let (cover, cover_synced): (Option, bool) = match fetch_publication_cover_png( + &pub_cfg.key, + ) + .await + { Ok((bytes, mime)) => match client.upload_blob(bytes, &mime).await { - Ok(blob) => Some(blob), + Ok(blob) => (Some(blob), true), Err(e) => { eprintln!( "Warning: cover upload failed for '{}': {e}. Proceeding without cover.", pub_cfg.key ); - None + (None, false) } }, Err(e) => { @@ -355,7 +371,7 @@ async fn init_publication( "Warning: could not fetch generated cover for '{}': {e}. Proceeding without cover.", pub_cfg.key ); - None + (None, false) } }; @@ -378,10 +394,11 @@ async fn init_publication( pub_cfg.at_uri = Some(response.uri.clone()); pub_cfg.at_cid = Some(response.cid.clone()); + pub_cfg.cover_synced = cover_synced; save_config(config_path, &cfg)?; println!( - "Publication '{key}' synced: uri={} cid={}", + "Publication '{key}' synced: uri={} cid={} cover_synced={cover_synced}", response.uri, response.cid ); Ok(()) @@ -443,26 +460,38 @@ async fn sync_one( let fm: BlogFrontMatter = serde_yaml::from_str(yaml).map_err(|e| eyre!("Invalid frontmatter: {e}"))?; - // Document syncing has no date cutoff — every historical post gets a - // `site.standard.document` on first deploy. The cutoff below only gates - // the Bluesky post: historical posts publish to standard.site only, - // recent posts also get a Bluesky post. - let outcome = classify_blog_post(&fm); - if matches!(outcome, SyncOutcome::Skip) { - return Ok(SyncOutcome::Skip); - } - // Filter by `publication` field — only sync posts that belong to this publication. if fm.publication != pub_cfg.key { return Ok(SyncOutcome::Skip); } - let is_historical = fm.date < bsky_post_cutoff(); + let pub_cid = pub_cfg + .at_cid + .as_deref() + .ok_or_else(|| eyre!("publication at_cid missing"))?; + let pub_uri = pub_cfg + .at_uri + .as_deref() + .ok_or_else(|| eyre!("publication at_uri missing"))?; + let pub_ref = StrongRef { + uri: pub_uri.to_string(), + cid: pub_cid.to_string(), + }; - // Historical post that already has its document synced — terminal state. - // (`BskyOnly` means atproto_uri set + bsky_url unset; for historical posts - // we never want to write a bsky_url, so this IS the desired final state.) - if is_historical && matches!(outcome, SyncOutcome::BskyOnly) { + let is_historical = fm.date < bsky_post_cutoff(); + let doc_exists = fm.atproto_uri.is_some(); + let doc_pinned_to_current_pub = fm.atproto_pub_cid.as_deref() == Some(pub_cid); + let bsky_exists = fm.bsky_url.is_some(); + + // What needs doing for this post? + // - Document put: any time the doc doesn't exist OR its pinned pub cid has drifted. + // - Bsky post: only when the post is recent (>= cutoff) and we don't have a bsky_url. + // When we DO need a bsky post but the doc is already current, we still re-put the + // doc so we have a fresh strong-ref to attach to the bsky post. + let need_bsky_post = !bsky_exists && !is_historical; + let need_doc_put = !doc_exists || !doc_pinned_to_current_pub || need_bsky_post; + + if !need_doc_put && !need_bsky_post { return Ok(SyncOutcome::Skip); } @@ -474,17 +503,8 @@ async fn sync_one( let ast = MarkdownAst::from_str(&content)?; let description: String = ast.0.plain_text().chars().take(100).collect(); - let pub_ref = StrongRef { - uri: pub_cfg - .at_uri - .clone() - .ok_or_else(|| eyre!("publication at_uri missing"))?, - cid: pub_cfg - .at_cid - .clone() - .ok_or_else(|| eyre!("publication at_cid missing"))?, - }; - + // Re-put the document with the current publication strong-ref. Idempotent + // on rkey; produces a fresh doc cid we attach to the bsky post if needed. let record = build_document_record(&fm, &pub_ref, post_url.clone(), description.clone()); let doc_response = client.put_document(&blog_rkey, record).await?; let doc_ref = StrongRef { @@ -492,48 +512,45 @@ async fn sync_one( cid: doc_response.cid.clone(), }; - match outcome { - SyncOutcome::Both if is_historical => { - // Document-only for historical posts. Write atproto_uri so future - // runs short-circuit via the BskyOnly + is_historical check above. - let updated = - frontmatter::append_frontmatter_keys(&content, &[("atproto_uri", &doc_ref.uri)]); - write_back(post_path, &updated)?; - Ok(SyncOutcome::Both) - } - SyncOutcome::Both => { - let bsky_response = client - .create_blog_post( - &fm.title, - &post_url, - &description, - vec![pub_ref.clone(), doc_ref.clone()], - ) - .await?; - let bsky_web_url = at_uri_to_web_url(&bsky_response.uri)?; - let updated = frontmatter::append_frontmatter_keys( - &content, - &[("atproto_uri", &doc_ref.uri), ("bsky_url", &bsky_web_url)], - ); - write_back(post_path, &updated)?; - Ok(SyncOutcome::Both) - } - SyncOutcome::BskyOnly => { - let bsky_response = client - .create_blog_post( - &fm.title, - &post_url, - &description, - vec![pub_ref.clone(), doc_ref.clone()], - ) - .await?; - let bsky_web_url = at_uri_to_web_url(&bsky_response.uri)?; - let updated = - frontmatter::append_frontmatter_keys(&content, &[("bsky_url", &bsky_web_url)]); - write_back(post_path, &updated)?; - Ok(SyncOutcome::BskyOnly) - } - SyncOutcome::Skip => Ok(SyncOutcome::Skip), + // Conditionally publish a Bluesky post (only for recent posts without one). + let bsky_web_url_opt = if need_bsky_post { + let bsky_response = client + .create_blog_post( + &fm.title, + &post_url, + &description, + vec![pub_ref.clone(), doc_ref.clone()], + ) + .await?; + Some(at_uri_to_web_url(&bsky_response.uri)?) + } else { + None + }; + + // Frontmatter updates: atproto_uri (if first put), atproto_pub_cid (every + // doc put), bsky_url (if we just created a bsky post). We always write + // atproto_pub_cid since the doc was just put against the current pub_cid. + let mut new_keys: Vec<(&str, &str)> = Vec::new(); + if !doc_exists { + new_keys.push(("atproto_uri", &doc_ref.uri)); + } + new_keys.push(("atproto_pub_cid", pub_cid)); + let bsky_web_url = bsky_web_url_opt.unwrap_or_default(); + if !bsky_web_url.is_empty() { + new_keys.push(("bsky_url", &bsky_web_url)); + } + let updated = frontmatter::append_frontmatter_keys(&content, &new_keys); + write_back(post_path, &updated)?; + + // Outcome reporting: distinguish between "both sides changed" and + // "only bsky changed" for the summary printout. A doc-only re-put + // (drift refresh) reports as Both since we did write doc-side state. + if need_bsky_post && (!doc_exists || !doc_pinned_to_current_pub) { + Ok(SyncOutcome::Both) + } else if need_bsky_post { + Ok(SyncOutcome::BskyOnly) + } else { + Ok(SyncOutcome::Both) } } @@ -584,6 +601,7 @@ mod tests { collection: "site.standard.document".to_string(), at_uri: None, at_cid: None, + cover_synced: false, } } @@ -600,6 +618,7 @@ mod tests { tags: vec![], author: None, atproto_uri: None, + atproto_pub_cid: None, publication: "blog".to_string(), } } From a7ef6c6806b6e0cf9f0f897f4d0d9c70959b7b9e Mon Sep 17 00:00:00 2001 From: Byte Date: Fri, 5 Jun 2026 09:16:12 -0400 Subject: [PATCH 3/3] fmt: rustfmt the init_publication cover branch Earlier cargo fmt apparently didn't apply this hunk locally. CI's rustfmt flagged the multi-line match form; condensed back to the standard `match expr { ... }` shape. Co-Authored-By: Claude Opus 4.7 --- server/src/commands/standard_site.rs | 33 +++++++++++++--------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/server/src/commands/standard_site.rs b/server/src/commands/standard_site.rs index 92b0c2ff..ca069d1a 100644 --- a/server/src/commands/standard_site.rs +++ b/server/src/commands/standard_site.rs @@ -351,29 +351,26 @@ async fn init_publication( // imgproxy rasterizes and caches the 1200×630 PNG that the cover blob // points at. Best-effort: if the fetch fails we proceed without a cover // — `cover_synced` stays `false` so the next deploy retries. - let (cover, cover_synced): (Option, bool) = match fetch_publication_cover_png( - &pub_cfg.key, - ) - .await - { - Ok((bytes, mime)) => match client.upload_blob(bytes, &mime).await { - Ok(blob) => (Some(blob), true), + let (cover, cover_synced): (Option, bool) = + match fetch_publication_cover_png(&pub_cfg.key).await { + Ok((bytes, mime)) => match client.upload_blob(bytes, &mime).await { + Ok(blob) => (Some(blob), true), + Err(e) => { + eprintln!( + "Warning: cover upload failed for '{}': {e}. Proceeding without cover.", + pub_cfg.key + ); + (None, false) + } + }, Err(e) => { eprintln!( - "Warning: cover upload failed for '{}': {e}. Proceeding without cover.", - pub_cfg.key - ); - (None, false) - } - }, - Err(e) => { - eprintln!( "Warning: could not fetch generated cover for '{}': {e}. Proceeding without cover.", pub_cfg.key ); - (None, false) - } - }; + (None, false) + } + }; let record = PublicationRecord { record_type: "site.standard.publication".to_string(),