<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ruslan Hrekov</title>
    <description>The latest articles on DEV Community by Ruslan Hrekov (@ruslan_hrekov_d296523b326).</description>
    <link>https://dev.to/ruslan_hrekov_d296523b326</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4153403%2Fd4606199-5154-429f-85e5-48070b3df40a.jpg</url>
      <title>DEV Community: Ruslan Hrekov</title>
      <link>https://dev.to/ruslan_hrekov_d296523b326</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ruslan_hrekov_d296523b326"/>
    <language>en</language>
    <item>
      <title>Shipping the cross-poster</title>
      <dc:creator>Ruslan Hrekov</dc:creator>
      <pubDate>Thu, 01 Oct 2026 04:37:52 +0000</pubDate>
      <link>https://dev.to/ruslan_hrekov_d296523b326/shipping-the-cross-poster-34hn</link>
      <guid>https://dev.to/ruslan_hrekov_d296523b326/shipping-the-cross-poster-34hn</guid>
      <description>&lt;p&gt;The previous post promised cross-posts to LinkedIn and dev.to "once Sprint 14 wires the automation." Three days later, Sprint 14 shipped and you're reading proof — this post landed on both platforms through a cron job the site runs on itself.&lt;/p&gt;

&lt;p&gt;Here's what shipping that pipeline looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The itch
&lt;/h2&gt;

&lt;p&gt;Cross-posting manually dies at the third post. Open LinkedIn, strip the markdown, re-check character count, paste. Open dev.to, remember the canonical URL field, paste the body, slugify the tags, confirm. Each post is fifteen minutes of friction between "I wrote something" and "the people who follow me on those platforms can read it." After two tries you stop writing instead of fixing it.&lt;/p&gt;

&lt;p&gt;The fix had to clear a lower bar than you'd think: it had to work, land in the correct places, and not accidentally publish the same post twice. The rest is polish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;One decision gated everything: no dedicated service, no Zapier, no VPS. The site already deploys on Vercel and the code already lives on GitHub, so the pipeline had to live inside the repo. GitHub Actions free tier cron, scripts in the same language as the site, state in a committed file. If the cron ever misbehaves, I want &lt;code&gt;git log&lt;/code&gt; to tell me when and why.&lt;/p&gt;

