feat: LinkedIn POSSE syndication for blog, newsletter, podcast - #398
feat: LinkedIn POSSE syndication for blog, newsletter, podcast#398byte-the-bot wants to merge 3 commits into
Conversation
…19f5318e76184459) Mirrors the Bluesky POSSE pipeline for LinkedIn: - New `publish-linkedin --kind <blog|newsletter|podcast> --dir <path>` command scans for items past the 2026-05-23 cutoff missing `linkedin_url:` in frontmatter, posts to LinkedIn's /rest/posts, writes the URN back as the new `linkedin_url:` field. - Custom post body: sibling `linkedin.md` file wins, then frontmatter `linkedin_content`, otherwise first paragraph of the body. Footer is always "New post on coreyja.com\n<canonical-url>". - OAuth tokens stored encrypted in new `LinkedInUsers` table (mirrors the Google integration). Lazy refresh at publish time; proactive 6h cron job (`RefreshLinkedInToken`) refreshes 7d before expiry and warns 30d before refresh-token expiry. - New `/admin/auth/linkedin` and `/admin/auth/linkedin/callback` admin routes complete the LinkedIn OAuth flow. CSRF protection uses a DB-backed `LinkedInOauthStates` table (matches existing `LinearOauthStates` pattern; avoids new dependencies that the plan's signed-cookie approach would require). - Admin dashboard shows LinkedIn auth status with expiry warnings. - New `.github/workflows/linkedin.yml` runs after successful Fly Deploy, publishes each kind with `continue-on-error: true`, commits the frontmatter updates back to main via App token, surfaces failures as a red workflow run for the next deploy to retry. - `LinkedInConfig::from_env_optional()` returns None when both env vars are unset (boot proceeds), Some when both set, Err when exactly one is set (prevents silent misconfiguration). - Posts crate filters sibling `linkedin.md` / `*.linkedin.md` files from `BlogPosts::from_dir` and `PodcastEpisodes::from_dir` so they're not parsed as posts at compile time. Notable deviations from the plan: - CSRF state uses a DB table (`LinkedInOauthStates`) instead of signed cookies. The existing codebase has no `SignedCookieJar` usage and Linear OAuth already uses this DB-state pattern. - Posts crate's `linkedin_content` doc comment uses `#[allow(clippy::doc_markdown)]` at module scope rather than backticking every "LinkedIn" mention. Required repo secrets before this workflow first runs: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, DATABASE_URL, ENCRYPTION_SECRET_KEY.
- OAuth state: enforce 10-minute TTL on callback validation, sweep stale
rows opportunistically, add idx_linkedin_oauth_states_created_at index
to support future cleanup jobs (mirrors LinearOauthStates pattern).
- Race-guard: log orphan URN/URL at error! level instead of warn! so the
LinkedIn post we just created can be recovered from workflow logs. Use
authoritative frontmatter parse (not substring scan) to detect a race
in publish_and_write so body text mentioning "linkedin_url:" can't
trigger a false-positive that orphans the post.
- classify_*: drop content.contains("linkedin_url:") early-out; the
parsed-frontmatter check is the authoritative idempotency signal and
the substring would false-positive against body text.
- linkedin_auth: use let-else for the Option<LinkedInConfig> branch to
match the symmetric handling in linkedin_auth_callback.
- from_env_optional tests: consolidate three env-mutating tests into one
sequential function so parallel cargo test doesn't race env vars.
Also covers the previously-untested "only secret set" partial case.
- LinkedInUserRow: drop broad #[allow(dead_code)] — all fields are read.
bc8622a to
2758565
Compare
`linkedin_url` in the frontmatter is the only record that a post already exists on LinkedIn. The commit step did a bare `git pull --rebase` and `git push` with no failure handling, so a rejected push silently dropped that record and the next run re-published — a duplicate on Corey's profile. A rejected push here is expected rather than exceptional: bluesky.yml chains off the same "Fly Deploy" completion, runs under a different concurrency group (`bluesky-publish` vs `linkedin-publish`, so they do not serialize against each other), and also rebases-and-pushes frontmatter to main. Both build a release binary first, so their push windows line up routinely. Retry the rebase-and-push up to 5 times with linear backoff, aborting any partial rebase between attempts. If every attempt fails, emit a `::error::` naming the exact consequence and the manual remedy, since at that point posts exist on LinkedIn with nothing on disk pointing at them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review after 94 days parked — one fix pushed, one decision needed before mergePicked this up off the stale-PR queue (oldest CLEAN+green PR in the backlog). CI green, mergeable, 1 commit behind main. Reviewed the OAuth flow, the refresh job, the idempotency path, and the workflow. Short version: the code is in good shape. One race I fixed, one editorial decision only you can make, and a handful of notes that are fine to merge with. Fixed in a56dc37 — dropped push silently causes duplicate LinkedIn posts
git commit -m "Add linkedin_url to syndicated posts"
git pull --rebase origin main
git pushNo failure handling. A rejected push drops the idempotency record, and the next run re-publishes → duplicate on your professional profile. The part that makes this likely rather than theoretical: Fixed with a bounded rebase-and-push retry (5 attempts, linear backoff, Needs your call before merge — the cutoff date is 94 days stale/// Cutoff date — confirm/update at PR-open time; must be the actual merge day.
const LINKEDIN_CUTOFF_DATE: &str = "2026-05-23";The code's own contract says this must be the merge day. Merging today makes it 94 days early. I checked what actually falls in that window against current So this is a one-line decision, not a spam risk:
I deliberately didn't pick for you — which of your posts goes on your professional profile is editorial. Notes — not blocking, filed so they're known rather than discoveredAt-least-once publishing is structural. The POST happens before the frontmatter write, so any failure in between (write error, or the push failing all 5 retries now) duplicates on retry. The race guard re-reads and re-parses the file after POSTing and logs an orphaned URN at
Refresh-token expiry is a
OAuth state isn't bound to a user —
What I likedThe workflow reasoning is genuinely careful and the comments explain why, which is why this was reviewable at all after three months: VerifiedBranch is mergeable/clean and 1 behind main; full CI green on the pre-fix commit. My change is workflow-YAML only — no Rust touched, so the existing green still stands for the code. YAML re-validated and the shell logic simulated. Recommendation: merge, once you've made the cutoff-date call above. |
|
Heads up on the Lint failure now showing on this PR — it isn't from this branch. It fires in Opened #422 to unblock it (one Everything else on this PR is green: Test stable, Test nightly, Doc, cargo-deny, review_app. |
Review Brief
What changed and why
Adds LinkedIn POSSE syndication for blog posts, weekly newsletters, and podcast episodes, mirroring the existing Bluesky pipeline. A post-Fly-Deploy GitHub Action (
linkedin.yml) scans each content directory, posts new items to Corey's personal LinkedIn profile via the REST/rest/postsAPI, writeslinkedin_url:back into markdown frontmatter, and commits tomainusing the App token. OAuth tokens are stored encrypted in a newLinkedInUserstable (mirroring the Google integration), lazily refreshed at publish time and proactively by a server-side cron job every 6 hours. The existing POSSE pattern — scan → skip-if-already-syndicated → publish → write frontmatter → commit — is preserved exactly.Architecture decisions and trade-offs
SignedCookieJar: The plan specified signed cookies, but the codebase has no existingSignedCookieJarusage. The implementation uses aLinkedInOauthStatestable (same pattern asLinearOauthStates), with a 10-minute TTL enforced at query time. Functionally equivalent CSRF protection, no new dependencies.AppState.linkedin: Option<LinkedInConfig>: Partial config (exactly one env var set) returnsErrat boot. Both-absent returnsOk(None), allowing the Fly container to boot before secrets are added. This prevents the PR from creating a deploy-blocking crash loop.Job::run:RefreshLinkedInToken::runwrapsdo_refreshinif let Err(e) = ... { tracing::error! }and always returnsOk(()). Correct — propagating an error would crash the server viacja::cron::Worker. Token expiry is expected operational state, not exceptional.publish_and_writere-parses frontmatter (not a rawcontains()check) after posting to detect a write race. On race, logs attracing::error!with the orphaned LinkedIn URN/URL so it's recoverable from workflow logs.PublishLinkedin(single capital L) with#[command(name = "publish-linkedin")]explicit annotation, avoiding heck'spublish-linked-inderivation.Risk assessment
LinkedInUsers,LinkedInOauthStates), newAppStatefield (linkedin: Option<LinkedInConfig>), new CLI command, new cron job, new admin routes, new workflow, new optional fields onBlogFrontMatterandPodcastFrontMatter. Server continues to boot and function if LinkedIn secrets are absent. Thefrom_dirsibling-file filter must ship before anylinkedin.mdfiles enter the repo (they're written only by the CLI after it lands, so the ordering is safe).cargo test --workspacepasses (187 server tests), clippy/fmt clean.Option<LinkedInConfig>field means removing the env vars instantly disables all LinkedIn-specific paths without a revert. The workflow can be disabled independently.Spot-check suggestions
server/src/commands/linkedin.rs:publish_and_write— after a detected race the function logs the orphaned URN attracing::error!and returnsOk(false). Confirm the LinkedIn post we just created is indeed loggable-and-recoverable: theurnandweb_urlare included in the error message, so manual cleanup is possible from workflow logs. Good.server/src/http_server/admin/linkedin_auth.rs:linkedin_auth_callback— the DB stateDELETE(which also sweeps stale rows) runs before the token exchange POST. If the exchange fails, the state row is already gone, which is correct (user restarts the flow). Verify this is the intended ordering.server/src/linkedin.rs:refresh_linkedin_token— bothencrypted_access_tokenandencrypted_refresh_token(plus their expiry timestamps) are written back in the UPDATE. Confirmed: the SQL updates all four columns. Correct.server/src/jobs/refresh_linkedin_token.rs:do_refresh— the 30-daytracing::warn!fires whenrefresh_token_expires_at < Utc::now() + 30 days. Confirm this warning is also surfaced in the admin dashboard panel (the plan specifies a visible warning; checkserver/src/http_server/admin/mod.rsfor the< NOW() + INTERVAL '30 days'check).db/migrations/20260523234949_AddLinkedInUsers.down.sql— dropsLinkedInOauthStatesbeforeLinkedInUsers. Neither table references the other via FK, so order is irrelevant, but worth a quick skim to confirm no FK was accidentally added.What the agent verified
cargo test --workspacepasses (187 server tests + posts tests, including newlinkedin::testsandcommands::linkedin::tests)cargo clippy --all-targets --all-features --workspace --tests -- -D warningspassescargo fmt --checkpasses./scripts/auto-fix-all.shproduces no diff.sqlx/offline cache regenerated and committed alongside the migrationSummary
Adds LinkedIn POSSE (Publish on your Own Site, Syndicate Elsewhere) syndication for blog posts, weekly newsletters, and podcast episodes. Mirrors the existing Bluesky pipeline.
linkedin_url:back into the markdown frontmatter, commits tomainvia App token.LinkedInUserstable (mirrors the Google integration). Lazy refresh at publish time + proactive 6h cron job./admin/auth/linkedinand dashboard panel showing expiry warnings.linkedin.mdfile, then frontmatterlinkedin_content, then first paragraph of the markdown body. Footer always "New post on coreyja.com\n".Files
New:
db/migrations/20260523234949_AddLinkedInUsers.{up,down}.sql—LinkedInUsers+LinkedInOauthStatestablesserver/src/linkedin.rs— HTTP client + token refresh +extract_first_paragraph/compose_linkedin_body/linkedin_urn_to_web_urlhelpersserver/src/commands/linkedin.rs—publish-linkedinCLI command + per-kind scanners/classifiers/publishersserver/src/jobs/refresh_linkedin_token.rs— proactive token-refresh cron job (6h interval)server/src/http_server/admin/linkedin_auth.rs—/admin/auth/linkedin+ callback.github/workflows/linkedin.yml— runs after successful Fly Deploy, three publish steps withcontinue-on-errorModified:
posts/src/{blog,podcast}.rs—linkedin_url+linkedin_contentfrontmatter fields, sibling-file filter infrom_dirserver/src/state.rs—linkedin: Option<LinkedInConfig>inAppStateserver/src/{main,cron}.rs,server/src/jobs/mod.rs,server/src/commands/mod.rs,server/src/http_server/{routes,admin/mod,test_helpers}.rs— wire everything upDeviations from the approved plan
SignedCookieJarbut the codebase has no existing usage of it (would require enabling newaxum-extrafeatures). The existing Linear OAuth flow uses a DB-backed state pattern (LinearOauthStates); I mirrored it asLinkedInOauthStates. Functionally equivalent CSRF protection, no new dependencies.#[allow(clippy::doc_markdown)]at file/module scope on the new LinkedIn files instead of backticking every "LinkedIn" mention in doc comments. "LinkedIn" is a proper noun appearing dozens of times in docs; the allow is more pragmatic.server/src/{bluesky,commands/{bluesky,buttondown},http_server/pages/podcast}.rs,posts/src/notes.rswere applied by the project'sscripts/auto-fix-all.sh(cargo fmt). They are pure formatting, not behavior changes.Required repo secrets (set BEFORE workflow first runs)
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET(LinkedIn Developer Portal)DATABASE_URL(Neon connection string — same value Fly uses)ENCRYPTION_SECRET_KEY(same value Fly uses — required to decrypt tokens)APP_ID,APP_PRIVATE_KEY(already used bybluesky.yml)Corey also needs to create the LinkedIn Developer app, add "Sign In with LinkedIn using OpenID Connect" + "Share on LinkedIn" products, set redirect URI to
https://coreyja.com/admin/auth/linkedin/callback, and complete the/admin/auth/linkedinflow once after deploy to seed theLinkedInUsersrow.Pre-merge sanity checks
LINKEDIN_CUTOFF_DATE = "2026-05-23"inserver/src/commands/linkedin.rsis the actual merge day; bump if it slipped.LINKEDIN_VERSION_HEADER = "202602"inserver/src/linkedin.rsis still within the last 6 months at https://learn.microsoft.com/en-us/linkedin/marketing/versioning .Test plan
cargo test --workspacepasses (187 server tests + posts tests all green, including newlinkedin::testsandcommands::linkedin::tests)cargo clippy --all-targets --all-features --workspace --tests -- -D warningspassescargo fmt --checkpasses./scripts/auto-fix-all.shproduces no further diff