All articles

RSS as a cross-site content bridge in Astro SSG

Using RSS as a stateless content contract between two independent Astro static sites, with GitHub Actions repository_dispatch to trigger rebuilds only when Markdown content changes — not on every commit.

Originally published on octa.page ↗

Two static sites. One is a portfolio (christianecg.com), built with Astro and deployed to GitHub Pages. The other is this notebook (octa.page), where active writing happens. The portfolio had a /blog section — but all the articles in it were from 2021–2022. The writing had moved to octa.page and the two were completely siloed.

The technical debt: a blog section showing stale content while the active equivalent existed elsewhere and neither site knew about the other.

Why RSS and not something else

The options were roughly:

  1. Shared CMS or database — overkill for two static sites. Introduces infrastructure neither site needs.
  2. Git submodule or monorepo — couples the two repos, complicates deploys.
  3. Direct fetch from the octa.page source files — would require shared repo access and tight coupling.
  4. RSS — octa.page already exposes /rss.xml. It's a stable contract. Stateless, versionless, requires nothing from the consumer.

RSS wins by elimination. It's also the right abstraction: octa.page doesn't need to know christianecg.com exists. The feed is an implementation detail of octa's public interface, consumed unilaterally.

Build-time consumption in Astro

Astro SSG runs fetch at build time in the page's frontmatter. No runtime, no hydration — the HTML is generated once with whatever the feed returns at that moment.

let octaArticles: Article[] = [];
try {
  const res = await fetch('https://octa.page/rss.xml');
  const xml = await res.text();
  octaArticles = [...xml.matchAll(/([\s\S]*?)<\/item>/g)].flatMap(([, item], i) => {
    const title = item.match(/(.*?)<\/title>/)?.[1]?.trim() ?? '';
    const link  = item.match(/<link>(.*?)<\/link>/)?.[1]?.trim() ?? '';
    const pub   = item.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
    const tags  = [...item.matchAll(/<category>(.*?)<\/category>/g)].map(m => m[1]);
    const date  = pub ? new Date(pub).toISOString().split('T')[0] : '';
    if (!title || !date) return [];
    return [{ id: `octa-${i}`, title, date, excerpt: '', tags, source: 'Octa', url: link, external: true, idx: 0 }];
  });
} catch {
  // RSS fetch failed — show only local articles
}
</code></pre>
<p>A few constraints drive the implementation:</p>
<p><strong>No DOMParser in Node.js.</strong> Browser APIs aren't available in the build context. Regex over the raw XML string is the pragmatic choice for a feed this simple. A proper XML parser (<code>fast-xml-parser</code> etc.) would be justified if the feed had CDATA sections or nested namespaces — this one doesn't.</p>
<p><strong>Graceful degradation is non-negotiable.</strong> A failed <code>fetch</code> during build shouldn't break the site. The <code>try/catch</code> ensures the page builds with only local articles if the feed is unreachable.</p>
<p><strong>Merging and sorting happen in-process.</strong> Local articles and RSS articles are normalized to the same <code>Article</code> type, merged into a single array, and sorted by date. The <code>external</code> flag drives rendering decisions (badge, <code>target="_blank"</code>, <code>↗</code> vs <code>→</code>).</p>
<h2>The freshness problem</h2>
<p>Build-time consumption means the portfolio goes stale as soon as octa.page publishes something new. The obvious mitigation — a scheduled nightly rebuild — has the wrong model: it rebuilds christianecg.com on a fixed interval regardless of whether octa.page changed.</p>
<p>The correct trigger is octa.page's own deploy completing.</p>
<h2>Cross-repo dispatch</h2>
<p>GitHub Actions supports <code>repository_dispatch</code>: an HTTP event that triggers a workflow in another repo. The pattern:</p>
<p><strong>octa.page</strong> dispatches after deploy:</p>
<pre><code class="language-yaml">trigger-christianecg:
  needs: [deploy, check-content]
  if: needs.check-content.outputs.md_changed == 'true'
  runs-on: ubuntu-latest
  steps:
    - name: Trigger christianecg.com rebuild
      run: |
        curl -s -X POST \
          -H "Authorization: token ${{ secrets.CHRISTIANECG_DEPLOY_TOKEN }}" \
          -H "Accept: application/vnd.github.v3+json" \
          https://api.github.com/repos/ChristianECG/christianecg.com/dispatches \
          -d '{"event_type":"octa-deployed"}'
