<?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: cynthia wahome</title>
    <description>The latest articles on DEV Community by cynthia wahome (@wamzii).</description>
    <link>https://dev.to/wamzii</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%2F1775793%2F1e9560fe-a2de-4cc0-8d27-2346c18af9aa.png</url>
      <title>DEV Community: cynthia wahome</title>
      <link>https://dev.to/wamzii</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wamzii"/>
    <language>en</language>
    <item>
      <title>Automating Android Play Store Releases, Part 3: The Storage-Quota Wall</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Tue, 22 Sep 2026 22:21:06 +0000</pubDate>
      <link>https://dev.to/wamzii/automating-android-play-store-releases-part-3-the-storage-quota-wall-2663</link>
      <guid>https://dev.to/wamzii/automating-android-play-store-releases-part-3-the-storage-quota-wall-2663</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of a 5-part series on automating a multi-app Android release pipeline. Part 1 → and Part 2 → covered getting the pipeline working — signing, versioning, tracks, release discipline. This part covers what happened when a *finished&lt;/em&gt; pipeline turned out to have a slow-burning cost problem nobody had budgeted for. Part 4 → covers the redesign that followed, and the first real production ship.*&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Weeks after the pipeline in Parts 1 and 2 was working and stable, releases started failing with a message that had nothing to do with code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Failed to CreateArtifact: Artifact storage quota has been hit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub's free-tier Actions storage quota is exactly 500MB, total, org-wide, forever. Our pipeline had been quietly consuming several times that in about ten days. The diagnosis chain: ten duplicate copies of the same Gradle cache (branch-scoped by default, and the real dominant cause), a cache key that had been silently broken — and therefore useless — since it was written, and 30-day retention on release files nobody was actually keeping around that long. The cleanup helped. It wasn't the real fix. The real fix was accepting that this pipeline should never again depend on GitHub's shared minutes or storage at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wall
&lt;/h2&gt;

&lt;p&gt;We'd stopped thinking about this pipeline. That's usually the sign a system is working — until a release run failed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Run actions/upload-artifact@v4
Error: Failed to CreateArtifact: Artifact storage quota has been hit.
Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub's free organization plan includes a fixed Actions storage allowance — not per-repo, org-wide, and small: &lt;strong&gt;500MB, total, forever.&lt;/strong&gt; That number is really the punchline of this entire post — everything below is different ways of finding out what actually filled it. Checking usage properly meant going past the summary numbers and actually listing every artifact and every cache GitHub was holding for this one repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/actions/artifacts &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'.artifacts[].size_in_bytes'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{sum+=$1; n++} END {printf "count=%d total_MB=%.1f\n", n, sum/1024/1024}'&lt;/span&gt;

gh api repos/&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;/actions/caches &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'.actions_caches[] | [.key, .size_in_bytes] | @tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Artifacts alone accounted for nearly two gigabytes — signed release AABs at roughly 70MB apiece, internal test APKs at 20–40MB apiece, accumulated over about two weeks. But a cache-by-cache audit turned up something bigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Dominant Cause: Ten Copies of the Same Cache
&lt;/h2&gt;

&lt;p&gt;The Gradle dependency cache — meant to be &lt;em&gt;one&lt;/em&gt; cache, reused across runs — showed up as &lt;strong&gt;ten separate entries&lt;/strong&gt;, all effectively identical, together accounting for the largest single share of the quota by far. GitHub scopes Actions caches per-branch by default: a cache saved on one branch isn't shared with another, it's a full new copy. Ten active branches meant ten full copies of the same dependency set, sitting in storage simultaneously, none of them expiring on their own because each one kept getting refreshed by ongoing work on its branch.&lt;/p&gt;

&lt;p&gt;This, not the artifact files, was the actual wall. It's also the least visible of everything in this post — nobody sees "10 duplicate caches" in an error message. You only find it by going and actually listing what's in storage instead of assuming you already know.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Cache That Was Never Actually Caching
&lt;/h2&gt;

&lt;p&gt;Digging into &lt;em&gt;why&lt;/em&gt; ten copies existed at all — rather than one cache correctly reused across every branch — turned up a second, compounding bug: the cache key itself was broken.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths live in an &lt;code&gt;android/&lt;/code&gt; directory that is &lt;code&gt;.gitignore&lt;/code&gt;d and only generated later, inside the build script's own prebuild step. Neither file existed yet at the point this cache step ran. &lt;code&gt;hashFiles()&lt;/code&gt; against paths that don't exist returns an empty string every single time — so the key never changed based on real config, every run was silently a cache &lt;strong&gt;miss&lt;/strong&gt; dressed up as a cache step, full re-downloads of every Gradle dependency every single build, and (worse, for the quota) every branch's "identical" cache got saved fresh under the same static key instead of ever being correctly reused. Two bugs, same root: a cache system that looked like it was working, doing none of its actual job, on both counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention Set for "Might Need It Someday," Not "Might Actually Look at It"
&lt;/h2&gt;

&lt;p&gt;The signed release AABs — the 70MB files driving most of the artifact total — had 30-day retention. That number had never been a deliberate choice; it was just Actions' own suggested default, left as-is. The AAB itself gets published to the Play Store in the very same job, seconds after the GitHub copy is uploaded — so the GitHub copy exists purely as a debugging convenience, in case someone needs to grab the exact file that shipped without re-running a build. Nobody had ever actually needed to reach for a 3-week-old copy of one. Thirty days of retention on a large binary that's a debugging convenience, not the deliverable, was pure waste that nobody had sized against what the free storage tier could actually hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Leak That Was Already Closed
&lt;/h2&gt;

&lt;p&gt;Worth naming, if only to be precise about timing: an earlier, unrelated cleanup had already caught and fixed a real duplicate-storage bug — the internal-testing flow used to upload every built APK to &lt;em&gt;both&lt;/em&gt; cloud storage and GitHub Actions artifacts, with the GitHub copy never actually used by anything. That fix landed weeks before this specific wall. What the quota investigation actually found wasn't that bug recurring — it was old, already-orphaned artifacts left over from &lt;em&gt;before&lt;/em&gt; that earlier fix, still sitting in storage because nothing had gone back to clear the backlog once the leak itself was closed. A useful reminder on its own: fixing where a duplicate gets created doesn't retroactively delete duplicates that already exist.&lt;/p&gt;

&lt;p&gt;GitHub also documents a clean pattern for exactly the kind of cache pileup described above: delete a cache the moment the branch or PR it belongs to closes, rather than waiting on an age-based sweep to eventually catch it. Adding that alongside the key fix meant a closed PR's caches stop costing anything the same day, not whenever a weekly cleanup job gets around to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cleanup That Wasn't the Real Fix
&lt;/h2&gt;

&lt;p&gt;Deleting the backlog and tightening retention buys time. It doesn't change the underlying fact: &lt;strong&gt;as long as this pipeline runs on GitHub-hosted infrastructure, it is subject to GitHub-hosted quotas&lt;/strong&gt; — minutes and storage both, shared across the whole org, and rationed the same way EAS's free build queue was rationed back in Part 1. A quiet accumulation of artifacts had just demonstrated that the exact ceiling we moved off of EAS to escape was still there, one layer up.&lt;/p&gt;

&lt;p&gt;The real fix was to stop depending on that shared allowance at all: move the pipeline's actual build job onto a &lt;strong&gt;self-hosted runner&lt;/strong&gt; — infrastructure we control, with disk space and build minutes that aren't rationed by anyone else's billing plan. That's a bigger decision than a cleanup script, and it came with its own first-run surprises, starting with a runner image that turned out to be missing a tool GitHub's hosted images ship by default (&lt;code&gt;/usr/bin/time&lt;/code&gt;, used purely to capture build duration and peak memory for diagnostics) — a small, fast fix once the actual migration was live, but a reminder that "self-hosted" means you now own every assumption a hosted image used to make for you silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Part 3 Leaves Off
&lt;/h2&gt;

&lt;p&gt;The build job no longer runs anywhere GitHub's shared minutes or storage quota can touch it. That closed off the &lt;em&gt;cause&lt;/em&gt; of the wall we hit. It didn't yet close off the &lt;em&gt;symptoms&lt;/em&gt; — the AAB still sitting in GitHub storage after every publish, the retention nobody had sized — and fixing those surfaced one more bug, this time inside the very notification system meant to tell us when something went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next: Part 4 → — killing the artifact-relay design that was quietly reporting real successes as failures, and the first real production ship after all of it.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about the debugging journeys nobody puts in the docs — more at &lt;a href="https://wamzii.com" rel="noopener noreferrer"&gt;wamzii.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>androiddev</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Automating Android Play Store Releases, Part 2: Wrong Track &amp; Broken YAML</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:05:39 +0000</pubDate>
      <link>https://dev.to/wamzii/automating-android-play-store-releases-part-2-wrong-track-broken-yaml-3559</link>
      <guid>https://dev.to/wamzii/automating-android-play-store-releases-part-2-wrong-track-broken-yaml-3559</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of a 5-part series on automating a multi-app Android release pipeline. Part 1 → covered the setup, a signing-fingerprint error, and a version number that was quietly drifting from reality. This part picks up right after versioning was fixed. Part 3 → and Part 4 → cover a real Actions storage-quota crisis and what it took to fix it for good.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;With version numbers finally trustworthy, one of our two apps started shipping cleanly. The other kept failing with a message that had nothing to do with its actual cause. Along the way we also hit a GitHub Actions bug so quiet it doesn't produce an error — it just makes your entire workflow file stop running, silently — and made a structural call that "code is production-ready" and "ship this specific app to the Play Store" needed to be two separate decisions, not one automatic consequence of the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #3: The Play Store Error That Was Lying About Its Cause
&lt;/h2&gt;

&lt;p&gt;With versioning fixed, the student app shipped cleanly. The staff app kept failing — always the same message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;##[error]Release in track targeting no countries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where we burned the most time, because every theory we had was plausible and wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 1:&lt;/strong&gt; the closed-testing track just needs its countries selected. Checked Play Console — countries were already set, and had been from the very first release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 2:&lt;/strong&gt; maybe it's set for one app's track but not the other's, since only staff was failing. Also wrong — nothing had been touched differently between the two apps' track settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 3:&lt;/strong&gt; the API field for a track's country availability must only be settable for certain release states. Also a dead end — that field isn't even writable through Google's own API for anything other than the production track.&lt;/p&gt;

&lt;p&gt;The actual answer only showed up once we stopped guessing and asked Play directly for &lt;em&gt;every&lt;/em&gt; track the staff app had, not just the one our pipeline was configured to publish to. The response, shape preserved but values genericized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"beta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alpha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"&amp;lt;our real internal testing track&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;release label&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"versionCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;real versionCode&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of Google's standard named tracks — &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;beta&lt;/code&gt;, &lt;code&gt;alpha&lt;/code&gt;, &lt;code&gt;internal&lt;/code&gt; — all completely empty, never used. And a fifth, custom-named track we'd created ourselves, holding the &lt;em&gt;actual&lt;/em&gt; live release with real testers and real country availability.&lt;/p&gt;

&lt;p&gt;Our pipeline was configured to publish to &lt;code&gt;alpha&lt;/code&gt;. That track had never had a release, never had countries configured, because nobody had ever actually used it. The countries were never missing — we were shipping to the wrong track entirely, and Google's error message described the empty track's state accurately; it just never occurred to us to question &lt;em&gt;which&lt;/em&gt; track it was talking about.&lt;/p&gt;

&lt;p&gt;The student app never had this problem because its real, active track happened to literally be named &lt;code&gt;alpha&lt;/code&gt;. One coincidence of naming was the only reason half our releases worked and the other half didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; a single shared "which track do we publish to" setting can't survive two apps that don't happen to share a track name. We split it into one variable per app instead of one value everyone assumed would fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #4: The One-Line Bug That Invalidated an Entire Workflow
&lt;/h2&gt;

&lt;p&gt;Fixing "which app failed" reporting in our chat notifications introduced its own bug — one worth calling out because of how completely it hid itself.&lt;/p&gt;