&lt;p&gt;That ruled out LinkedIn's partner-reviewed Community Management API (multi-week approval gate for a solo-developer app) and ruled in the self-serve &lt;code&gt;w_member_social&lt;/code&gt; scope targeting the legacy &lt;code&gt;/v2/ugcPosts&lt;/code&gt; endpoint. Different surface, same posting outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Three hundred lines of orchestrator (&lt;code&gt;scripts/publish-due.mjs&lt;/code&gt;) read &lt;code&gt;content/blog/schedule.yml&lt;/code&gt;, cross-reference &lt;code&gt;.distribution-ledger.jsonl&lt;/code&gt;, and when a scheduled post is due and not already published, hand it to the matching publisher — &lt;code&gt;lib/distribution/devto.mjs&lt;/code&gt; or &lt;code&gt;lib/distribution/linkedin.mjs&lt;/code&gt;. On success, three things happen in a fixed order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Append to the ledger (&lt;code&gt;{slug, channel, publishedUrl, remoteId, ts}&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Mutate the post's frontmatter (&lt;code&gt;status: posted&lt;/code&gt;, &lt;code&gt;publishedUrl&lt;/code&gt;, &lt;code&gt;postedAt&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Commit and push with &lt;code&gt;[skip ci]&lt;/code&gt; so Vercel still rebuilds but the cron origin is filterable in &lt;code&gt;git log&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ordering is non-negotiable. If step 2 crashes, step 1 already recorded the publish — the next cron tick sees the ledger entry and skips the slug instead of double-posting. Reversing to frontmatter-first means a crash leaves the post un-marked-as-published; the next tick re-publishes.&lt;/p&gt;

&lt;p&gt;The ledger is checked into git. Had to catch that one in review — my first draft had &lt;code&gt;.distribution-ledger.jsonl&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt; because the filename looks like local state. GitHub Actions runners are ephemeral; an ignored ledger would see empty state on every tick and re-publish every past-scheduled post. The file isn't local state; it's the durable idempotency guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three traps I almost shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/rest/posts&lt;/code&gt; vs &lt;code&gt;/v2/ugcPosts&lt;/code&gt;.&lt;/strong&gt; The LinkedIn docs lead you to the newer &lt;code&gt;/rest/posts&lt;/code&gt; under Community Management API. Looks modern, has nice typed schemas. Returns 403 with a self-serve token because that surface is partner-review-gated. The legacy UGC endpoint with &lt;code&gt;X-Restli-Protocol-Version: 2.0.0&lt;/code&gt; (and no &lt;code&gt;LinkedIn-Version&lt;/code&gt; header) is what self-serve apps actually get. A full afternoon vanished before I noticed the two docs sets live in parallel universes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refresh tokens don't exist on self-serve.&lt;/strong&gt; The access token is sixty days. The weekly refresh worker I wrote assumed a refresh token would arrive — none did. Not a bug; an empirical limit of the &lt;code&gt;w_member_social&lt;/code&gt; scope. Fix was a graceful no-op with a cliff-countdown warning, and a calendar reminder two weeks before expiry to re-run the OAuth handshake manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Company Pages aren't optional, but they're one-time.&lt;/strong&gt; My app wouldn't bind to a personal profile; LinkedIn's post-2019 verification chain wants a Page as the ownership anchor. Created a minimal Page in fifteen minutes, bound the app, then never used the Page again. All posts go to the personal profile via &lt;code&gt;urn:li:person:XKuTKDehCv&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs now
&lt;/h2&gt;

&lt;p&gt;Hourly on &lt;code&gt;15 * * * *&lt;/code&gt;. Twenty-one seconds end-to-end on a cold GitHub Actions runner. On ticks with nothing due, the output is one line — &lt;code&gt;[publish-due] nothing due at &amp;lt;iso-timestamp&amp;gt;&lt;/code&gt; — and the job exits clean. On ticks with a due post, you see the publish lines, the devlog entry, and a cron commit landing in &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first live run shipped the previous post, &lt;a href="https://dev.to/blog/launching-the-journal"&gt;Launching the journal&lt;/a&gt;, to both platforms. That post went live manually because I needed to validate the renderers against real APIs before trusting the cron. This post is the first one going through the full automated path — authored, scheduled, pushed, published by the machine, with a commit back on &lt;code&gt;main&lt;/code&gt; to prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this post itself proves
&lt;/h2&gt;

&lt;p&gt;You're reading a Build Log about a cross-posting pipeline. If you found it on dev.to or LinkedIn, that pipeline is what put it there — the schedule-driven path with no manual step between my &lt;code&gt;git push&lt;/code&gt; and your scroll.&lt;/p&gt;

&lt;p&gt;The whole thing is in the open: three hundred lines of orchestrator at &lt;a href="https://github.com/ruslan4427/portfolio/blob/main/scripts/publish-due.mjs" rel="noopener noreferrer"&gt;scripts/publish-due.mjs&lt;/a&gt;, four invariants locked into &lt;a href="https://github.com/ruslan4427/portfolio/blob/main/STABLE_LOGIC.md" rel="noopener noreferrer"&gt;STABLE_LOGIC.md&lt;/a&gt;, the full ship retro at &lt;a href="https://github.com/ruslan4427/portfolio/blob/main/DEVLOG.md" rel="noopener noreferrer"&gt;DEVLOG.md&lt;/a&gt;. Clone the repo, read the cron, run the dry-run — the pipeline isn't a demo of a clever idea, it's the thing that made this very post appear in two more places than I had to type.&lt;/p&gt;

</description>
      <category>meta</category>
      <category>automation</category>
      <category>distribution</category>
    </item>
    <item>
      <title>Launching the journal</title>
      <dc:creator>Ruslan Hrekov</dc:creator>
      <pubDate>Thu, 01 Oct 2026 02:27:51 +0000</pubDate>
      <link>https://dev.to/ruslan_hrekov_d296523b326/launching-the-journal-3fh5</link>
      <guid>https://dev.to/ruslan_hrekov_d296523b326/launching-the-journal-3fh5</guid>
      <description>&lt;p&gt;The site had five case studies and no place to put anything shorter. Everything I wanted to write about — a debugging trace, a pattern that keeps paying off, a skeptical read on a tool the timeline is excited about — needed the same weight as a full case study or it got dropped. That's the wrong shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/blog&lt;/code&gt; supports four formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case Study&lt;/strong&gt; — long-form, receipts attached, commit hashes referenced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Log&lt;/strong&gt; — chronological, dated, mid-work. This post is one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field Note&lt;/strong&gt; — skeptical take on something popular. Short, arguable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern&lt;/strong&gt; — a rule I keep applying, why, and when it breaks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each post lands with reading time, tags, and (where relevant) a &lt;strong&gt;Receipts&lt;/strong&gt; aside — links to the actual commits, prompts, or artifacts behind the claims. If I can't cite it, I don't write it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;MDX with frontmatter validated at build time (invalid frontmatter throws).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/rss.xml&lt;/code&gt; is generated from the same source with CDATA descriptions.&lt;/li&gt;
&lt;li&gt;Google Search Console meta tag ships via env var, so the feed shows up.&lt;/li&gt;
&lt;li&gt;Analytics: GA4 with IP anonymisation, off by default for EU visitors until you click accept. &lt;code&gt;proxy.ts&lt;/code&gt; (Next.js 16's edge middleware convention) reads Vercel's &lt;code&gt;x-vercel-ip-country&lt;/code&gt; header and sets a &lt;code&gt;geo-eu&lt;/code&gt; cookie so the client knows which path to render.&lt;/li&gt;
&lt;li&gt;No third-party tracking beyond that. No newsletter form yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;First real posts land over the next two weeks — one Field Note on prompt patterns that stopped working when I moved from Sonnet to Opus, one Build Log on Sprint 11 itself, and a Pattern write-up of &lt;code&gt;STABLE_LOGIC.md&lt;/code&gt; (the file that keeps AI collaboration from unwinding your own fixes).&lt;/p&gt;

&lt;p&gt;Subscribe via &lt;a href="///rss.xml"&gt;RSS&lt;/a&gt;, or wait for the LinkedIn + dev.to cross-posts — Sprint 14 wires the schedule-driven automation that lands this very post on both platforms.&lt;/p&gt;

</description>
      <category>meta</category>
      <category>portfolio</category>
    </item>
  </channel>
</rss>