</code></pre>
<p><strong>christianecg.com</strong> listens for the event:</p>
<pre><code class="language-yaml">on:
  push:
    branches: [main]
  workflow_dispatch:
  repository_dispatch:
    types: [octa-deployed]
</code></pre>
<p>The <code>if: needs.check-content.outputs.md_changed == 'true'</code> condition is the key constraint. A style change, config update, or tooling commit on octa.page still triggers a full build and deploy of octa — but doesn't cascade to christianecg.com. The dispatch only fires when <code>.md</code> files are in the diff.</p>
<p>The <code>check-content</code> job detects this:</p>
<pre><code class="language-yaml">check-content:
  runs-on: ubuntu-latest
  outputs:
    md_changed: ${{ steps.check.outputs.md_changed }}
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 2
    - id: check
      run: |
        if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '\.md$'; then
          echo "md_changed=true" >> $GITHUB_OUTPUT
        else
          echo "md_changed=false" >> $GITHUB_OUTPUT
        fi
</code></pre>
<p><code>fetch-depth: 2</code> gives access to <code>HEAD~1</code>. Without it, a shallow clone has no parent to diff against.</p>
<h2>Trade-offs</h2>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Freshness</strong></td>
<td>Near-zero delay when dispatch works. Falls back to manual <code>workflow_dispatch</code> if the PAT expires or the hook fails.</td>
</tr>
<tr>
<td><strong>Coupling</strong></td>
<td>One-directional. octa.page doesn't know christianecg.com exists. christianecg.com depends on the RSS contract, which is stable.</td>
</tr>
<tr>
<td><strong>Failure modes</strong></td>
<td>RSS unreachable at build time → local-only blog, no build failure. Dispatch token expired → stale portfolio until manual rebuild.</td>
</tr>
<tr>
<td><strong>Complexity added</strong></td>
<td>One PAT, one secret, ~20 lines of YAML across two repos.</td>
</tr>
</tbody></table>
<p>The RSS contract is the right boundary. It decouples publication from consumption, keeps both sites independently deployable, and adds no shared infrastructure.</p></div> <footer class="article-footer" data-astro-cid-mvpdthgs> <a href="/en/blog" class="back-link-bottom" data-astro-cid-mvpdthgs> <span data-astro-cid-mvpdthgs>←</span> Back to blog </a> </footer> </article> </div> </main> <footer class="footer" data-astro-cid-sz7xmlte> <div class="container footer-inner" data-astro-cid-sz7xmlte> <div class="footer-brand" data-astro-cid-sz7xmlte> <p class="footer-logo" data-astro-cid-sz7xmlte>cecg</p> <p class="footer-tagline" data-astro-cid-sz7xmlte>Senior Software Engineer</p> <p class="footer-location" data-astro-cid-sz7xmlte>Web developer in Mixquiahuala de Juárez, Hidalgo, Mexico</p> </div> <nav class="footer-cols" aria-label="Footer links" data-astro-cid-sz7xmlte> <div class="footer-col" data-astro-cid-sz7xmlte> <p class="footer-col-label" data-astro-cid-sz7xmlte>About me</p> <ul data-astro-cid-sz7xmlte> <li data-astro-cid-sz7xmlte><a href="/en/press" class="footer-press-link" data-astro-cid-sz7xmlte>Press</a></li> <li data-astro-cid-sz7xmlte><a href="/en/cv" data-astro-cid-sz7xmlte>CV</a></li> <li data-astro-cid-sz7xmlte><a href="/en/timeline" data-astro-cid-sz7xmlte>Timeline</a></li> <li data-astro-cid-sz7xmlte><a href="/en/manifiesto" data-astro-cid-sz7xmlte>Manifesto</a></li> <li data-astro-cid-sz7xmlte><a href="/en/now" data-astro-cid-sz7xmlte>Now</a></li> <li data-astro-cid-sz7xmlte><a href="/en/colophon" data-astro-cid-sz7xmlte>Colophon</a></li> </ul> </div> <div class="footer-col" data-astro-cid-sz7xmlte> <p class="footer-col-label" data-astro-cid-sz7xmlte>Publications</p> <ul data-astro-cid-sz7xmlte> <li data-astro-cid-sz7xmlte><a href="/en/blog" data-astro-cid-sz7xmlte>Blog</a></li> <li data-astro-cid-sz7xmlte><a href="https://ieeexplore.ieee.org/author/37089182389" target="_blank" rel="noopener" data-astro-cid-sz7xmlte>IEEE</a></li> <li data-astro-cid-sz7xmlte><a href="https://datatracker.ietf.org/person/Christian%20El%C3%ADas%20Cruz%20Gonz%C3%A1lez" target="_blank" rel="noopener" data-astro-cid-sz7xmlte>IETF</a></li> <li data-astro-cid-sz7xmlte><a href="/en/api" data-astro-cid-sz7xmlte>API</a></li> </ul> </div> <div class="footer-col" data-astro-cid-sz7xmlte> <p class="footer-col-label" data-astro-cid-sz7xmlte>Contact</p> <ul data-astro-cid-sz7xmlte> <li data-astro-cid-sz7xmlte><a href="https://github.com/ChristianECG" target="_blank" rel="noopener" data-astro-cid-sz7xmlte>GitHub</a></li> <li data-astro-cid-sz7xmlte><a href="https://www.linkedin.com/in/christianeliascg/" target="_blank" rel="noopener" data-astro-cid-sz7xmlte>LinkedIn</a></li> <li data-astro-cid-sz7xmlte><a href="mailto:contacto@christianecg.com" data-astro-cid-sz7xmlte>Email</a></li> </ul> </div> </nav> </div> <div class="footer-bottom" data-astro-cid-sz7xmlte> <div class="container footer-bottom-inner" data-astro-cid-sz7xmlte> <p data-astro-cid-sz7xmlte>© 2026 Christian Elías Cruz González. Made in Hidalgo.</p> <div class="footer-feeds" data-astro-cid-sz7xmlte> <a href="/rss.xml" title="RSS Feed" data-astro-cid-sz7xmlte> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-astro-cid-sz7xmlte> <path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z" data-astro-cid-sz7xmlte></path> </svg>
RSS
</a> <a href="/feed.json" title="JSON Feed" data-astro-cid-sz7xmlte> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-sz7xmlte> <path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" data-astro-cid-sz7xmlte></path><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1" data-astro-cid-sz7xmlte></path> </svg>
JSON Feed
</a> </div> </div> </div> </footer>  <button class="back-to-top" id="back-to-top" aria-label="Back to top" data-astro-cid-sckkx6r4>↑</button> <script type="module">(function(){const o="#5B8CF5",e="#3E4A60",c="#7E8EAB";console.clear(),console.log("%c  cecg  ",`background:${o};color:#fff;font-size:1.4rem;font-weight:800;letter-spacing:0.1em;padding:6px 18px;border-radius:4px`),console.log("%cchristianecg.com",`color:${c};font-size:0.9rem;font-style:italic`),console.log(" "),console.log("%cSenior Software Engineer",`color:${o};font-size:0.85rem;font-weight:600`),console.log("%cFrontend · Mobile · IoT",`color:${e};font-size:0.75rem`),console.log(" "),console.log("%c↑ ↑ ↓ ↓ ← → ← → B A",`color:${c};font-size:0.7rem;letter-spacing:0.05em`),console.log("%c(hay un secreto aquí)",`color:${e};font-size:0.65rem;font-style:italic`)})();</script> <script type="module">const d=window.matchMedia("(prefers-reduced-motion: reduce)").matches,c=window.matchMedia("(pointer: fine)").matches,m=typeof CSS<"u"&&CSS.supports("animation-timeline: scroll()");let i=!1;function u(){const o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(t.target.classList.add("visible"),o.unobserve(t.target))})},{threshold:.08,rootMargin:"0px 0px -48px 0px"});document.querySelectorAll(".reveal:not(.visible)").forEach(e=>o.observe(e)),document.getElementById("back-to-top")?.addEventListener("click",()=>window.scrollTo({top:0,behavior:"smooth"})),c&&!d&&(document.querySelectorAll(".spotlight").forEach(e=>{e.addEventListener("pointermove",t=>{const s=e.getBoundingClientRect();e.style.setProperty("--mx",`${t.clientX-s.left}px`),e.style.setProperty("--my",`${t.clientY-s.top}px`)},{passive:!0})}),document.querySelectorAll(".cs-screenshot img, .project-media .browser-frame").forEach(e=>{e.addEventListener("mousemove",t=>{const s=e.getBoundingClientRect(),a=(t.clientX-(s.left+s.width/2))/(s.width/2),l=(t.clientY-(s.top+s.height/2))/(s.height/2);e.style.transform=`perspective(900px) rotateX(${-l*5}deg) rotateY(${a*5}deg) scale(1.02)`},{passive:!0}),e.addEventListener("mouseleave",()=>{e.classList.add("is-leaving"),e.style.transform="",setTimeout(()=>e.classList.remove("is-leaving"),500)})}))}function p(){if(i)return;i=!0,window.addEventListener("scroll",()=>{document.getElementById("back-to-top")?.classList.toggle("btt-visible",window.scrollY>400);const e=document.getElementById("scroll-hint");if(e&&window.scrollY>60&&(e.style.opacity="0"),!m){const t=document.getElementById("nav-progress");if(t){const s=window.scrollY/(document.body.scrollHeight-window.innerHeight);t.style.width=s*100+"%"}}},{passive:!0});const o=document.getElementById("cursor-glow");o&&c&&(document.addEventListener("mousemove",n=>{o.style.transform=`translate(${n.clientX}px, ${n.clientY}px)`,o.classList.add("visible")},{passive:!0}),document.addEventListener("mouseleave",()=>o.classList.remove("visible")))}let r=!0;function v(){if(r){r=!1;return}const o=location.hostname;if(o==="localhost"||o==="127.0.0.1"||o==="::1")return;const n=document.querySelector("script[data-project]")?.getAttribute("data-project");if(n)try{let e=sessionStorage.getItem("_av_sid");e||(e=Date.now().toString(36)+Math.random().toString(36).slice(2),sessionStorage.setItem("_av_sid",e)),fetch("https://analytics.avelor.es/collect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({project_id:n,url:location.pathname+(location.search||""),referrer:document.referrer||null,session_id:e}),keepalive:!0,credentials:"omit"}).catch(()=>{})}catch{}}document.addEventListener("astro:page-load",()=>{p(),u(),v()});</script> </body> </html>  <script type="module">let a=!1;function s(){function i(){const t=document.getElementById("reading-progress");if(!t)return;const e=document.documentElement.scrollHeight-window.innerHeight;t.style.width=e>0?`${window.scrollY/e*100}%`:"0%"}a||(a=!0,window.addEventListener("scroll",i,{passive:!0})),i();const n=new URLSearchParams(location.search).get("from");if(n){const t=document.getElementById("lang-banner"),e=document.getElementById("lang-banner-text"),o=document.getElementById("lang-banner-dismiss");n==="en"?e.innerHTML='This post is only available in Spanish. <a href="/en/blog">Go back to English</a>.':n==="lat"?e.innerHTML='Hic articulus solum Hispanice scriptus est. <a href="/lat/blog">Redi ad Latinum</a>.':e.textContent="Este artículo solo está disponible en español.",t.removeAttribute("hidden"),o.addEventListener("click",()=>{t.setAttribute("hidden",""),history.replaceState(null,"",location.pathname)},{once:!0})}document.querySelectorAll(".prose pre").forEach(t=>{if(t.querySelector(".copy-btn")||t.querySelector("code.language-mermaid"))return;const e=document.createElement("button");e.className="copy-btn",e.setAttribute("aria-label","Copiar código"),e.textContent="Copiar",t.style.position="relative",t.appendChild(e),e.addEventListener("click",()=>{const o=t.querySelector("code")?.textContent??"";navigator.clipboard.writeText(o).then(()=>{e.textContent="✓",e.classList.add("copied"),setTimeout(()=>{e.textContent="Copiar",e.classList.remove("copied")},2e3)})})})}document.addEventListener("astro:page-load",s);</script> <!-- Mermaid — loaded only when the article has diagrams (octa mirrors) --> <script type="module" src="/_astro/_slug_.astro_astro_type_script_index_1_lang.Dpm6RJ2r.js"></script>