&lt;p&gt;We wanted to record, per app, whether that specific release succeeded or failed, so a mixed result (one app ships, one doesn't) wouldn't read as a blanket failure. The first attempt looked reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;result="${{ failure() &amp;amp;&amp;amp; 'failure' || 'success' }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That workflow run showed &lt;strong&gt;zero jobs&lt;/strong&gt;. Not a failed job — no jobs at all, and the run's name fell back to the literal file path instead of the workflow's actual &lt;code&gt;name:&lt;/code&gt;. That's GitHub's specific signature for "this workflow file could not be parsed," and generic YAML validation (&lt;code&gt;yaml.safe_load()&lt;/code&gt; in Python, or any plain YAML linter) will tell you the file is completely fine, because it is — as YAML.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;success()&lt;/code&gt;, &lt;code&gt;failure()&lt;/code&gt;, &lt;code&gt;cancelled()&lt;/code&gt;, and &lt;code&gt;always()&lt;/code&gt; are &lt;strong&gt;only valid inside an &lt;code&gt;if:&lt;/code&gt; condition&lt;/strong&gt; in GitHub Actions' expression syntax. Use them anywhere else — including inside a &lt;code&gt;run:&lt;/code&gt; command, even wrapped in &lt;code&gt;${{ }}&lt;/code&gt; — and the entire workflow file is invalid, not just the step containing the mistake.&lt;/p&gt;

&lt;p&gt;The tool that actually catches this is &lt;a href="https://github.com/rhysd/actionlint" rel="noopener noreferrer"&gt;&lt;code&gt;actionlint&lt;/code&gt;&lt;/a&gt;, which understands GitHub Actions' schema and expression rules specifically — not a generic YAML parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;actionlint .github/workflows/release-android.yml
&lt;span class="c"&gt;# (zero errors, after switching to two if:-gated steps instead)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is the standard pattern for exactly this situation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result — success&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "ROUTE_RESULT=success" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result — failure&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "ROUTE_RESULT=failure" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Issue #5: One Merge, Two Apps, Zero Control
&lt;/h2&gt;

&lt;p&gt;The last one wasn't a bug — it was a design decision we'd made early and outgrown. Merging a PR into &lt;code&gt;main&lt;/code&gt; triggered a full build-and-publish of &lt;em&gt;both&lt;/em&gt; apps, unconditionally, every time.&lt;/p&gt;

&lt;p&gt;That was fine when both apps usually shipped together. It stopped being fine the moment a fix was scoped to just one of them. Landing the staff-only track fix meant &lt;code&gt;main&lt;/code&gt;'s matrix build would rebuild student too — an app that had &lt;em&gt;already succeeded&lt;/em&gt; and was sitting in Play Console under review. Resubmitting it wouldn't just waste CI time; it would reset a review clock that was already ticking down, for a change that had nothing to do with student at all.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating "code is production-ready" and "publish this app to the Play Store" as the same event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Every pull_request event is preflight-only now — secrets, signing,&lt;/span&gt;
&lt;span class="c1"&gt;# Play-track diagnostics — regardless of branch, regardless of merged status.&lt;/span&gt;
&lt;span class="c1"&gt;# A real build+publish only ever happens via an explicit workflow_dispatch.&lt;/span&gt;
&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
  &lt;span class="s"&gt;github.event_name == 'workflow_dispatch' ||&lt;/span&gt;
  &lt;span class="s"&gt;(github.event_name == 'pull_request' &amp;amp;&amp;amp; github.event.action != 'closed')&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main&lt;/code&gt; is still the production branch — merging to it still means "this is ready." It no longer &lt;em&gt;also&lt;/em&gt; means "and therefore publish every app to Google Play right now." Shipping a specific app became a deliberate action: pick the branch, pick the app, run the workflow. Adding a third app later means adding a third option to that same dropdown — not rewriting the release logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons From Parts 1 and 2
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What broke&lt;/th&gt;
&lt;th&gt;What we learned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local version-state file, gitignored&lt;/td&gt;
&lt;td&gt;Never trust local/ephemeral state for anything CI needs to stay in sync with an external system — query the system directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;versionCode&lt;/code&gt; rejected across an unrelated track&lt;/td&gt;
&lt;td&gt;Google Play's version rule is per-app, across &lt;em&gt;every&lt;/em&gt; track — not scoped to the one you're looking at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Play Console's release label didn't match the real versionCode&lt;/td&gt;
&lt;td&gt;A human-readable label is never the same thing as the field a platform actually enforces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"No countries" error, countries were already set&lt;/td&gt;
&lt;td&gt;When a platform's error message doesn't match reality, get the &lt;em&gt;complete&lt;/em&gt; picture (every track) before trusting any one theory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;failure()&lt;/code&gt; used outside &lt;code&gt;if:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Some expressions are context-restricted; a generic syntax check won't catch a schema violation — use a tool that understands the platform's own rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One merge auto-released two apps&lt;/td&gt;
&lt;td&gt;"Code is integrated" and "ship this specific thing" are different decisions the moment you have more than one independently-releasable unit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Part 2 Leaves Off
&lt;/h2&gt;

&lt;p&gt;Both apps now released independently, on demand, with the actual &lt;code&gt;versionCode&lt;/code&gt; and track resolved from Google Play itself before every build. Landing code on &lt;code&gt;main&lt;/code&gt; was safe by default; shipping to the Play Store was a deliberate, scoped action every time. It felt, at this point, like the pipeline was actually finished.&lt;/p&gt;

&lt;p&gt;It wasn't. A few weeks later, this exact pipeline quietly filled up an entire year's worth of storage quota in about ten days — and the fix wasn't "clean up some files," it was rethinking what GitHub Actions should and shouldn't be trusted to run at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next: Part 3 → — the storage-quota wall, and why the real fix was a self-hosted runner, not a cleanup script.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about the debugging journeys nobody puts in the docs — more at &lt;a href="https://cycy.is-a.dev/" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>androiddev</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Automating Android Play Store Releases, Part 1: Signing &amp; Version Drift</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Sat, 12 Sep 2026 06:48:49 +0000</pubDate>
      <link>https://dev.to/wamzii/automating-android-play-store-releases-part-1-signing-version-drift-4f1k</link>
      <guid>https://dev.to/wamzii/automating-android-play-store-releases-part-1-signing-version-drift-4f1k</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of a 5-part series on automating a multi-app Android release pipeline — from a free-tier build queue to a genuine self-hosted infrastructure migration. This part covers the setup and the first two real incidents. Part 2 → covers a Play Store error that lied about its own cause, a one-line bug that invalidated an entire workflow file, and separating "merged" from "released." Part 3 → and Part 4 → cover a real Actions storage-quota crisis and what it took to fix it for good.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We ship &lt;strong&gt;two separate Android apps&lt;/strong&gt; (staff and student/parent) from &lt;strong&gt;one Expo React Native codebase&lt;/strong&gt;, distinguished by an env var at build time. EAS's free build tier kept running out mid-sprint, and running builds locally instead was frying a developer's laptop for twenty minutes at a time — so the goal was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions. Two things nearly derailed that before the pipeline shipped anything real:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;EAS's free build tier ran out&lt;/strong&gt;, and building locally doesn't scale as a substitute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gitignored local counter decided our version numbers&lt;/strong&gt; — it doesn't survive a fresh CI runner, so we silently shipped &lt;code&gt;v1.0.1-b2&lt;/code&gt; when we meant a release several versions ahead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Play's &lt;code&gt;versionCode&lt;/code&gt; must strictly increase per app, across &lt;em&gt;every&lt;/em&gt; track — not per track.&lt;/strong&gt; An old, forgotten upload on an unrelated track can block every future release until you exceed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play Console's display name isn't the real number.&lt;/strong&gt; A release literally labeled "(9)" had an actual enforced &lt;code&gt;versionCode&lt;/code&gt; of 6 — a human-typed label, not something Play cross-checks against reality.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of that made you wince in recognition, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: Two Apps, One Codebase, One Free-Tier Ceiling
&lt;/h2&gt;

&lt;p&gt;Cyfamod builds a school management platform. Two of its Android apps — one for staff, one for students and parents — ship from a single Expo React Native codebase, switched at build time by a route env var. Same components, same hooks, same infra. Two separate &lt;code&gt;.apk&lt;/code&gt;/&lt;code&gt;.aab&lt;/code&gt; outputs, two separate Play Store listings.&lt;/p&gt;

&lt;p&gt;We started the obvious way: &lt;code&gt;eas build&lt;/code&gt;. It's a great service, right up until the free tier's queue tells you this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'm trying to create a new build, but since we're on the free tier,
I'll have to wait for an available worker.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the build locally instead worked, but running these builds locally takes a lot from a laptop — every local build meant a frozen machine and a developer who couldn't do anything else for twenty minutes. That's not sustainable for two apps that both need regular internal testing builds &lt;em&gt;and&lt;/em&gt; eventual Play Store releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That ceiling is the entire reason this pipeline exists.&lt;/strong&gt; The goal from day one wasn't "add CI on top of EAS" — it was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions, where we weren't rationed by a build queue. Everything in this series — the signing, the versioning, the tracks, the triggers, and eventually a real infrastructure migration — is what it actually takes to replace a managed build service with your own pipeline once you commit to that move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt #1: Internal Testing, Automated
&lt;/h2&gt;

&lt;p&gt;The first real automation target wasn't even the Play Store — it was just getting a testable build in front of the team without anyone's laptop catching fire. The shape that emerged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build APKs for both apps, upload to cloud storage, post download links to the team's chat automatically — internal testing only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build signed AABs and release to the Google Play Store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Simple on paper. Getting there took several real production incidents, in this order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #1: The Signing Fingerprint Nobody Mentioned Up Front
&lt;/h2&gt;

&lt;p&gt;The first time the AAB pipeline actually tried to publish, it failed — and the fix wasn't about GitHub Actions at all. Expo generates and manages your upload keystore for you by default. Google Play, however, needs to know the SHA-1 fingerprint of whatever key signs your &lt;em&gt;production&lt;/em&gt; uploads, in advance, to accept them. Skip that step and your carefully automated pipeline can build a perfectly valid AAB — that Play will reject anyway, because the signature attached to it doesn't match anything Play has been told to trust.&lt;/p&gt;

&lt;p&gt;Once we pulled the real upload-key fingerprint via &lt;code&gt;eas credentials&lt;/code&gt; and pinned it as an expected value the workflow checks &lt;em&gt;before&lt;/em&gt; ever spending time on a Gradle build, this class of failure became a fast, cheap preflight check instead of a wasted 40-minute build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #2: The Version Number Nobody Could Trust
&lt;/h2&gt;

&lt;p&gt;With signing sorted, releases started reaching the actual Play publish step — and immediately hit a wall neither of us expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;##[error]You cannot rollout this release because it does not allow
any existing users to upgrade to the newly added APKs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build that failed this way was several minor versions behind the release we'd actually intended to ship. Here's what was happening: the build script tracked the current version and build number in a JSON file on disk, incrementing it on every build. That file was &lt;code&gt;.gitignore&lt;/code&gt;d — correct for a &lt;em&gt;build artifact&lt;/em&gt;, except we were also using it as the source of truth for &lt;em&gt;what version to ship next&lt;/em&gt;. A fresh GitHub Actions runner has no memory of any previous run. Every real release started that counter over from scratch, confidently building an old version number while genuinely believing it was doing the right thing.&lt;/p&gt;

&lt;p&gt;The fix had two parts, because there were actually two numbers being tracked, and neither belonged where it was living:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The version name&lt;/strong&gt; now comes from the highest-numbered file already committed in our release-notes folder — notes we write by hand anyway, so the version they're named for becomes the source of truth instead of a side effect of when a counter last ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build number (&lt;code&gt;versionCode&lt;/code&gt;)&lt;/strong&gt; — the one that actually matters to Google, and the one with a much sharper rule underneath it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Google Play Rejects an App With "Cannot Roll Out" or "Does Not Allow Existing Users to Upgrade"
&lt;/h3&gt;

&lt;p&gt;If you've landed here from a search engine: this error means Google Play has already seen a &lt;strong&gt;higher &lt;code&gt;versionCode&lt;/code&gt;&lt;/strong&gt; for your package than the one you're currently trying to upload — and it will not let a newer release ship with a &lt;em&gt;lower or equal&lt;/em&gt; number, because that would look like a downgrade to users already on the higher version.&lt;/p&gt;

&lt;p&gt;The part that catches people off guard: &lt;strong&gt;this rule applies per app, across every track — not per track.&lt;/strong&gt; If your internal testing track, or an old one-off upload, or even a build someone did for a pentest, ever used a given &lt;code&gt;versionCode&lt;/code&gt; for your package, Google Play will reject &lt;em&gt;any&lt;/em&gt; new upload to &lt;em&gt;any&lt;/em&gt; track — production, a fresh closed-testing track, doesn't matter — with an equal or lower number. The number is global to the app, not scoped to whichever track's dropdown you happen to be looking at.&lt;/p&gt;

&lt;p&gt;We fixed this by never trusting a local counter (or a human-typed release name — more on that below) again. Before every real build, the pipeline now asks Google directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;androidpublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;androidpublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tracks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;editId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versionCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tracks&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;releases&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;versionCodes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;highestKnownVersionCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;versionCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;versionCodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single query, across &lt;em&gt;every&lt;/em&gt; track the app has, is the only reliable floor for "what number comes next." Nothing else — not our own records, not a display name in the Play Console UI — can be trusted, which brings us to the next surprise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Play Console's Display Name Isn't the Real Number
&lt;/h3&gt;

&lt;p&gt;While debugging the versionCode issue, we found something that would have derailed us if we'd trusted it: Play Console showed a release literally labeled with a build number in its name — implying that number was the real &lt;code&gt;versionCode&lt;/code&gt;. The actual enforced &lt;code&gt;versionCode&lt;/code&gt; on that release, queried directly from the API, was a different, lower number.&lt;/p&gt;

&lt;p&gt;The label is free-text — a name a human typed in, completely decoupled from the number Google actually enforces. It's not malicious, it's just how Play Console's UI works: the name field is yours to fill in however you like, and Play never cross-checks it against the real version. If we'd built our fix around "the console label says N, so ship N+1," we'd have hit the exact same rollout rejection all over again, just at a different number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; if a number matters to an automated pipeline, query the field that's actually enforced. Never infer it from a label a human wrote for other humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Part 1 Leaves Off
&lt;/h2&gt;

&lt;p&gt;Version numbers were now trustworthy — resolved straight from Google's own records, every time, no local state involved. That fixed &lt;em&gt;what&lt;/em&gt; number a release would ship as. It didn't yet fix &lt;em&gt;where&lt;/em&gt; it would ship — which turned out to be its own, much stranger problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next: Part 2 → — a Play Store error message that was actively lying about its own cause, a one-line GitHub Actions bug that invalidated an entire workflow file, and why "code merged to main" and "release this app to the Play Store" had to become two different decisions.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about the debugging journeys nobody puts in the docs — more at &lt;a href="https://cycy.is-a.dev/" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>androiddev</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>We Automated Play Store Releases for Two Android Apps — Here’s What Broke</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Mon, 10 Aug 2026 17:56:31 +0000</pubDate>
      <link>https://dev.to/wamzii/from-manual-apks-to-automated-play-store-releases-every-mistake-we-made-shipping-two-android-apps-2l3n</link>
      <guid>https://dev.to/wamzii/from-manual-apks-to-automated-play-store-releases-every-mistake-we-made-shipping-two-android-apps-2l3n</guid>
      <description>&lt;p&gt;&lt;em&gt;A complete debugging journey through automating a multi-app Android release pipeline — from local builds burning out a free tier, to a Google Play error message that was actively lying to us about its own cause.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;We ship &lt;strong&gt;two separate Android apps&lt;/strong&gt; (staff and student/parent) from &lt;strong&gt;one Expo React Native codebase&lt;/strong&gt;, distinguished by an env var at build time. EAS's free build tier kept running out mid-sprint, and running builds locally instead was frying a developer's laptop for twenty minutes at a time — so the actual goal was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions, where we already had free CI minutes. Along the way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;EAS's free build tier ran out&lt;/strong&gt; — building locally on a laptop instead doesn't scale either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gitignored local counter decided our version numbers&lt;/strong&gt; — it doesn't survive a fresh CI runner, so we silently shipped &lt;code&gt;v1.0.1-b2&lt;/code&gt; when we meant &lt;code&gt;v1.1.1-b10&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Play's &lt;code&gt;versionCode&lt;/code&gt; must strictly increase per app, across &lt;em&gt;every&lt;/em&gt; track — not per track.&lt;/strong&gt; An old, forgotten upload on an unrelated track can block every future release until you exceed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Release in track targeting no countries" was not a countries problem.&lt;/strong&gt; It took us three wrong theories to find out our pipeline was quietly publishing to an empty, unused Play track — while the real one, with real testers, sat one dropdown away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;success()&lt;/code&gt; and &lt;code&gt;failure()&lt;/code&gt; only work inside an &lt;code&gt;if:&lt;/code&gt; condition in GitHub Actions.&lt;/strong&gt; Use them anywhere else and your entire workflow file becomes invalid — not just the step you wrote it in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Code merged to main" and "release this app to the Play Store" are two different decisions.&lt;/strong&gt; Wiring them together meant one app's fix forced a pointless rebuild — and a resubmission mid-review — of an app that had already shipped fine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those six sentences made you wince in recognition, keep reading. (If you just want the finished, genericized workflow, skip straight to the &lt;a href="https://gist.github.com/CynthiaWahome/0dd5c0899b0f11baa6a13967aa3f2be3" rel="noopener noreferrer"&gt;gist&lt;/a&gt; — full YAML plus every required secret and variable, no signup required.)&lt;/p&gt;

&lt;h2&gt;
  
  
  🚨 The Setup: Two Apps, One Codebase, One Free-Tier Ceiling
&lt;/h2&gt;

&lt;p&gt;Cyfamod-SMS is a school management platform. Two of its Android apps — one for staff, one for students and parents — ship from a single Expo React Native codebase, switched at build time by an &lt;code&gt;EXPO_PUBLIC_ROUTE&lt;/code&gt; env var. Same components, same hooks, same infra. Two separate &lt;code&gt;.apk&lt;/code&gt;/&lt;code&gt;.aab&lt;/code&gt; outputs, two separate Play Store listings.&lt;/p&gt;

&lt;p&gt;We started the obvious way: &lt;code&gt;eas build&lt;/code&gt;. It's a great service, right up until the free tier's queue tells you this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'm trying to create a new build, but since we're on the free tier,
I'll have to wait for an available worker.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the build locally instead worked, but:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Running these builds locally takes a lot from my pc&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A laptop is not a build farm. Every local build meant a frozen machine and a developer who couldn't do anything else for twenty minutes. That's not sustainable for two apps that both need regular internal testing builds &lt;em&gt;and&lt;/em&gt; eventual Play Store releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That ceiling is the entire reason this pipeline exists.&lt;/strong&gt; The goal from day one wasn't "add CI on top of EAS" — it was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions, where we already had free CI minutes and weren't rationed by a build queue. Everything in this post — the signing, the versioning, the tracks, the triggers — is what it actually takes to replace a managed build service with your own pipeline once you commit to that move.&lt;/p&gt;

&lt;h2&gt;
  
  
  📦 Attempt #1: Internal Testing, Automated
&lt;/h2&gt;

&lt;p&gt;The first real automation target wasn't even the Play Store — it was just getting a testable build in front of the team without anyone's laptop catching fire. The idea, roughly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I need it automated in a way that when someone pushes to the dev branch, the pipeline will build into an APK file and upload it to S3, then the links to both APK files (staff and student) from S3 will be sent to our build channel automatically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That became the shape of the whole system going forward:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build APKs for both apps, upload to S3, post download links to Discord — internal testing only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build signed AABs and release to the Google Play Store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Simple on paper. Getting there took several real production incidents, in this order.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔐 Issue #1: The Signing Fingerprint Nobody Mentioned Up Front
&lt;/h2&gt;

&lt;p&gt;The first time the AAB pipeline actually tried to publish, it failed — and the fix that came back wasn't about GitHub Actions at all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You need to get the fingerprint from Expo using EAS before you build the AAB.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Expo generates and manages your upload keystore for you by default. Google Play, however, needs to know the SHA-1 fingerprint of whatever key signs your &lt;em&gt;production&lt;/em&gt; uploads, in advance, to accept them. Skip that step and your carefully automated pipeline can build a perfectly valid AAB — that Play will reject anyway, because the signature attached to it doesn't match anything Play has been told to trust.&lt;/p&gt;

&lt;p&gt;Once we pulled the real upload-key fingerprint via &lt;code&gt;eas credentials&lt;/code&gt; and pinned it as an expected value the workflow checks &lt;em&gt;before&lt;/em&gt; ever spending time on a Gradle build, this class of failure became a fast, cheap preflight check instead of a wasted 40-minute build.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧮 Issue #2: The Version Number Nobody Could Trust
&lt;/h2&gt;

&lt;p&gt;With signing sorted, releases started reaching the actual Play publish step — and immediately hit a wall neither of us expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;##[error]You cannot rollout this release because it does not allow
any existing users to upgrade to the newly added APKs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build that failed this way was tagged &lt;code&gt;v1.0.1-b2&lt;/code&gt;. The release we'd actually intended to ship was &lt;code&gt;v1.2.1-b10&lt;/code&gt; — a completely different version, several minor releases ahead.&lt;/p&gt;

&lt;p&gt;Here's what was happening: the build script tracked the current version and build number in a JSON file on disk — &lt;code&gt;local-builds/.version-state.json&lt;/code&gt; — incrementing it on every build. That file was &lt;code&gt;.gitignore&lt;/code&gt;d, which is correct for a &lt;em&gt;build artifact&lt;/em&gt;, except we were also using it as the source of truth for &lt;em&gt;what version to ship next&lt;/em&gt;. A fresh GitHub Actions runner has no memory of any previous run. Every real release started that counter over from scratch, confidently building &lt;code&gt;v1.0.1-b2&lt;/code&gt; while genuinely believing it was doing the right thing.&lt;/p&gt;

&lt;p&gt;The fix had two parts, because there were actually two numbers being tracked, and neither belonged where it was living:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The version name&lt;/strong&gt; (&lt;code&gt;1.2.1&lt;/code&gt;) now comes from the highest-numbered file already committed in our &lt;code&gt;docs/releases/&amp;lt;app&amp;gt;/&lt;/code&gt; folder — release notes we write by hand anyway, so the version they're named for becomes the source of truth instead of a side effect of when the counter last ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build number (&lt;code&gt;versionCode&lt;/code&gt;)&lt;/strong&gt; — this is the one that actually matters to Google, and it's the one with a much sharper rule underneath it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ❓ Why Does Google Play Reject My App With "Cannot Roll Out" or "Does Not Allow Existing Users to Upgrade"?
&lt;/h2&gt;

&lt;p&gt;If you've landed here from a search engine: this error means Google Play has already seen a &lt;strong&gt;higher &lt;code&gt;versionCode&lt;/code&gt;&lt;/strong&gt; for your package than the one you're currently trying to upload — and it will not let a newer release ship with a &lt;em&gt;lower or equal&lt;/em&gt; number, because that would look like a downgrade to users already on the higher version.&lt;/p&gt;

&lt;p&gt;The part that catches people off guard: &lt;strong&gt;this rule applies per app, across every track — not per track.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your &lt;code&gt;internal&lt;/code&gt; testing track, or an old one-off upload, or even a build someone did for a pentest, ever used &lt;code&gt;versionCode 12&lt;/code&gt; for your package, Google Play will reject &lt;em&gt;any&lt;/em&gt; new upload to &lt;em&gt;any&lt;/em&gt; track — your production track, a fresh closed-testing track, doesn't matter — with &lt;code&gt;versionCode ≤ 12&lt;/code&gt;. The number is global to the app. It is not scoped to whichever track's dropdown you happen to be looking at.&lt;/p&gt;

&lt;p&gt;We fixed this by never trusting a local counter (or a human-typed release name — more on that below) again. Before every real build, the pipeline now asks Google directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;androidpublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;androidpublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tracks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;editId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versionCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tracks&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;releases&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;versionCodes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;highestKnownVersionCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;versionCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;versionCodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single query, across &lt;em&gt;every&lt;/em&gt; track the app has, is the only reliable floor for "what number comes next." Nothing else — not our own records, not a display name in the Play Console UI — can be trusted, which brings us to the next surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  🕵️ Why This Was Sneaky: Play Console's Display Name Isn't the Real Number
&lt;/h2&gt;

&lt;p&gt;While debugging the versionCode issue, we found something that would have derailed us if we'd trusted it: Play Console showed a release literally labeled &lt;strong&gt;"Student v1.2.0 (9)"&lt;/strong&gt; — implying &lt;code&gt;versionCode 9&lt;/code&gt;. The actual enforced &lt;code&gt;versionCode&lt;/code&gt; on that release, queried directly from the API, was &lt;strong&gt;6&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;(9)&lt;/code&gt; is a free-text release &lt;em&gt;name&lt;/em&gt; — a label a human typed in, completely decoupled from the number Google actually enforces. It's not malicious, it's just how Play Console's UI works: the name field is yours to fill in however you like, and Play never cross-checks it against the real version. If we'd built our fix around "the console says 9, so ship 10," we'd have hit the exact same rollout rejection all over again, just at a different number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; if a number matters to an automated pipeline, query the field that's actually enforced. Never infer it from a label a human wrote for other humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 Issue #3: The Play Store Error That Was Lying About Its Cause
&lt;/h2&gt;

&lt;p&gt;With versioning fixed, the student app shipped cleanly. The staff app kept failing — always the same message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;##[error]Release in track targeting no countries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where we burned the most time, because every theory we had was plausible and wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 1:&lt;/strong&gt; the closed-testing track just needs its countries selected. Checked Play Console — countries were already set, and had been from the very first release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 2:&lt;/strong&gt; maybe it's set for one app's track but not the other's, since only staff was failing. Also wrong — nothing had been touched differently between the two apps' track settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theory 3:&lt;/strong&gt; the API field for a track's country availability (&lt;code&gt;countryTargeting&lt;/code&gt;) must only be settable for certain release states. Also a dead end — that field isn't even writable through Google's own API for anything other than the production track.&lt;/p&gt;

&lt;p&gt;The actual answer only showed up once we stopped guessing and asked Play directly for &lt;em&gt;every&lt;/em&gt; track the staff app had, not just the one our pipeline was configured to publish to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;com.cyfamod.staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;com.cyfamod.staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"beta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;com.cyfamod.staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alpha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;com.cyfamod.staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;diagnostic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;com.cyfamod.staff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tracks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Cyfamod SMS Staff Beta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1.2.0 (9) - Result PINs nav + freshness fixes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"versionCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"12"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four tracks — &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;beta&lt;/code&gt;, &lt;code&gt;alpha&lt;/code&gt;, &lt;code&gt;internal&lt;/code&gt; — all completely empty, never used. And a fifth, custom-named track, &lt;code&gt;Cyfamod SMS Staff Beta&lt;/code&gt;, holding the &lt;em&gt;actual&lt;/em&gt; live release with real testers and real country availability.&lt;/p&gt;

&lt;p&gt;Our pipeline was configured to publish to &lt;code&gt;alpha&lt;/code&gt;. That track had never had a release, never had countries configured, because nobody had ever actually used it. The countries were never missing — we were shipping to the wrong track entirely, and Google's error message described the empty track's state accurately; it just never occurred to us to question &lt;em&gt;which&lt;/em&gt; track it was talking about.&lt;/p&gt;

&lt;p&gt;The student app never had this problem because its real, active track happened to literally be named &lt;code&gt;alpha&lt;/code&gt;. One coincidence of naming was the only reason half our releases worked and the other half didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; a single shared "which track do we publish to" setting can't survive two apps that don't happen to share a track name. We split it into one variable per app (&lt;code&gt;PLAY_STORE_TRACK_STAFF&lt;/code&gt;, &lt;code&gt;PLAY_STORE_TRACK_STUDENT&lt;/code&gt;) instead of one value everyone assumed would fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧨 Issue #4: The One-Line Bug That Invalidated an Entire Workflow
&lt;/h2&gt;

&lt;p&gt;Fixing "which app failed" reporting in our Discord notifications introduced its own bug — one worth calling out because of how completely it hid itself.&lt;/p&gt;

&lt;p&gt;We wanted to record, per app, whether that specific release succeeded or failed, so a mixed result (one app ships, one doesn't) wouldn't read as a blanket failure. The first attempt looked reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;result="${{ failure() &amp;amp;&amp;amp; 'failure' || 'success' }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That workflow run showed &lt;strong&gt;zero jobs&lt;/strong&gt;. Not a failed job — no jobs at all, and the run's name fell back to the literal file path instead of the workflow's actual &lt;code&gt;name:&lt;/code&gt;. That's GitHub's specific signature for "this workflow file could not be parsed," and generic YAML validation (&lt;code&gt;yaml.safe_load()&lt;/code&gt; in Python, or any plain YAML linter) will tell you the file is completely fine, because it is — as YAML.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;success()&lt;/code&gt;, &lt;code&gt;failure()&lt;/code&gt;, &lt;code&gt;cancelled()&lt;/code&gt;, and &lt;code&gt;always()&lt;/code&gt; are &lt;strong&gt;only valid inside an &lt;code&gt;if:&lt;/code&gt; condition&lt;/strong&gt; in GitHub Actions' expression syntax. Use them anywhere else — including inside a &lt;code&gt;run:&lt;/code&gt; command, even wrapped in &lt;code&gt;${{ }}&lt;/code&gt; — and the entire workflow file is invalid, not just the step containing the mistake.&lt;/p&gt;

&lt;p&gt;The tool that actually catches this is &lt;a href="https://github.com/rhysd/actionlint" rel="noopener noreferrer"&gt;&lt;code&gt;actionlint&lt;/code&gt;&lt;/a&gt;, which understands GitHub Actions' schema and expression rules specifically — not a generic YAML parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;actionlint .github/workflows/release-android.yml
&lt;span class="c"&gt;# (zero errors, after switching to two if:-gated steps instead)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is the standard pattern for exactly this situation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result — success&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "ROUTE_RESULT=success" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Record release result — failure&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;failure()&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "ROUTE_RESULT=failure" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚦 Issue #5: One Merge, Two Apps, Zero Control
&lt;/h2&gt;

&lt;p&gt;The last one wasn't a bug — it was a design decision we'd made early and outgrown. Merging a PR into &lt;code&gt;main&lt;/code&gt; triggered a full build-and-publish of &lt;em&gt;both&lt;/em&gt; apps, unconditionally, every time.&lt;/p&gt;

&lt;p&gt;That was fine when both apps usually shipped together. It stopped being fine the moment a fix was scoped to just one of them. Landing the staff-only track fix meant &lt;code&gt;main&lt;/code&gt;'s matrix build would rebuild student too — an app that had &lt;em&gt;already succeeded&lt;/em&gt; and was sitting in Play Console under review. Resubmitting it wouldn't just waste 45 minutes of CI time; it would reset a review clock that was already ticking down, for a change that had nothing to do with student at all.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating "code is production-ready" and "publish this app to the Play Store" as the same event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Every pull_request event is preflight-only now — secrets, signing,&lt;/span&gt;
&lt;span class="c1"&gt;# Play-track diagnostics — regardless of branch, regardless of merged status.&lt;/span&gt;
&lt;span class="c1"&gt;# A real build+publish only ever happens via an explicit workflow_dispatch.&lt;/span&gt;
&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
  &lt;span class="s"&gt;github.event_name == 'workflow_dispatch' ||&lt;/span&gt;
  &lt;span class="s"&gt;(github.event_name == 'pull_request' &amp;amp;&amp;amp; github.event.action != 'closed')&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main&lt;/code&gt; is still the production branch — merging to it still means "this is ready." It no longer &lt;em&gt;also&lt;/em&gt; means "and therefore publish every app to Google Play right now." Shipping a specific app became a deliberate action: pick the branch, pick the app, run the workflow. Adding a third app later means adding a third option to that same dropdown — not rewriting the release logic.&lt;/p&gt;

&lt;p&gt;I genericized the actual workflow file (package names, secret names, and track names swapped for placeholders — the structure and every comment explaining &lt;em&gt;why&lt;/em&gt; are real) into a gist if you want the full shape of it: &lt;strong&gt;&lt;a href="https://gist.github.com/CynthiaWahome/0dd5c0899b0f11baa6a13967aa3f2be3" rel="noopener noreferrer"&gt;per-app Android release pipeline template →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧾 Lessons Learned
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What broke&lt;/th&gt;
&lt;th&gt;What we learned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local &lt;code&gt;.version-state.json&lt;/code&gt;, gitignored&lt;/td&gt;
&lt;td&gt;Never trust local/ephemeral state for anything CI needs to stay in sync with an external system — query the system directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;versionCode&lt;/code&gt; rejected across an unrelated track&lt;/td&gt;
&lt;td&gt;Google Play's version rule is per-app, across &lt;em&gt;every&lt;/em&gt; track — not scoped to the one you're looking at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Play Console's release name said "(9)", real versionCode was 6&lt;/td&gt;
&lt;td&gt;A human-readable label is never the same thing as the field a platform actually enforces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"No countries" error, countries were already set&lt;/td&gt;
&lt;td&gt;When a platform's error message doesn't match reality, get the &lt;em&gt;complete&lt;/em&gt; picture (every track) before trusting any one theory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;failure()&lt;/code&gt; used outside &lt;code&gt;if:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Some expressions are context-restricted; a generic syntax check won't catch a schema violation — use a tool that understands the platform's own rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One merge auto-released two apps&lt;/td&gt;
&lt;td&gt;"Code is integrated" and "ship this specific thing" are different decisions the moment you have more than one independently-releasable unit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 Where It Stands Now
&lt;/h2&gt;

&lt;p&gt;Both apps release independently, on demand, with the actual &lt;code&gt;versionCode&lt;/code&gt; and track resolved from Google Play itself before every build — never from a counter or a label that could quietly drift from reality. Landing code on &lt;code&gt;main&lt;/code&gt; is safe by default; shipping to the Play Store is a deliberate, scoped action every time. Failures for one app no longer masquerade as failures for both in our Discord channel, and a workflow file that can't be parsed shows up as a fast, obvious signal instead of a silent no-op.&lt;/p&gt;

&lt;p&gt;None of this was obvious going in. Most of it wasn't obvious &lt;em&gt;after&lt;/em&gt; the first fix either — three of these six issues required a wrong theory (sometimes two) before the real cause showed up. If you're building something similar and any of these error messages looked familiar on the way in, hopefully this saved you a few of the wrong turns.&lt;/p&gt;

&lt;p&gt;The full, genericized workflow — every step, every gotcha commented inline, plus a complete required-secrets-and-variables checklist — is in this gist: &lt;strong&gt;&lt;a href="https://gist.github.com/CynthiaWahome/0dd5c0899b0f11baa6a13967aa3f2be3" rel="noopener noreferrer"&gt;per-app Android release pipeline template&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The mobile apps discussed here are closed-source, but the backend and web frontend they talk to are public — &lt;a href="https://github.com/Cyfamod-Technologies/school-be-laravel" rel="noopener noreferrer"&gt;school-be-laravel&lt;/a&gt; (Laravel 11, multi-tenant) and &lt;a href="https://github.com/Cyfamod-Technologies/school-fe-nextjs" rel="noopener noreferrer"&gt;school-fe-nextjs&lt;/a&gt; (Next.js 15) power the same Cyfamod School Management System, alongside &lt;a href="https://github.com/Cyfamod-Technologies/school-public-web" rel="noopener noreferrer"&gt;school-public-web&lt;/a&gt; for the public-facing school sites. If you're curious how the pieces fit together, or want to contribute, they're open for it. More on what we build at &lt;a href="https://www.cyfamod.com" rel="noopener noreferrer"&gt;cyfamod.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Hit a version of any of these? I'd genuinely like to hear which theory you tried first — drop it in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write about the debugging journeys nobody puts in the docs — more at &lt;a href="https://cycy.is-a.dev/" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>androiddev</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Supabase Gotchas Nobody Warns You About (Until You Hit Them)</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 20 Mar 2026 17:23:24 +0000</pubDate>
      <link>https://dev.to/wamzii/the-supabase-gotchas-nobody-warns-you-about-until-you-hit-them-2a7g</link>
      <guid>https://dev.to/wamzii/the-supabase-gotchas-nobody-warns-you-about-until-you-hit-them-2a7g</guid>
      <description>&lt;p&gt;Supabase has some of the best developer experience (DX) in the BaaS space right now. DX is shorthand for how pleasant a tool is to work with — the docs, the dashboard, the auto-generated APIs, the speed at which you go from zero to something working. On all of those: Supabase is excellent.&lt;/p&gt;

&lt;p&gt;But excellent DX has a shadow side. When a platform abstracts things this smoothly, it's easy to forget what's running underneath. And what's running underneath Supabase is &lt;strong&gt;PostgreSQL&lt;/strong&gt; — a powerful, strict, battle-tested database that has its own permission system, its own rules, and absolutely no obligation to care how clean your dashboard looks.&lt;/p&gt;

&lt;p&gt;These are the two things that will catch you — usually at the same time, usually showing the same symptom.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;You've set up auth. Users sign up, verify their email, try to log in — and your app shows something like a "pending approval" or "no role assigned" screen even though everything completed correctly.&lt;/p&gt;

&lt;p&gt;Your trigger is firing. Your RLS policies are in place. The Supabase dashboard looks fine.&lt;/p&gt;

&lt;p&gt;Nothing is broken. Nothing is obviously wrong.&lt;/p&gt;

&lt;p&gt;This is the trap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 1: The GRANT That Nobody Told You About
&lt;/h2&gt;

&lt;p&gt;This is the most common question in the Supabase Discord and it's easy to see why — it violates every reasonable expectation.&lt;/p&gt;

&lt;p&gt;When you enable Row Level Security (RLS) on a table in Supabase, you create &lt;strong&gt;policies&lt;/strong&gt; that define who can do what with the rows. A policy might say: "authenticated users can read their own rows." Logical. Clear.&lt;/p&gt;

&lt;p&gt;What the dashboard doesn't shout at you is that &lt;strong&gt;RLS policies and table permissions are two entirely separate systems in PostgreSQL.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the mental model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Analogy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GRANT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Can this role access the table at all?&lt;/td&gt;
&lt;td&gt;The keycard that opens the building&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RLS Policy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which rows can this role see?&lt;/td&gt;
&lt;td&gt;Which floors that keycard can reach&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without a GRANT, the keycard doesn't work. PostgreSQL's PostgREST layer turns you away at the door before RLS even runs. You can have perfectly written policies and they will never be evaluated — because the role was never let in to begin with.&lt;/p&gt;

&lt;p&gt;This is why you can get &lt;code&gt;permission denied for table user_roles&lt;/code&gt; even with the &lt;strong&gt;service_role key&lt;/strong&gt; — the key that's supposed to bypass everything. Without explicit GRANTs, "supposed to bypass everything" turns out to have an asterisk.&lt;/p&gt;

&lt;p&gt;The fix is two lines that most Supabase tutorials never show you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_roles&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Two lines of SQL that most migration guides don't include.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Migration Template You Should Steal
&lt;/h2&gt;

&lt;p&gt;Every time you create a table in Supabase, this is the full pattern. Save it as a GitHub Gist. Tattoo it somewhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Create the table&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_table&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Enable RLS&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_table&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Write your policies&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"authenticated_can_select"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_table&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. THE PART EVERYONE FORGETS&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_table&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_table&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4 is not optional. It's not an edge case. It is required every single time, and the dashboard will not remind you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 60-Second Diagnostic
&lt;/h2&gt;

&lt;p&gt;Hit &lt;code&gt;permission denied&lt;/code&gt; and not sure why? Run these three queries in the Supabase SQL editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Is RLS actually enabled?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table_name'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Do your policies exist?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table_name'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Do GRANTs exist? (this is usually the missing one)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;grantee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;privilege_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_privileges&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your_table_name'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the third query returns nothing for &lt;code&gt;service_role&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; — you found it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 2: The Race Condition Nobody Mentions
&lt;/h2&gt;

&lt;p&gt;This one is subtler and it shows up as a UI flicker — your app briefly flashes an "account pending" or "access denied" screen for a user who is fully verified and approved.&lt;/p&gt;

&lt;p&gt;It's not a database problem. It's a timing problem.&lt;/p&gt;

&lt;p&gt;When a user logs in, your frontend is doing at least two async things at once: confirming the auth session exists, and fetching the user's role from the database. If your UI renders before the role fetch completes, it sees &lt;code&gt;role = null&lt;/code&gt; for a split second and acts accordingly — showing whatever your "no role" fallback state is.&lt;/p&gt;

&lt;p&gt;The common mistake is initialising the loading state as &lt;code&gt;false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This is the trap&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;roleLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRoleLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;false&lt;/code&gt; means "done loading." Your UI reads that and renders immediately, before the role has arrived.&lt;/p&gt;

&lt;p&gt;The fix is using &lt;code&gt;null&lt;/code&gt; as the initial state instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// null = "I genuinely don't know yet — don't render anything"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;roleLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRoleLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; means "ask me later." Your UI holds. The role arrives. The correct screen renders. No flicker.&lt;/p&gt;

&lt;p&gt;It's a one-character change — &lt;code&gt;false&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; — that's the difference between a UI that lies to users for 200ms and one that waits until it knows the truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Happens With BaaS Platforms Specifically
&lt;/h2&gt;

&lt;p&gt;Both of these issues share a root cause: &lt;strong&gt;Supabase abstracts so much so well that it's easy to stop thinking about the layer underneath.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The dashboard gives you toggles. The client library gives you clean methods. The auto-generated APIs give you something that feels like it's handling everything. And mostly it is — until it isn't, and the failure is silent, and the symptom looks identical whether the problem is a missing GRANT or a race condition or something else entirely.&lt;/p&gt;

&lt;p&gt;This isn't a criticism. The abstraction is genuinely the point, and it's genuinely good. But every abstraction has a floor. With Supabase, the floor is PostgreSQL — and PostgreSQL has opinions.&lt;/p&gt;

&lt;p&gt;The moment you remember that Supabase is PostgreSQL with excellent packaging, a lot of otherwise mysterious behaviour starts making sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Pre-Launch Auth Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship any Supabase auth flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every table has &lt;strong&gt;both&lt;/strong&gt; RLS policies &lt;strong&gt;and&lt;/strong&gt; GRANT statements&lt;/li&gt;
&lt;li&gt;[ ] Role/loading state initialises as &lt;code&gt;null&lt;/code&gt;, not &lt;code&gt;false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Tested clean: run &lt;code&gt;npx supabase db reset --local&lt;/code&gt; from scratch at least once&lt;/li&gt;
&lt;li&gt;[ ] Tested with both &lt;code&gt;anon&lt;/code&gt; key and &lt;code&gt;service_role&lt;/code&gt; key separately&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third one matters more than it looks. If you've only ever run your app against a database that was built incrementally and never torn down, you haven't found all your migration gaps yet. A clean reset is the smoke detector.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Hit a Supabase gotcha that this doesn't cover?&lt;/strong&gt; Drop it in the comments — these things rarely travel alone. 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Backend engineer. I write about the bugs I actually hit. Portfolio: &lt;a href="https://cycy.is-a.dev" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt; 🚀&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Usipoziba Ufa, Utajenga Ukuta — On Technical Debt and the Discipline to Fix It</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Mon, 16 Mar 2026 11:59:47 +0000</pubDate>
      <link>https://dev.to/wamzii/usipoziba-ufa-utajenga-ukuta-on-technical-debt-and-the-discipline-to-fix-it-2h1c</link>
      <guid>https://dev.to/wamzii/usipoziba-ufa-utajenga-ukuta-on-technical-debt-and-the-discipline-to-fix-it-2h1c</guid>
      <description>&lt;p&gt;There's a Swahili proverb that lives in my head rent-free as a developer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Usipoziba ufa, utajenga ukuta.&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;If you don't seal the crack, you'll rebuild the wall.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I was recently doing a technical review of a school management system — a real, live, production codebase built by a team moving fast and shipping features. The kind of project where prototyping decisions become permanent ones, and technical debt quietly accumulates in the walls.&lt;/p&gt;

&lt;p&gt;I could have done the minimum. Fixed what was on the ticket, shipped, moved on. Instead I did what I always do — I kept pulling the thread.&lt;/p&gt;

&lt;p&gt;Here's what I found, and the first principles behind every fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context (Because You Deserve It)
&lt;/h2&gt;

&lt;p&gt;This was a &lt;strong&gt;PHP/Laravel&lt;/strong&gt; backend — a language I was relatively new to at the time. &lt;em&gt;Laravel&lt;/em&gt; is a popular PHP web framework, and &lt;em&gt;Tinker&lt;/em&gt; is its interactive console, like a REPL you can use to query and manipulate the database directly without building a UI. Think of it as a surgical tool for backend diagnosis.&lt;/p&gt;

&lt;p&gt;The system managed multiple schools on one platform (multi-tenancy), had just added a sales agent referral program, and needed a security and UX audit before the next release.&lt;/p&gt;

&lt;p&gt;I wasn't here to judge the original code. Prototyping leaves debt — that's not incompetence, that's software. My job was to leave it better than I found it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Screenshots and PR references: [link to PRs here when publishing]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdghd2ydibkrtsmqv3rg5.png" alt="Closed PR's CynthiaWahome" width="799" height="420"&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3rzsv3m0gztidwfj9s8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi3rzsv3m0gztidwfj9s8.png" alt="Opened PR's CynthiaWahome" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Seal the Crack Before You Need a New Wall
&lt;/h2&gt;

&lt;p&gt;The most critical find: a single helper method with a dangerous fallback.&lt;/p&gt;

&lt;p&gt;The system was supposed to identify which sales agent was making a request by checking their login session. Correct. But if that check failed — instead of stopping — it fell back to reading an &lt;code&gt;agent_id&lt;/code&gt; value straight from the URL.&lt;/p&gt;

&lt;p&gt;Anyone. Any authenticated user. Could type &lt;code&gt;?agent_id=someone-elses-id&lt;/code&gt; in the URL and gain full access to that agent's dashboard, commissions, and payout history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix was one line.&lt;/strong&gt; But the lesson is about what happens when you don't fix it: a crack becomes a wall you eventually have to rebuild entirely — after the breach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;User input is untrusted. Always.&lt;/em&gt; URL parameters, form fields, headers, cookies — all of it can be faked. Never use client-supplied data to make an authorisation decision. This is not a PHP principle. This is not a Laravel principle. It is a law of the internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Never Break the Architecture to Solve One Edge Case
&lt;/h2&gt;

&lt;p&gt;This was the most architectural moment of the whole review.&lt;/p&gt;

&lt;p&gt;The system needed a Global Super Admin — someone who could see all agents across all schools. Problem: every user in this system is physically tied to a specific school in the database. That's the security contract. And it's enforced at the database level, not just in code.&lt;/p&gt;

&lt;p&gt;The tempting fix: make the &lt;code&gt;school_id&lt;/code&gt; column nullable. One migration, problem solved.&lt;/p&gt;

&lt;p&gt;I almost did it. Then I stopped.&lt;/p&gt;

&lt;p&gt;Making that column nullable wouldn't just solve this one case — it would quietly weaken the security guarantee for &lt;em&gt;every&lt;/em&gt; school on the platform. The whole point of the design was that cross-school data leaks are structurally impossible. One nullable exception and that guarantee has a crack in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual solution:&lt;/strong&gt; create a real management entity — "Platform HQ" — and make the Super Admin belong to it. The architecture stays intact. The admin has a valid home in the system. No nullable columns. No broken contracts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Own the strongest room inside the walls. Don't remove the walls.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This transcends PHP, Laravel, any framework. Whenever you're about to modify a database schema to accommodate a single edge case — pause. Ask whether the edge case can be made to fit the existing contract instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Guard the Exit, Not the Entrance
&lt;/h2&gt;

&lt;p&gt;A product decision dressed as a security question: should agents who sign up via Google OAuth be blocked from the dashboard until an admin manually approves them?&lt;/p&gt;

&lt;p&gt;The security case for blocking: obvious. Unverified users shouldn't see sensitive data.&lt;/p&gt;

&lt;p&gt;The product case against: onboarding friction at the highest-intent moment is how you lose users. Someone who just signed up via Google and immediately hits a wall will leave. You've completed their registration and immediately punished them for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution: guard the exit, not the entrance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let them in. Let them see the dashboard. Let them understand the product and start generating referrals. But the payout service — where money actually moves — requires approval before it runs.&lt;/p&gt;

&lt;p&gt;This is applicable everywhere a payment or financial feature sits behind a registration flow. Google OAuth (or any SSO) is a legitimate, convenient way to onboard users. The mistake is assuming that authentication and authorisation are the same thing. They're not. Authentication says &lt;em&gt;who you are&lt;/em&gt;. Authorisation says &lt;em&gt;what you're allowed to do&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Lock the vault. Leave the lobby open.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The Bug That Only Existed in Production
&lt;/h2&gt;

&lt;p&gt;A "Ghost Object" bug — one of the more disorienting things you'll encounter.&lt;/p&gt;

&lt;p&gt;The dashboard "Approve Agent" button returned an error saying the agent wasn't in the right status. I checked directly in Tinker — the status was correct. So why was the controller seeing null?&lt;/p&gt;

&lt;p&gt;Diagnosis via logs showed the entire agent object was empty. The server received the ID from the URL but never fetched the actual record.&lt;/p&gt;

&lt;p&gt;Root cause: the admin routes were wired up manually in a way that bypassed the standard API middleware. &lt;em&gt;Route Model Binding&lt;/em&gt; — the feature that automatically resolves URL parameters into database records — only runs inside that middleware. Without it, the server got an ID and did nothing with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;"Works in local tooling" and "works in the API" are not the same test.&lt;/em&gt; Your REPL, your console, your local scripts — they run in a different context than your actual request lifecycle. When something works in one and fails in the other, look at what the request pipeline does that your tooling skips.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Harden Your Logic Against Its Environment
&lt;/h2&gt;

&lt;p&gt;This one is underrated. We added &lt;code&gt;trim()&lt;/code&gt; and &lt;code&gt;strtolower()&lt;/code&gt; to status comparisons in the data model — not because users were submitting messy input, but because different database engines handle string comparison differently.&lt;/p&gt;

&lt;p&gt;Postgres is strict. MariaDB can be inconsistent about trailing whitespace and case sensitivity depending on configuration. An internal state machine that depends on string equality needs to be immune to the floor beneath it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;Write code that's correct regardless of the environment it runs in.&lt;/em&gt; Defensive logic isn't paranoia. It's acknowledging that you don't control every variable between your code and the database driver.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Came For
&lt;/h2&gt;

&lt;p&gt;I came in to add some UI banners. I left having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Closed a privilege escalation hole&lt;/li&gt;
&lt;li&gt;Preserved a multi-tenancy security contract that almost got weakened&lt;/li&gt;
&lt;li&gt;Fixed a ghost object caused by misconfigured middleware&lt;/li&gt;
&lt;li&gt;Moved an auth check from the wrong door to the right door&lt;/li&gt;
&lt;li&gt;Hardened string comparisons against database inconsistency&lt;/li&gt;
&lt;li&gt;Opened issues, flagged technical debt, and added milestones so none of it gets lost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters. I could have fixed my ticket and left. But I logged everything else I found — as issues, as notes, as future work. Because debt you've seen and documented is manageable. Debt you've seen and ignored is a wall waiting to be rebuilt.&lt;/p&gt;




&lt;p&gt;I'm naturally curious. I'm not afraid of bugs — they're how I learn. And I genuinely enjoy taking a codebase that's been moving fast and making it move more safely.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/Cyfamod-Technologies" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F177118475%3Fs%3D280%26v%3D4" height="280" class="m-0" width="280"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/Cyfamod-Technologies" rel="noopener noreferrer" class="c-link"&gt;
            Cyfamod Technologies · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Cyfamod Technologies has 18 repositories available. Follow their code on GitHub.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The language was PHP. The principles aren't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Usipoziba ufa, utajenga ukuta.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Seal the crack now. Or rebuild the wall later.&lt;/p&gt;




&lt;p&gt;Backend engineer. I write about systems, architecture, and what I learn by poking at things. Portfolio: cycy.is-a.dev 🚀&lt;/p&gt;

</description>
      <category>security</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>It Ran Fine in Production. Then I Wrote the Tests.</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Thu, 12 Mar 2026 21:01:37 +0000</pubDate>
      <link>https://dev.to/wamzii/it-ran-fine-in-production-then-i-wrote-the-tests-1kif</link>
      <guid>https://dev.to/wamzii/it-ran-fine-in-production-then-i-wrote-the-tests-1kif</guid>
      <description>&lt;p&gt;The app worked. Users could log in. Data was saving. Nobody was on fire.&lt;/p&gt;

&lt;p&gt;So naturally, I went looking for the fire.&lt;/p&gt;

&lt;p&gt;I was asked to audit a new &lt;strong&gt;Agent Referral&lt;/strong&gt; feature on a school management system — agents refer schools, earn commissions, the usual. No brief. No checklist. Just &lt;em&gt;"make sure it's fine."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It was not fine. Nothing was visibly broken, but the architecture had been quietly making peace with some genuinely bad decisions. Everything held together by vibes and accumulated runtime state.&lt;/p&gt;

&lt;p&gt;Here's what I found — and why it matters whether you're writing PHP, Python, Go, or whatever language you've convinced yourself won't betray you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Back Door That Was Literally Labelled
&lt;/h2&gt;

&lt;p&gt;There was a helper method — &lt;code&gt;resolveAgent()&lt;/code&gt; — whose job was to figure out which agent was logged in. Two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if the authenticated user is an Agent ✅&lt;/li&gt;
&lt;li&gt;If not — check if the URL has an &lt;code&gt;agent_id&lt;/code&gt; parameter, and if so, load that agent from the database ❌&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is called &lt;strong&gt;Privilege Escalation&lt;/strong&gt;. Any authenticated user — a school admin, a teacher, literally anyone — could type &lt;code&gt;?agent_id=some-uuid&lt;/code&gt; into the URL and gain full access to that agent's dashboard, commissions, and payout history.&lt;/p&gt;

&lt;p&gt;The fix was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're an agent or you get nothing. No fallbacks. No trusting what someone typed in a URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;Never trust the client.&lt;/em&gt; Anything arriving from a browser — URL params, form fields, headers, cookies — can be faked. Assume it is. This isn't a PHP rule or a Laravel rule. It's a law of the internet, as immovable as gravity and significantly less forgiving.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Database That Only Looked Stable
&lt;/h2&gt;

&lt;p&gt;To prove my fix worked, I wrote tests. Ran them. The whole suite detonated before a single test executed — the database couldn't rebuild itself from the migration files.&lt;/p&gt;

&lt;p&gt;Two issues:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue 1:&lt;/strong&gt; A migration tried to set a default value on a &lt;code&gt;TEXT&lt;/code&gt; column. &lt;code&gt;TEXT&lt;/code&gt; is unbounded — think of it as an essay answer sheet with no word limit. The database's response was essentially &lt;em&gt;"I'm not pre-filling every essay box. That's expensive and I refuse."&lt;/em&gt; MariaDB won't allow it. MySQL might let it slide depending on config. They're like twins — 99% identical, except MariaDB read the rulebook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue 2:&lt;/strong&gt; A composite primary key included a &lt;code&gt;nullable&lt;/code&gt; column. A primary key is the database's ID badge for a row. An ID badge that might be blank can't identify anything. The database said no. Correctly.&lt;/p&gt;

&lt;p&gt;The wild part? The production app was running fine. The database had been built incrementally over months, never torn down. My tests used &lt;code&gt;RefreshDatabase&lt;/code&gt; — which destroys everything and rebuilds from scratch every run. That clean slate exposed every shortcut that had been quietly accumulating.&lt;/p&gt;

&lt;p&gt;It's like a building that looks solid because nobody's pulled up the floorboards in years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;Your code isn't portable until it survives a clean build.&lt;/em&gt; If your app only works because the database was assembled "just right" and nobody's had to start fresh — that's not stability, that's a patient time bomb.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Vault and the Clerk
&lt;/h2&gt;

&lt;p&gt;Once the database rejected the defaults, I had to put them somewhere else. Which raised the interesting question: where do defaults &lt;em&gt;belong&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Think of your backend as a bank:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The vault (the database):&lt;/strong&gt; stores things safely. Doesn't think. Doesn't decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The clerk (the application):&lt;/strong&gt; handles logic. Decides what to fill in when nobody specified.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original code was asking the vault to think. The vault refused.&lt;/p&gt;

&lt;p&gt;I moved the defaults to the Model layer — the part of the code that defines what a "Term Summary" actually &lt;em&gt;is&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$attributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'overall_comment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'This student is good.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'principal_comment'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'This student is hardworking.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the application handles the logic. The database just stores. Swap Postgres for MySQL tomorrow — the defaults don't care. Rewrite the frontend entirely — the backend doesn't notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The principle:&lt;/strong&gt; &lt;em&gt;Separation of Concerns.&lt;/em&gt; Each layer minds its own business. The systems that survive change are the ones where nobody's doing someone else's job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tests Are Receipts, Not Optional
&lt;/h2&gt;

&lt;p&gt;I wrote eight tests. One Factory to generate realistic fake data. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 security tests:&lt;/strong&gt; a school admin tries every angle to access an agent's dashboard. Every single one returns &lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 auth tests:&lt;/strong&gt; agents can still register, log in, get rejected with the wrong password. The front door still works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;"I fixed it"&lt;/em&gt; is a belief. Eight green checkmarks are evidence.&lt;/p&gt;

&lt;p&gt;Six months from now when someone asks &lt;em&gt;"are we certain a school admin can't get into agent data?"&lt;/em&gt; — the answer isn't a Slack message. It's a test suite that runs every time someone touches that code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests are documentation that doesn't go stale and can't be misremembered.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Part Worth Keeping
&lt;/h2&gt;

&lt;p&gt;Two things kept surfacing and they're worth separating cleanly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First Principles&lt;/strong&gt; are the physics — the laws that don't change regardless of framework or language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User input is untrusted. Always.&lt;/li&gt;
&lt;li&gt;A primary key that might not exist can't identify anything.&lt;/li&gt;
&lt;li&gt;A database won't efficiently enforce constraints it wasn't built for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design Patterns&lt;/strong&gt; are the blueprints that implement those laws:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero trust → authentication guards, no URL parameter fallbacks&lt;/li&gt;
&lt;li&gt;Defaults the database won't hold → move them to the Model&lt;/li&gt;
&lt;li&gt;Prove it works → Factories and tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is the tool. The principle is why you pick it up.&lt;/p&gt;




&lt;p&gt;Three questions worth asking about every system you touch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who owns this responsibility?&lt;/strong&gt; If the answer isn't obvious, that's the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is actually, fundamentally true here?&lt;/strong&gt; Strip the assumptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you prove it?&lt;/strong&gt; If not, you don't know — you just haven't been wrong yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The recipes change. The physics don't.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Drop a comment — genuinely curious:&lt;/strong&gt; what's the worst "it worked in production" moment you've walked into? The more specific the horror, the better. 👇&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Backend engineer. I write about systems, architecture, and what happens when you poke at things that were better left unexamined. Portfolio: &lt;a href="https://cycy.is-a.dev" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt; 🚀&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The is-a.dev + Vercel Field Guide: Every Pitfall, Mapped</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 06 Mar 2026 10:40:03 +0000</pubDate>
      <link>https://dev.to/wamzii/the-is-adev-vercel-field-guide-every-pitfall-mapped-1g0o</link>
      <guid>https://dev.to/wamzii/the-is-adev-vercel-field-guide-every-pitfall-mapped-1g0o</guid>
      <description>&lt;p&gt;Free &lt;code&gt;.is-a.dev&lt;/code&gt; subdomains are genuinely one of the better things the dev community has built. Clean URL, no cost, no registrar drama. The whole process is submitting a JSON file via GitHub Pull Request.&lt;/p&gt;

&lt;p&gt;Here's the part nobody front-loads: the repo runs &lt;strong&gt;automated CI tests&lt;/strong&gt; on every PR before a human even looks at it. Fail those, and you're already closed before a reviewer blinks. Pass those, and &lt;em&gt;then&lt;/em&gt; a real human volunteer visits your site and reviews your files.&lt;/p&gt;

&lt;p&gt;Two gates. Different failure modes. This guide covers both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 0: Check Availability Before Anything Else
&lt;/h2&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://is-a.dev" rel="noopener noreferrer"&gt;https://is-a.dev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Search your name. Takes 10 seconds. Do it before you get attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub account&lt;/li&gt;
&lt;li&gt;A project &lt;strong&gt;live, deployed, and has actual content&lt;/strong&gt; on Vercel — not a placeholder page&lt;/li&gt;
&lt;li&gt;Vercel dashboard open at &lt;strong&gt;your project → Settings → Domains&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A screenshot of your live site (the PR template requires this)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Fork, Clone, Branch
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/your-github-username/register.git
&lt;span class="nb"&gt;cd &lt;/span&gt;register
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; add-yourname-domain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fresh branch every time. Don't work on &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Your Domain File
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;domains/yourname.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-github-username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-email@example.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"records"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CNAME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cname.vercel-dns.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What the CI tests will catch here
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;record&lt;/code&gt; vs &lt;code&gt;records&lt;/code&gt;&lt;/strong&gt; — The CI schema validation flags &lt;code&gt;record&lt;/code&gt; (singular) immediately. Your PR won't survive automated checks. It's &lt;code&gt;records&lt;/code&gt;, plural, always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalid JSON&lt;/strong&gt; — A missing comma or unclosed brace and the CI fails. Validate at &lt;a href="https://jsonlint.com" rel="noopener noreferrer"&gt;jsonlint.com&lt;/a&gt; before you push.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File in the wrong place&lt;/strong&gt; — Must be inside &lt;code&gt;domains/&lt;/code&gt;. Not the root, not a subfolder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A records must be arrays&lt;/strong&gt; — If you use an A record instead of CNAME, it has to be: &lt;code&gt;"A": ["76.76.21.21"]&lt;/code&gt; — array syntax, even with one value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CNAME + A in the same file&lt;/strong&gt; — They conflict. CI will catch it. Pick one; for Vercel, CNAME is the right call since their IPs can change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-referencing&lt;/strong&gt; — A CNAME that points back to &lt;code&gt;yourname.is-a.dev&lt;/code&gt; will also get caught.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use CNAME over A record for Vercel. &lt;code&gt;cname.vercel-dns.com&lt;/code&gt; is stable long-term.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3: The Vercel Verification Pitfall 🚨
&lt;/h2&gt;

&lt;p&gt;This is what gets people past CI but stopped by the human reviewer.&lt;/p&gt;

&lt;h3&gt;
  
  
  First — get your TXT token from Vercel
&lt;/h3&gt;

&lt;p&gt;Your project needs to already be live and deployed. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Vercel project → Settings → Domains → Add Domain&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Enter &lt;code&gt;yourname.is-a.dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Vercel asks if you want to redirect to &lt;code&gt;www.yourname.is-a.dev&lt;/code&gt; — &lt;strong&gt;do not enable this&lt;/strong&gt; (more on why in a second)&lt;/li&gt;
&lt;li&gt;Select the root domain only → click &lt;strong&gt;"Continue manually"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Copy the TXT string Vercel shows you:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vc-domain-verify=yourname.is-a.dev,sometoken123abc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vercel shows "Invalid Configuration" immediately. That's expected — your PR isn't merged yet. Leave it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where NOT to put the TXT record
&lt;/h3&gt;

&lt;p&gt;The obvious move: add the TXT record into &lt;code&gt;yourname.json&lt;/code&gt; next to the CNAME. Tidy, logical, wrong.&lt;/p&gt;

&lt;p&gt;is-a.dev requires verification records in their &lt;strong&gt;own dedicated file&lt;/strong&gt;. For Vercel, that file is &lt;code&gt;_vercel.yourname.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-github-username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-email@example.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"records"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"TXT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vc-domain-verify=yourname.is-a.dev,your-token-from-vercel"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You end up with &lt;strong&gt;two files&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Does what&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yourname.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Routes traffic to Vercel via CNAME&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_vercel.yourname.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proves ownership to Vercel via TXT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One routes. One verifies. That's the unlock.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3b: The WWW Redirect Trap 🪤
&lt;/h2&gt;

&lt;p&gt;Back in Step 3 when Vercel asks about redirecting to &lt;code&gt;www.yourname.is-a.dev&lt;/code&gt; — here's why you skip it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;www.yourname.is-a.dev&lt;/code&gt; is a completely separate subdomain. It needs its &lt;strong&gt;own JSON file and its own PR&lt;/strong&gt; in the is-a.dev repo. If you enable the redirect without registering &lt;code&gt;www&lt;/code&gt;, Vercel goes looking for a domain it can't find — and your site serves a 404 instead of your portfolio.&lt;/p&gt;

&lt;p&gt;Worse: if you then try to remove the redirect and go back to the root domain, Vercel can get stuck demanding a new TXT verification token for the &lt;code&gt;www&lt;/code&gt; subdomain before it lets you proceed. It's an annoying loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you accidentally clicked it already:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Vercel → Project Settings → Domains&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Find &lt;code&gt;www.yourname.is-a.dev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Delete it 🗑️&lt;/li&gt;
&lt;li&gt;Root domain only stays&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Propagates in about 60 seconds. Back to normal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Submit the PR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add domains/yourname.json domains/_vercel.yourname.json
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat(domain): add yourname.is-a.dev with Vercel CNAME and verification"&lt;/span&gt;
git push origin add-yourname-domain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a PR against &lt;code&gt;is-a-dev/register&lt;/code&gt;. An automated welcome message appears when you do — read it. It tells you exactly what reviewers check. Short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fill in the PR template&lt;/strong&gt; — don't delete it or swap it out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include a screenshot of your live site&lt;/strong&gt; — not optional, reviewers look for it&lt;/li&gt;
&lt;li&gt;Mention the domain is already added in Vercel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reviewers are volunteers. A complete, clean submission is the only reliable fast path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full Pitfall Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What kills your PR&lt;/th&gt;
&lt;th&gt;When it's caught&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;record&lt;/code&gt; not &lt;code&gt;records&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;Fix the key, validate JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid JSON syntax&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;Run through jsonlint.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File outside &lt;code&gt;domains/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;Move it to the right folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A record as a string not array&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"A": ["ip"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAME + A in same file&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;Pick one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAME pointing to itself&lt;/td&gt;
&lt;td&gt;CI — automated&lt;/td&gt;
&lt;td&gt;Don't self-reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT token inside main domain file&lt;/td&gt;
&lt;td&gt;Human reviewer&lt;/td&gt;
&lt;td&gt;Move to &lt;code&gt;_vercel.yourname.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain added to Vercel but shows 404&lt;/td&gt;
&lt;td&gt;Human reviewer&lt;/td&gt;
&lt;td&gt;Add domain first, let Vercel sit in "pending"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No screenshot in PR description&lt;/td&gt;
&lt;td&gt;Human reviewer&lt;/td&gt;
&lt;td&gt;Add one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site is under construction&lt;/td&gt;
&lt;td&gt;Human reviewer&lt;/td&gt;
&lt;td&gt;Finish it, then submit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;www&lt;/code&gt; redirect enabled without registering &lt;code&gt;www&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Human reviewer / post-merge&lt;/td&gt;
&lt;td&gt;Delete &lt;code&gt;www&lt;/code&gt; entry in Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR goes stale after maintainer asks for changes&lt;/td&gt;
&lt;td&gt;Administrative&lt;/td&gt;
&lt;td&gt;Respond within a few days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Name already taken by a pending PR&lt;/td&gt;
&lt;td&gt;Administrative&lt;/td&gt;
&lt;td&gt;Check open PRs before submitting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested subdomain, parent doesn't exist&lt;/td&gt;
&lt;td&gt;CI or reviewer&lt;/td&gt;
&lt;td&gt;Register parent domain first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  GitHub Pages? Same Pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-github-username"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"records"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CNAME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-github-username.github.io"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification file: &lt;code&gt;_github-pages-challenge-yourname.json&lt;/code&gt; — same structure, different prefix. The &lt;code&gt;_platform.yourname.json&lt;/code&gt; naming convention applies across the board.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pre-Submit Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Name available at &lt;a href="https://is-a.dev" rel="noopener noreferrer"&gt;is-a.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fresh branch off main&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;domains/yourname.json&lt;/code&gt; — CNAME, &lt;code&gt;records&lt;/code&gt; plural, valid JSON&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;domains/_vercel.yourname.json&lt;/code&gt; — TXT token from Vercel&lt;/li&gt;
&lt;li&gt;[ ] Domain added to Vercel Settings → Domains, root only, no www redirect&lt;/li&gt;
&lt;li&gt;[ ] Site is live with real content&lt;/li&gt;
&lt;li&gt;[ ] Screenshot of live site ready for PR description&lt;/li&gt;
&lt;li&gt;[ ] PR template filled out, not replaced&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  After the Merge
&lt;/h2&gt;

&lt;p&gt;Usually live within minutes. Still seeing the is-a.dev homepage after 15–20 min? Flush DNS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dscacheutil &lt;span class="nt"&gt;-flushcache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;killall &lt;span class="nt"&gt;-HUP&lt;/span&gt; mDNSResponder

&lt;span class="c"&gt;# Linux&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemd-resolve &lt;span class="nt"&gt;--flush-caches&lt;/span&gt;

&lt;span class="c"&gt;# Windows&lt;/span&gt;
ipconfig /flushdns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then enable &lt;strong&gt;Enforce HTTPS&lt;/strong&gt; in Vercel. You have a clean URL now — secure it.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Check &lt;a href="https://is-a.dev" rel="noopener noreferrer"&gt;is-a.dev&lt;/a&gt; first&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;records&lt;/code&gt; — plural, or CI rejects you immediately&lt;/li&gt;
&lt;li&gt;CNAME to &lt;code&gt;cname.vercel-dns.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Get TXT token: &lt;strong&gt;Vercel → project → Settings → Domains → Add Domain → Continue manually&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;TXT token goes in &lt;code&gt;_vercel.yourname.json&lt;/code&gt; — separate file, not the main one&lt;/li&gt;
&lt;li&gt;Root domain only in Vercel — skip the www redirect&lt;/li&gt;
&lt;li&gt;Screenshot in the PR description&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two gates. Two files. Right keys. That's the whole thing. &lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by a developer who now has a working &lt;code&gt;.is-a.dev&lt;/code&gt; domain and a portfolio full of backend projects to show for it — &lt;a href="https://cycy.is-a.dev" rel="noopener noreferrer"&gt;cycy.is-a.dev&lt;/a&gt; 🚀&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>vercel</category>
      <category>dns</category>
      <category>git</category>
    </item>
    <item>
      <title>this was a really fun drive 🚘 ...plus I totally love synthwave 😍

starting cursing here: https://sundaydevdrive.pilotronica.com
contribute to the project here: https://github.com/georgekobaidze/sunday-dev-drive

well done @Giorgi Kobaidze</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 06 Mar 2026 06:55:25 +0000</pubDate>
      <link>https://dev.to/wamzii/this-was-a-really-fun-drive-plus-i-totally-love-synthwave-starting-cursing-here-2abb</link>
      <guid>https://dev.to/wamzii/this-was-a-really-fun-drive-plus-i-totally-love-synthwave-starting-cursing-here-2abb</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032" class="crayons-story__hidden-navigation-link"&gt;Sunday DEV Drive: A Synthwave Driving Experience Through Your DEV Community Articles&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;DEV Weekend Challenge: Community&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/georgekobaidze" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F55651%2F3ad144e9-ca91-4395-b73a-9a0a3d843af9.jpg" alt="georgekobaidze profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/georgekobaidze" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Giorgi Kobaidze
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Giorgi Kobaidze
                &lt;a href="/++"&gt;&lt;img alt="Subscriber" class="subscription-icon" src="https://assets.dev.to/assets/subscription-icon-805dfa7ac7dd660f07ed8d654877270825b07a92a03841aa99a1093bd00431b2.png" width="166" height="102"&gt;&lt;/a&gt;
                
              
              &lt;div id="story-author-preview-content-3299096" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/georgekobaidze" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F55651%2F3ad144e9-ca91-4395-b73a-9a0a3d843af9.jpg" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Giorgi Kobaidze&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 1&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032" id="article-link-3299096"&gt;
          Sunday DEV Drive: A Synthwave Driving Experience Through Your DEV Community Articles
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devchallenge&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/weekendchallenge"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;weekendchallenge&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;65&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/georgekobaidze/sunday-dev-drive-a-synthwave-driving-experience-through-your-dev-community-articles-5032#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              23&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            10 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;



&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://sundaydevdrive.pilotronica.com/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsundaydevdrive.pilotronica.com%2Fassets%2Fposter.jpg" height="339" class="m-0" width="799"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://sundaydevdrive.pilotronica.com/" rel="noopener noreferrer" class="c-link"&gt;
            Sunday DEV Drive
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Drive through your DEV Community articles in a synthwave world. Your posts become neon billboards along an endless road.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsundaydevdrive.pilotronica.com%2Fassets%2Ffavicon.ico" width="1754" height="1754"&gt;
          sundaydevdrive.pilotronica.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/georgekobaidze/sunday-dev-drive" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fopengraph.githubassets.com%2F3b947b2c6cf2e7ec8558d7a7ed00152c7ec7c4db28ed0d145fee6971f1dfcd2c%2Fgeorgekobaidze%2Fsunday-dev-drive" height="400" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/georgekobaidze/sunday-dev-drive" rel="noopener noreferrer" class="c-link"&gt;
            GitHub - georgekobaidze/sunday-dev-drive: A synthwave driving experience through your DEV Community articles · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A synthwave driving experience through your DEV Community articles - georgekobaidze/sunday-dev-drive
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Deep Dive: Resolving DNS Issues with GitHub and Understanding SSH vs HTTPS</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Thu, 05 Mar 2026 10:39:57 +0000</pubDate>
      <link>https://dev.to/wamzii/deep-dive-resolving-dns-issues-with-github-and-understanding-ssh-vs-https-4bom</link>
      <guid>https://dev.to/wamzii/deep-dive-resolving-dns-issues-with-github-and-understanding-ssh-vs-https-4bom</guid>
      <description>&lt;p&gt;&lt;strong&gt;Ever tried pushing your code to GitHub, only to be stopped by a timeout error that makes zero sense?&lt;/strong&gt; That's exactly what happened to me — and what started as a frustrating blocker turned into a genuinely useful deep dive into DNS, network routing, and Git's connection methods. 🚀&lt;/p&gt;

&lt;p&gt;Let me walk you through every step.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: GitHub, You're Killing Me 😩
&lt;/h2&gt;

&lt;p&gt;Everything was going fine until I ran a routine push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin feat/add-tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of the usual success, I got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fatal: unable to access 'https://github.com/your-username/your-repo.git/':
Failed to connect to github.com port 443 after 75079 ms: Couldn't connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;75 seconds of waiting, then nothing. Time to dig in. 🔍&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Is GitHub Actually Down? 🌐
&lt;/h2&gt;

&lt;p&gt;Before blaming my own setup, I checked whether GitHub was reachable at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; https://github.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response? &lt;strong&gt;&lt;code&gt;000&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;000&lt;/code&gt; status code from &lt;code&gt;curl&lt;/code&gt; means the request never even got a response — no HTTP handshake, no connection, nothing. GitHub's HTTPS port was completely unreachable from my machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Ping &amp;amp; DNS Lookup — Finding the Culprit 🕵️‍♂️
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ping test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 github.com
PING github.com &lt;span class="o"&gt;(&lt;/span&gt;20.87.245.0&lt;span class="o"&gt;)&lt;/span&gt;: 56 data bytes
Request &lt;span class="nb"&gt;timeout &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;icmp_seq 0
64 bytes from 20.87.245.0: &lt;span class="nv"&gt;icmp_seq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;114 &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;72.940 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Packet loss right away. The IP &lt;code&gt;20.87.245.0&lt;/code&gt; was responding inconsistently — not the behavior you'd expect from GitHub's infrastructure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Packet loss in a ping doesn't always mean slow internet. It often means the specific IP you're being routed to has a problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  DNS lookup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;nslookup github.com
Name:    github.com
Address: 20.87.245.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. DNS was resolving &lt;code&gt;github.com&lt;/code&gt; to &lt;code&gt;20.87.245.0&lt;/code&gt; — a &lt;strong&gt;broken or misrouted IP&lt;/strong&gt;. Large services like GitHub use many IPs across their infrastructure. When DNS caching or propagation goes wrong, you can end up pointed at one that's temporarily dead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Port Check — Confirm It's Not Just a General Outage 🔌
&lt;/h2&gt;

&lt;p&gt;To rule out a broader network issue, I checked whether GitHub's HTTPS port (443) specifically was blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nc &lt;span class="nt"&gt;-zv&lt;/span&gt; github.com 443 &lt;span class="nt"&gt;-w&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No connection. Port 443 was unresponsive via that IP.&lt;/p&gt;

&lt;p&gt;But this raised an interesting question — &lt;strong&gt;if HTTPS (port 443) is blocked, what about SSH (port 22)?&lt;/strong&gt; I kept that in mind for later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Traceroute — Where Is the Connection Actually Dying? 🗺️
&lt;/h2&gt;

&lt;p&gt;Running a traceroute showed exactly where packets were getting dropped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;traceroute github.com
 1  192.168.x.x &lt;span class="o"&gt;(&lt;/span&gt;192.168.x.x&lt;span class="o"&gt;)&lt;/span&gt;    14.672 ms
 2  10.x.x.x &lt;span class="o"&gt;(&lt;/span&gt;10.x.x.x&lt;span class="o"&gt;)&lt;/span&gt;          9.098 ms
 3  &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;
 4  &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;
 5  &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;(Local IPs anonymised)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hops 1 and 2 — my local router and ISP gateway — responded fine. After that: silence. The connection was dying &lt;strong&gt;somewhere in the ISP's routing infrastructure&lt;/strong&gt;, well before reaching GitHub's servers.&lt;/p&gt;

&lt;p&gt;This is a classic sign of either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;routing failure&lt;/strong&gt; at the ISP level&lt;/li&gt;
&lt;li&gt;Traffic being directed to a &lt;strong&gt;bad IP&lt;/strong&gt; that simply drops packets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The DNS evidence from earlier made the latter the most likely culprit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Verify the Fix With a Known-Good IP 🧪
&lt;/h2&gt;

&lt;p&gt;Before touching any system config, I tested whether the &lt;em&gt;network itself&lt;/em&gt; was fine by bypassing DNS entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--resolve&lt;/span&gt; github.com:443:140.82.121.3 https://github.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces &lt;code&gt;curl&lt;/code&gt; to connect to &lt;code&gt;140.82.121.3&lt;/code&gt; (a legitimate GitHub IP) while keeping the hostname intact for TLS. The result? &lt;strong&gt;200 OK.&lt;/strong&gt; ✅&lt;/p&gt;

&lt;p&gt;The network was fine. The problem was purely DNS returning a bad IP.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How to find legitimate GitHub IPs:&lt;/strong&gt; Check &lt;a href="https://api.github.com/meta" rel="noopener noreferrer"&gt;https://api.github.com/meta&lt;/a&gt; — GitHub publishes their official IP ranges here.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 6: Override DNS via &lt;code&gt;/etc/hosts&lt;/code&gt; 🔧
&lt;/h2&gt;

&lt;p&gt;With the root cause confirmed, the fastest fix was to bypass DNS locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"140.82.121.3 github.com"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or edit the file manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/hosts
&lt;span class="c"&gt;# Add this line:&lt;/span&gt;
140.82.121.3 github.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells your OS: &lt;em&gt;"Don't ask DNS where github.com is — I'm telling you directly."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After saving, GitHub loaded instantly in the browser. But the &lt;code&gt;git push&lt;/code&gt; was still failing — because my remote was still using HTTPS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Switch Git Remote from HTTPS to SSH 🔑
&lt;/h2&gt;

&lt;p&gt;Here's what I had been missing. Even with the &lt;code&gt;/etc/hosts&lt;/code&gt; fix, port 443 was still being affected by the underlying routing issue. SSH uses &lt;strong&gt;port 22&lt;/strong&gt;, which was completely unaffected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check your current remote:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# origin  https://github.com/your-username/your-repo.git (fetch)&lt;/span&gt;
&lt;span class="c"&gt;# origin  https://github.com/your-username/your-repo.git (push)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Switch to SSH:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote set-url origin git@github.com:your-username/your-repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@github.com:your-username/your-repo.git (fetch)&lt;/span&gt;
&lt;span class="c"&gt;# origin  git@github.com:your-username/your-repo.git (push)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin feat/add-tests
&lt;span class="c"&gt;# ✅ Success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a quick comparison of both methods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Auth&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;Username + PAT token&lt;/td&gt;
&lt;td&gt;Simple setups, one-off clones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSH&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;SSH key pair&lt;/td&gt;
&lt;td&gt;Frequent pushes, automation, CI/CD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SSH is worth setting up properly if you haven't — no tokens to rotate, no passwords to enter, and as this situation showed, port 22 is far less likely to be caught up in DNS/routing failures than 443.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways 💡
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A &lt;code&gt;curl&lt;/code&gt; status of &lt;code&gt;000&lt;/code&gt; means zero connectivity&lt;/strong&gt; — not a server error, not a redirect. Nothing got through.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DNS can return broken IPs.&lt;/strong&gt; Large services like GitHub use many IPs. Caching issues can leave you pointed at one that's dead or misrouted.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Traceroute tells you &lt;em&gt;where&lt;/em&gt; things break.&lt;/strong&gt; If packets die at hop 3+, the issue is upstream of you — your ISP or beyond.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;/etc/hosts&lt;/code&gt; is a surgical bypass tool.&lt;/strong&gt; It skips DNS entirely for a specific hostname. Useful in emergencies, but remember to revert it once DNS is healthy again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SSH over HTTPS for Git — always, if you can.&lt;/strong&gt; It's more secure, needs no token management, and uses a different port that's often unaffected when HTTPS routes break.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always verify IPs against official sources.&lt;/strong&gt; Hardcoding a rogue IP in &lt;code&gt;/etc/hosts&lt;/code&gt; is a real MITM vector. Use &lt;a href="https://api.github.com/meta" rel="noopener noreferrer"&gt;api.github.com/meta&lt;/a&gt; to confirm.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Full Troubleshooting Cheatsheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Check if HTTPS is reachable at all&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; https://github.com

&lt;span class="c"&gt;# 2. Check DNS resolution&lt;/span&gt;
nslookup github.com

&lt;span class="c"&gt;# 3. Test connectivity to a known-good IP without changing DNS&lt;/span&gt;
curl &lt;span class="nt"&gt;--resolve&lt;/span&gt; github.com:443:140.82.121.3 https://github.com

&lt;span class="c"&gt;# 4. Check if port 443 is open&lt;/span&gt;
nc &lt;span class="nt"&gt;-zv&lt;/span&gt; github.com 443 &lt;span class="nt"&gt;-w&lt;/span&gt; 5

&lt;span class="c"&gt;# 5. Trace where packets are dying&lt;/span&gt;
traceroute github.com

&lt;span class="c"&gt;# 6. Override DNS locally&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"140.82.121.3 github.com"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts

&lt;span class="c"&gt;# 7. Check your Git remote&lt;/span&gt;
git remote &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# 8. Switch remote to SSH&lt;/span&gt;
git remote set-url origin git@github.com:your-username/your-repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;What looked like a random GitHub outage turned out to be a DNS routing failure — solvable with a two-line fix once you know what you're looking for. The traceroute and &lt;code&gt;curl&lt;/code&gt; tests were the real turning point: they made the invisible visible, showing exactly where the connection was breaking and why.&lt;/p&gt;

&lt;p&gt;If you take nothing else from this: &lt;strong&gt;learn to read traceroutes, and set up SSH for Git.&lt;/strong&gt; Both have saved me hours of confusion.&lt;/p&gt;

&lt;p&gt;Got questions, or your own DNS war stories? Drop them in the comments 👇&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push&lt;/code&gt; failed with a port 443 timeout&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; returned &lt;code&gt;000&lt;/code&gt; — no connection at all&lt;/li&gt;
&lt;li&gt;DNS was resolving GitHub to a broken IP (&lt;code&gt;20.87.245.0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Traceroute confirmed the failure was upstream, not local&lt;/li&gt;
&lt;li&gt;Fixed short-term with &lt;code&gt;/etc/hosts&lt;/code&gt; pointing to a working IP&lt;/li&gt;
&lt;li&gt;Fixed properly by switching Git remote from HTTPS → SSH (port 22)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>git</category>
      <category>github</category>
      <category>networking</category>
      <category>devops</category>
    </item>
    <item>
      <title>I resonate so much with this</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Sun, 01 Mar 2026 18:34:40 +0000</pubDate>
      <link>https://dev.to/wamzii/-14f5</link>
      <guid>https://dev.to/wamzii/-14f5</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/the_nortern_dev/the-hardest-part-of-being-a-developer-isnt-coding-its-disappearing-quietly-52l" class="crayons-story__hidden-navigation-link"&gt;The Hardest Part of Being a Developer Isn’t Coding. It’s Disappearing Quietly.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/the_nortern_dev" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3630167%2F2e206d7e-04d3-484b-8a73-1f98d17a0e1a.png" alt="the_nortern_dev profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/the_nortern_dev" class="crayons-story__secondary fw-medium m:hidden"&gt;
              NorthernDev
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                NorthernDev
                
                
              
              &lt;div id="story-author-preview-content-3294921" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/the_nortern_dev" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3630167%2F2e206d7e-04d3-484b-8a73-1f98d17a0e1a.png" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;NorthernDev&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/the_nortern_dev/the-hardest-part-of-being-a-developer-isnt-coding-its-disappearing-quietly-52l" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Feb 28&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/the_nortern_dev/the-hardest-part-of-being-a-developer-isnt-coding-its-disappearing-quietly-52l" id="article-link-3294921"&gt;
          The Hardest Part of Being a Developer Isn’t Coding. It’s Disappearing Quietly.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/mentalhealth"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;mentalhealth&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/career"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;career&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/the_nortern_dev/the-hardest-part-of-being-a-developer-isnt-coding-its-disappearing-quietly-52l" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;157&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/the_nortern_dev/the-hardest-part-of-being-a-developer-isnt-coding-its-disappearing-quietly-52l#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              97&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            2 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>discuss</category>
      <category>mentalhealth</category>
      <category>webdev</category>
      <category>career</category>
    </item>
    <item>
      <title>I Almost Filed a Bug Report. Then I Pressed Ctrl+P</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Sun, 01 Mar 2026 10:45:16 +0000</pubDate>
      <link>https://dev.to/wamzii/i-almost-filed-a-bug-report-then-i-pressed-ctrlp-2jck</link>
      <guid>https://dev.to/wamzii/i-almost-filed-a-bug-report-then-i-pressed-ctrlp-2jck</guid>
      <description>&lt;p&gt;I was using &lt;strong&gt;Kilo CLI v7.0.33&lt;/strong&gt; and something felt off. I could scroll fine — but there was no scrollbar indicator. No sense of where I was in a long session. Other CLIs had it. Mine didn't.&lt;/p&gt;

&lt;p&gt;My immediate take: &lt;em&gt;the scrollbar is broken.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I went digging.&lt;/p&gt;




&lt;h2&gt;
  
  
  The GitHub Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;I went through OpenCode issues, Kilo issues, anything mentioning scroll + Warp + iTerm. Left a couple of comments piecing together what I thought was happening:&lt;/p&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/anomalyco/opencode/issues/2500" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        scrollbars missing
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#2500&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/ubuntupunk" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F246662%3Fv%3D4" alt="ubuntupunk avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/ubuntupunk" rel="noopener noreferrer"&gt;ubuntupunk&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/anomalyco/opencode/issues/2500" rel="noopener noreferrer"&gt;&lt;time&gt;Sep 08, 2025&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;p&gt;I can't seem to scroll up to read what opencode is outputting. I don't have this problem with gemini-cli and any other cli  so its not terminal related.
The opencode scrollbar is just a sold bar for some reason&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/user-attachments/assets/f340d163-0352-4d07-935a-9aeddcccceb4"&gt;&lt;img width="1560" height="896" alt="Image" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Fuser-attachments%2Fassets%2Ff340d163-0352-4d07-935a-9aeddcccceb4"&gt;&lt;/a&gt;&lt;/p&gt;

    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/anomalyco/opencode/issues/2500" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;--&lt;/p&gt;


&lt;div class="ltag_github-liquid-tag"&gt;
  &lt;h1&gt;
    &lt;a href="https://github.com/anomalyco/opencode/issues/6209" rel="noopener noreferrer"&gt;
      &lt;img class="github-logo" alt="GitHub logo" src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg"&gt;
      &lt;span class="issue-title"&gt;
        Cannot scroll on opencode when using iterm
      &lt;/span&gt;
      &lt;span class="issue-number"&gt;#6209&lt;/span&gt;
    &lt;/a&gt;
  &lt;/h1&gt;
  &lt;div class="github-thread"&gt;
    &lt;div class="timeline-comment-header"&gt;
      &lt;a href="https://github.com/anugrahsinghal" rel="noopener noreferrer"&gt;
        &lt;img class="github-liquid-tag-img" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F18058884%3Fv%3D4" alt="anugrahsinghal avatar"&gt;
      &lt;/a&gt;
      &lt;div class="timeline-comment-header-text"&gt;
        &lt;strong&gt;
          &lt;a href="https://github.com/anugrahsinghal" rel="noopener noreferrer"&gt;anugrahsinghal&lt;/a&gt;
        &lt;/strong&gt; posted on &lt;a href="https://github.com/anomalyco/opencode/issues/6209" rel="noopener noreferrer"&gt;&lt;time&gt;Dec 26, 2025&lt;/time&gt;&lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div class="ltag-github-body"&gt;
      &lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Description&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;When trying to scroll the opencode TUI on iterm, it scrolls the input box but not the output of the previous command&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;OpenCode version&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;1.0.203&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Steps to reproduce&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Install iTerm2&lt;/li&gt;
&lt;li&gt;opencode&lt;/li&gt;
&lt;li&gt;!ls # to see basic output - should be long enough to overflow from view&lt;/li&gt;
&lt;li&gt;scroll&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Screenshot and/or share link&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/user-attachments/assets/eea1ae7e-6401-4ef8-b30d-d5265fee28ca" rel="noopener noreferrer"&gt;https://github.com/user-attachments/assets/eea1ae7e-6401-4ef8-b30d-d5265fee28ca&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Operating System&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;26.2&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Terminal&lt;/h3&gt;
&lt;span class="octicon octicon-link"&gt;&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;iTerm2&lt;/p&gt;

    &lt;/div&gt;
    &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/anomalyco/opencode/issues/6209" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The threads were noisy but a pattern emerged — people were actually describing &lt;strong&gt;two different problems&lt;/strong&gt; and conflating them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 1 — Mouse scroll not working at all&lt;/strong&gt; (terminal layer)&lt;br&gt;
Warp and iTerm need &lt;strong&gt;Mouse Reporting&lt;/strong&gt; enabled before they'll forward scroll events to a full-screen TUI. Without it, your scroll wheel just doesn't reach the app. Fix is in terminal settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 2 — Scrollbar not visible&lt;/strong&gt; (app layer)&lt;br&gt;
Completely separate. Scroll events arriving fine, but the indicator is just... off.&lt;/p&gt;

&lt;p&gt;I had Problem 2. But the GitHub threads mostly discussed Problem 1.&lt;/p&gt;



&lt;p&gt;Here’s what it looked like in my terminal — no visible scrollbar, long output, no sense of position and it was consistent in all the themes!&lt;/p&gt;


  


&lt;p&gt;At this point, I was convinced it was broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wait, What Am I Even Running?
&lt;/h2&gt;

&lt;p&gt;Before assuming anything, I needed to understand the actual architecture. Kilo isn't just OpenCode — it's a fork:&lt;/p&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mhl0xqnkv8rgl7iti1c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3mhl0xqnkv8rgl7iti1c.png" alt="Kilo Architecture" width="800" height="774"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;So I went to the source.&lt;/p&gt;

&lt;p&gt;Buried in the session route, I found it. The scrollbar wasn’t missing at all — it was &lt;strong&gt;disabled by default&lt;/strong&gt;. A KV-backed feature flag, persistent across sessions, but initialized to &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I was one tab away from opening an enhancement issue.&lt;/p&gt;

&lt;p&gt;Inside:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;opencode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;showScrollbar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShowScrollbar&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scrollbar_visible&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;verticalScrollbarOptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;
  &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;showScrollbar&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the moment everything clicked.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scrollbar existed.&lt;/li&gt;
&lt;li&gt;It was toggleable.&lt;/li&gt;
&lt;li&gt;And by default — it was off.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Then I Pressed Ctrl+P
&lt;/h2&gt;

&lt;p&gt;Command Palette. I typed "scroll."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Toggle session scrollbar&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I pressed it. The scrollbar appeared.&lt;/p&gt;

&lt;p&gt;It had been there the whole time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Actually Was
&lt;/h2&gt;

&lt;p&gt;Not a bug. Not a missing feature. A &lt;strong&gt;discoverability gap&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The scrollbar is implemented, toggleable, persistent — just hidden behind a keybind nobody thinks to try when they're confused about scroll behavior. That's a UX conversation worth having with maintainers, not a bug report.&lt;/p&gt;

&lt;p&gt;After piecing everything together, I went back to the GitHub threads.&lt;/p&gt;

&lt;p&gt;Instead of filing a new issue, I left a couple of comments clarifying what I’d found — separating the terminal mouse-reporting problem from the scrollbar visibility toggle. A small thing, but hopefully useful signal in a noisy thread.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fizn2p28n4q9io469j650.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fizn2p28n4q9io469j650.png" alt="Opencode Issue 2500" width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;--&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4iawl5lu4zz74l0cfow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj4iawl5lu4zz74l0cfow.png" alt="Opencode Issue 6209" width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sometimes contributing isn’t about opening something new.&lt;br&gt;
It’s about tightening what already exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Separate layers before blaming.&lt;/strong&gt; Terminal config and app state are different things. Conflating them sends you in circles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read source before filing.&lt;/strong&gt; That &lt;code&gt;false&lt;/code&gt; default was right there. Five minutes of reading saved a maintainer from triaging noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forks are their own thing.&lt;/strong&gt; Upstream behavior isn't a reliable guide. Check what the fork actually exposes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discoverability is a real problem.&lt;/strong&gt; If multiple users independently can't find a feature, that's signal — even if the feature works perfectly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Fix if You're Here for the Answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Scroll not working at all → enable Mouse Reporting in your terminal settings&lt;/li&gt;
&lt;li&gt;Scroll works but no scrollbar → &lt;code&gt;Ctrl+P&lt;/code&gt; → search "scrollbar" → toggle on&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It'll persist across sessions once set.&lt;/p&gt;




</description>
      <category>kilocli</category>
      <category>opencode</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
