<?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>I Went to Write Two Tests and Ended Up Fixing the App</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 13 Nov 2026 11:58:31 +0000</pubDate>
      <link>https://dev.to/wamzii/i-went-to-write-two-tests-and-ended-up-fixing-the-app-4773</link>
      <guid>https://dev.to/wamzii/i-went-to-write-two-tests-and-ended-up-fixing-the-app-4773</guid>
      <description>&lt;p&gt;The job sounded small. Two issues, same shape: &lt;em&gt;write a Playwright end-to-end test.&lt;/em&gt; Log in, report something, check the confirmation, report it again, check the message. Maybe an afternoon each.&lt;/p&gt;

&lt;p&gt;Four and a half months later, I had opened four pull requests, and not one of them was a test. Not yet. This is the story of why, and why I think it's the best argument for E2E tests I've ever lived through.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/QuoteVote/quotevote-next" rel="noopener noreferrer"&gt;Quote.Vote&lt;/a&gt;, an open-source, text-only platform for thoughtful dialogue (Next.js, an Express and Apollo GraphQL API, MongoDB, and a Prisma migration in progress). I'm a volunteer contributor there, and this is where things stand as I write this (November 12): the two bug-fix pull requests are &lt;strong&gt;merged&lt;/strong&gt;, and the test pull request is open as a draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assignment
&lt;/h2&gt;

&lt;p&gt;On June 22 I was assigned two issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/QuoteVote/quotevote-next/issues/394" rel="noopener noreferrer"&gt;E2E-MOD-006&lt;/a&gt;:&lt;/strong&gt; report a post. A logged-in user reports content, picks a reason, sees a confirmation, and a repeat report is handled gracefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/QuoteVote/quotevote-next/issues/395" rel="noopener noreferrer"&gt;E2E-MOD-007&lt;/a&gt;:&lt;/strong&gt; report a user as a bot. Same idea, from a profile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you can test anything, you have to run it. In July I did the local setup, and the app greeted me with a profile page that said "Invalid user". The server had answered &lt;code&gt;200 OK&lt;/code&gt; with &lt;code&gt;{"data":{"user":null}}&lt;/code&gt;. No error anywhere. The GraphQL schema declared a &lt;code&gt;user(username)&lt;/code&gt; query, and &lt;strong&gt;no resolver existed for it&lt;/strong&gt;, so Apollo returned &lt;code&gt;null&lt;/code&gt; with a straight face.&lt;/p&gt;

&lt;p&gt;I fixed that, and I also found a seed script that wrote to a different database than the one the server read. Two small pull requests, both merged. Then the maintainers told me what I'd half suspected: the backend for &lt;em&gt;reporting itself&lt;/em&gt; didn't exist yet. They opened a separate issue to build it, marked mine "Not Ready", and I stepped away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming back after four months
&lt;/h2&gt;

&lt;p&gt;In early November the maintainers asked if I was ready to pick the issues up again. I said yes, and my laptop immediately said no. In one day I hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A local clone that was &lt;strong&gt;hundreds of files behind&lt;/strong&gt; the real repository, so &lt;code&gt;git status&lt;/code&gt; showed a wall of changes I hadn't made.&lt;/li&gt;
&lt;li&gt;A database container that crash-looped, because the "latest" Mongo image had moved a major version past my old data volume.&lt;/li&gt;
&lt;li&gt;A backend that needed a &lt;strong&gt;MongoDB replica set&lt;/strong&gt; (Prisma uses transactions for writes), which was documented in a migration doc and not in the setup steps.&lt;/li&gt;
&lt;li&gt;A browser that couldn't talk to the API, because an old setting still allowed only the previous frontend port.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;main&lt;/code&gt; branch whose frontend and backend deliberately don't match, because the real work lives on &lt;code&gt;staging&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote each one down in a notes file as I went. (Several turned out to be documentation gaps worth reporting.) None of that is the story, but it's worth saying: &lt;strong&gt;the first test you write is for your own setup.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson one: read the issue against the real screen
&lt;/h2&gt;

&lt;p&gt;The issue said: &lt;em&gt;select a report reason.&lt;/em&gt; I opened the app. Reporting a post is a single click, with no reason picker. Reporting a user is a confirm dialog that says "Report Bot", and the reason picker component exists in the code but is never shown.&lt;/p&gt;

&lt;p&gt;I could have built a test for the screen in the issue, one that could never pass. Instead I took screenshots and asked, with the evidence attached. The answer came back within days: test the flow that exists, and treat a reason picker as a separate product decision. A few minutes of comparing, and a test written against a screen that doesn't exist, avoided.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: "Post not found" for a post that exists
&lt;/h2&gt;

&lt;p&gt;I logged in as a second user, opened a post, and clicked &lt;strong&gt;Report post&lt;/strong&gt;. The app said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Post not found&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The post was on the screen in front of me.&lt;/p&gt;

&lt;p&gt;The reporting code checked for a duplicate with a Prisma filter, roughly &lt;em&gt;"update this post unless &lt;code&gt;reportedBy&lt;/code&gt; already contains this user."&lt;/em&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="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;NOT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reportedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;userId&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reportedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;}&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;Posts created through the app's normal "create post" path &lt;strong&gt;have no &lt;code&gt;reportedBy&lt;/code&gt; field at all&lt;/strong&gt;. MongoDB leaves an unset list absent. And a filter on a list can't match a field that isn't there. So the update changed nothing, the code assumed "nothing changed means the post doesn't exist", and it threw &lt;code&gt;NOT_FOUND&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I proved it on one post in the database: add an empty &lt;code&gt;reportedBy: []&lt;/code&gt;, and the report worked, and the second report said "already reported". Remove the field, and "Post not found" came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing field is not an empty list.&lt;/strong&gt; Humans read them the same. Databases don't.&lt;/p&gt;

&lt;p&gt;A maintainer suggested the fix: one raw MongoDB update, where &lt;code&gt;$ne&lt;/code&gt; also matches a missing field, and &lt;code&gt;$push&lt;/code&gt; and &lt;code&gt;$inc&lt;/code&gt; create absent fields.&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="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$oid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reportedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$ne&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;userId&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="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reportedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$inc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check and the write stay one atomic step, so I tested that too: &lt;strong&gt;five simultaneous reports from one user gave one success and four "already reported", and the database counted one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why had the unit tests stayed green? They mock the database, and every fake post in them already had &lt;code&gt;reportedBy: []&lt;/code&gt;. The tests only ever tried the one shape that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second finding: a repeat report is not a failure
&lt;/h2&gt;

&lt;p&gt;Reporting a user twice produced a red error toast, a GraphQL error in the browser console, and a dialog that stayed open. The maintainers' view was fair: &lt;em&gt;you already told us, that's not an error.&lt;/em&gt; The server now returns an &lt;code&gt;ALREADY_REPORTED&lt;/code&gt; result and the app shows a calm, neutral message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third finding: tests that never ran
&lt;/h2&gt;

&lt;p&gt;To change that dialog I looked at its existing tests. One ("reports user successfully") finished in &lt;strong&gt;12 milliseconds&lt;/strong&gt;. That's too fast to open a dialog.&lt;/p&gt;

&lt;p&gt;They imported &lt;code&gt;MockedProvider&lt;/code&gt; from Apollo, which Apollo Client 4 &lt;strong&gt;removed&lt;/strong&gt;. In the test it was &lt;code&gt;undefined&lt;/code&gt;, so the component crashed into its error boundary, and the tests passed through an "else" branch that expected the error screen. They had been passing without ever testing the feature.&lt;/p&gt;

&lt;p&gt;I left that file alone and wrote new tests in their own file that drive the real menu and dialog. &lt;strong&gt;A test that passes in 12 ms deserves suspicion.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Accidental test-driven development
&lt;/h2&gt;

&lt;p&gt;Test-driven development (TDD) is a simple loop: write a test that &lt;strong&gt;fails&lt;/strong&gt; (red), write the smallest change that makes it &lt;strong&gt;pass&lt;/strong&gt; (green), then tidy up. I'd always treated it like a gym membership: admirable, and mostly for other people.&lt;/p&gt;

&lt;p&gt;Here I did it without planning to. The issue's steps &lt;em&gt;were&lt;/em&gt; the failing test: follow them by hand and the confirmation never appears, because the app says "Post not found". So for each fix I wrote the unit test first and watched it fail (five failures for the post fix; two backend and three frontend for the bot fix), then changed the code until they passed (38 in the post test file, 55 in the user one).&lt;/p&gt;

&lt;p&gt;The red step also caught &lt;em&gt;my&lt;/em&gt; mistakes. My first version of a new frontend test failed for the wrong reason: I had copied the old tests' setup, which crashed the component. That's how I found out the old setup was broken in the first place.&lt;/p&gt;

&lt;p&gt;What I'd tell a TDD skeptic: the failing test isn't a ritual, it's the bug report written in code. If you can't make the test fail the way the bug does, you haven't understood the bug yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then, the actual tests
&lt;/h2&gt;

&lt;p&gt;Finally, the thing I was assigned. Playwright drives a real Chrome browser like a very fast, very literal person. Headless means no window (fast, what CI uses). Headed means you can watch it. It's faster than you'd believe.&lt;/p&gt;

&lt;p&gt;The design had one wrinkle: you can report a given post, or a given user as a bot, &lt;strong&gt;once per pair of accounts&lt;/strong&gt;. A test that passes today would fail forever tomorrow. So each test creates a &lt;strong&gt;brand-new user&lt;/strong&gt; through a small local-only seed script, and the tests repeat on the same database with nothing to reset. The spec also &lt;strong&gt;skips unless the API is explicitly local&lt;/strong&gt;, because it writes test accounts.&lt;/p&gt;

&lt;p&gt;The results from my machine: all 3 tests pass on desktop and 3 on mobile, and a second run on the same database passed again, 6 of 6. One more small thing surfaced: a failed report shows the &lt;em&gt;same error toast twice&lt;/em&gt;, so Playwright's strict matching complained about two identical elements. I noted it as a finding and made the test accept the first copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens next
&lt;/h2&gt;

&lt;p&gt;Both bug-fix pull requests were merged on November 12. The test pull request needed both, so it waited on a local branch, and then moved onto the updated &lt;code&gt;staging&lt;/code&gt; with a single &lt;code&gt;git rebase --onto&lt;/code&gt; (my commit was the only one that moved, since the maintainers' merge commits had kept the fixes intact). It's open now as a draft, and I'm adding the evidence: a Playwright report with all 6 results, two short screen recordings, and the step-by-step view of each test.&lt;/p&gt;

&lt;p&gt;Here's a small tip I learned on the way: Playwright records video only for failures by default, and it starts recording before your setup finishes, so a raw video of a passing test opens on several seconds of a blank page. Run it in slow motion (&lt;code&gt;slowMo&lt;/code&gt;), record every test, and trim the empty start. A reviewer will actually watch a seven-second clip.&lt;/p&gt;

&lt;p&gt;One more thing to watch: GitHub only auto-closes issues when a pull request merges into the &lt;strong&gt;default&lt;/strong&gt; branch. Mine targets &lt;code&gt;staging&lt;/code&gt;, so the two issues won't close by themselves when it merges. I've mentioned that in the issue threads so nobody is surprised.&lt;/p&gt;

&lt;p&gt;I'll update this post again when the test pull request is merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell the next contributor
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat a test task as a product review.&lt;/strong&gt; You're the first user the feature has had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the issue with the real screen before writing a line.&lt;/strong&gt; Attach screenshots when they disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove a bug before you report it.&lt;/strong&gt; "Five simultaneous requests, one success" is harder to argue with than "I think there's a race".&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Be suspicious of fast, green tests.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down your setup problems.&lt;/strong&gt; Your notes file is a documentation PR waiting to happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask before you build, and in the open.&lt;/strong&gt; Maintainers answered in days, with a design, because I came with evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you're about to write E2E tests for a half-finished feature
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the feature by hand first, as a user, with a second account.&lt;/li&gt;
&lt;li&gt;Write down every way the screen differs from the issue.&lt;/li&gt;
&lt;li&gt;Reproduce each bug in the smallest way you can, and keep the proof.&lt;/li&gt;
&lt;li&gt;Fix the bugs in small separate pull requests, and say which one the test depends on.&lt;/li&gt;
&lt;li&gt;Make the test independent of old data: a fresh user or fresh record each run.&lt;/li&gt;
&lt;li&gt;Add a guard so it can't run against anything but a local server.&lt;/li&gt;
&lt;li&gt;Run it twice on the same database before you trust it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is test-driven development (TDD)?&lt;/strong&gt;&lt;br&gt;
A loop of three steps: write a test that fails (red), write the smallest code that makes it pass (green), then clean up (refactor). The failing test comes first, so it proves the bug or the missing feature exists before you fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can E2E tests find bugs that unit tests miss?&lt;/strong&gt;&lt;br&gt;
Yes. Unit tests mock the database and the network, so they only exercise the shapes of data you imagined. An end-to-end test uses real data created the real way, which is how a post with no &lt;code&gt;reportedBy&lt;/code&gt; field showed up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between headed and headless in Playwright?&lt;/strong&gt;&lt;br&gt;
Headless runs the browser with no visible window, which is faster and is what CI servers use. Headed shows the window so you can watch. Playwright's &lt;code&gt;--debug&lt;/code&gt; and &lt;code&gt;--ui&lt;/code&gt; modes let you step through a test one action at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a MongoDB filter not match a missing field?&lt;/strong&gt;&lt;br&gt;
A list filter such as "does not contain X" is evaluated against an array. If the field doesn't exist there is no array, and Prisma's list filters don't match it. Raw MongoDB operators such as &lt;code&gt;$ne&lt;/code&gt; do match a missing field, and &lt;code&gt;$push&lt;/code&gt; and &lt;code&gt;$inc&lt;/code&gt; create absent fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you test something that can only happen once per user?&lt;/strong&gt;&lt;br&gt;
Give each test its own fresh user, created by a small seed script, so the test never depends on earlier runs. Then it can repeat on the same database without cleanup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you come back to an open-source issue after months away?&lt;/strong&gt;&lt;br&gt;
Re-read the whole thread first, since things change fast. Re-sync your clone and check which branch the project is currently developing on. Compare the issue text with the current app, and ask in the thread before building anything the screen contradicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why would a test pass in 12 milliseconds?&lt;/strong&gt;&lt;br&gt;
Usually because it never ran the code it claims to test. A common cause is a test helper that no longer exists in an upgraded library, so the component falls into an error boundary and the assertions take a fallback path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (Nov 13):&lt;/strong&gt;  &lt;a class="mentioned-user" href="https://dev.to/maciek_chmura_building"&gt;@maciek_chmura_building&lt;/a&gt; asked two good questions about fixtures and the double toast. I tested both and wrote up the results, with screenshots, in &lt;a href="https://dev.to/wamzii/comment/3l4a0"&gt;this reply&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;In short: both the component and the global error link show the toast, and three more post lists are missing on new posts but handled safely.&lt;/p&gt;




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

</description>
      <category>tdd</category>
      <category>playwright</category>
      <category>prisma</category>
      <category>graphql</category>
    </item>
    <item>
      <title>One Stolen Token Should Not Ship Your App: A Two-Person Release Gate in GitHub Actions</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 13 Nov 2026 11:24:47 +0000</pubDate>
      <link>https://dev.to/wamzii/one-stolen-token-should-not-ship-your-app-a-two-person-release-gate-in-github-actions-330c</link>
      <guid>https://dev.to/wamzii/one-stolen-token-should-not-ship-your-app-a-two-person-release-gate-in-github-actions-330c</guid>
      <description>&lt;h2&gt;
  
  
  One Stolen Token Should Not Ship Your App
&lt;/h2&gt;

&lt;p&gt;An attacker got hold of one admin's access token. Within minutes they had pushed a workflow file into every repository in the organisation, each one tailored to that repo, each one written to read the repo's secrets and send them out.&lt;/p&gt;

&lt;p&gt;We contained it. Tokens revoked, secrets rotated, signatures enforced. I've written about that part separately.&lt;/p&gt;

&lt;p&gt;This post is about the question that stayed with me afterwards, looking at our mobile repo:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If one token can push code, and pushing code can publish to the Play Store, then one token can ship an app to our users.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That had to stop being true. This is the release gate I built so that it takes two named humans, and so that a merge is no longer a release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, in two parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One: a merge was a release.&lt;/strong&gt; Merging to &lt;code&gt;main&lt;/code&gt; built and published both of our Android apps (two apps, one codebase). A fix for one app forced a rebuild and store resubmission of the other, in the middle of its review, for nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: one person was enough.&lt;/strong&gt; Anyone with write access, or anything holding their token, could start the publish.&lt;/p&gt;

&lt;p&gt;I wanted three properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Code is merged" and "this app is released" are different events.&lt;/li&gt;
&lt;li&gt;A release needs a request from one person and an approval from a &lt;strong&gt;different&lt;/strong&gt;, named person.&lt;/li&gt;
&lt;li&gt;There is exactly one door to the store credentials, and it cannot be opened from outside.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The template is cooked and now in GitHub  🚀
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CynthiaWahome/release-gate" rel="noopener noreferrer"&gt;check it out here &lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7j6hq0bujsa8jwvw32r7.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7j6hq0bujsa8jwvw32r7.gif" alt="Monkey bath" width="360" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: three workflows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Request
&lt;/h3&gt;

&lt;p&gt;Run manually. It publishes nothing and builds nothing for the store. It only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;writes a small JSON file (which app, which store track, who asked) into a &lt;code&gt;release-requests/&lt;/code&gt; folder on a new branch, and&lt;/li&gt;
&lt;li&gt;opens a pull request into a dedicated branch called &lt;code&gt;release-gate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important detail: the pull request is opened &lt;strong&gt;by the automation&lt;/strong&gt;, not by the person.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Approve
&lt;/h3&gt;

&lt;p&gt;Runs whenever a review is submitted anywhere in the repo, then narrows down hard. It continues only if all of these hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the pull request targets &lt;code&gt;release-gate&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the review is an approval, not a comment;&lt;/li&gt;
&lt;li&gt;the pull request was authored by the automation;&lt;/li&gt;
&lt;li&gt;the approver is on a fixed list of names;&lt;/li&gt;
&lt;li&gt;the approver is &lt;strong&gt;not&lt;/strong&gt; the person named in the request file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything it cannot read is a "no". A missing file, a malformed file, a missing field: stop, comment on the pull request, publish nothing. Fail closed, not open.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Publish
&lt;/h3&gt;

&lt;p&gt;The only workflow that touches signing keys and store credentials. It has exactly one trigger:&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;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_call&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;workflow_call&lt;/code&gt; means "another workflow may call me". There is no manual-run button, no push trigger, no pull-request trigger. The only caller is the Approve workflow, after its checks pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the request must be authored by the bot
&lt;/h2&gt;

&lt;p&gt;This was the hole I found while designing it.&lt;/p&gt;

&lt;p&gt;Without that rule, anyone with write access could open their &lt;strong&gt;own&lt;/strong&gt; pull request into &lt;code&gt;release-gate&lt;/code&gt;, with a hand-written request file naming someone else as the requester, and then approve it themselves. The self-approval check would pass, because it trusts the file.&lt;/p&gt;

&lt;p&gt;The file is only trustworthy if our own script wrote it. Our script only ever runs when the automation opens the pull request. So "authored by the bot" is what makes "approver is not requester" mean anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alarm that guards the door
&lt;/h2&gt;

&lt;p&gt;The whole design rests on Publish having one trigger. One well-meaning edit ("let me add a manual trigger for a quick test") would add a second door that skips approval, and nothing would look broken.&lt;/p&gt;

&lt;p&gt;So normal CI, on every pull request, reads that file's &lt;code&gt;on:&lt;/code&gt; block and fails unless the only trigger is &lt;code&gt;workflow_call&lt;/code&gt;:&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;triggers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(awk '/^on:/{flag=1; next} /^[a-zA-Z]/{flag=0} flag &amp;amp;&amp;amp; /^  [a-zA-Z_]+:/{print}' "&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;" \
  | sed 's/^  //;s/:.*//')"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$triggers"&lt;/span&gt; != &lt;span class="s2"&gt;"workflow_call"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::$file must have ONLY 'workflow_call' as a trigger."&lt;/span&gt;
  &lt;span class="nb"&gt;exit&lt;/span&gt; 1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks only at keys indented exactly two spaces under &lt;code&gt;on:&lt;/code&gt;, so the inputs declared inside &lt;code&gt;workflow_call&lt;/code&gt; are not mistaken for triggers. It is a hard failure on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The approver list is hard-coded, on purpose
&lt;/h2&gt;

&lt;p&gt;The list of who may approve lives inside the workflow file, not read from &lt;code&gt;CODEOWNERS&lt;/code&gt; at run time. If it were read live, a quiet edit to one file would change who can release to the store. Keeping it in the workflow means changing the approvers is a visible diff in the most scrutinised file in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four bugs I only found by running it
&lt;/h2&gt;

&lt;p&gt;Reading the YAML told me it was correct. Running it told me otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The reusable workflow could not be found.&lt;/strong&gt; I referenced Publish by a relative path. On a review event, GitHub resolves that path against the pull request's merge ref, and the frozen &lt;code&gt;release-gate&lt;/code&gt; branch has no such file. Result: the run failed at start-up with no jobs. Fix: reference it by repository and branch explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Permissions are capped by the caller.&lt;/strong&gt; Publish asked for write permissions; the calling job declared none, so it had read-only defaults, and GitHub rejected the whole workflow. A called workflow can never have more permission than the job that calls it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It read the wrong file.&lt;/strong&gt; I took "the first changed file" in the pull request as the request file. Because &lt;code&gt;release-gate&lt;/code&gt; is a stale branch, every file changed since shows up in the diff, and the first one alphabetically was not my JSON. Fix: select by path prefix, and page through the full file list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A failed publish burned a valid approval.&lt;/strong&gt; The first real release died on runner disk space. My workflow closed the request anyway, so two people had to go through the whole cycle again for an infrastructure hiccup. Now a failed publish leaves the request open. Re-running replays the same approval against the same commit, so it can only publish what was already approved.&lt;/p&gt;

&lt;p&gt;And one for humans: someone merged a request in good faith and nothing happened, because merging does nothing here. A workflow that never runs cannot explain itself. So there is now a tiny workflow whose only job is to comment: "merging does not publish; approve the request instead".&lt;/p&gt;

&lt;h2&gt;
  
  
  What this stops, and what it does not
&lt;/h2&gt;

&lt;p&gt;Honesty section. A gate you overestimate is worse than no gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It stops:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an accidental release from a merge;&lt;/li&gt;
&lt;li&gt;one person, or one stolen token, starting a store publish through the pipeline;&lt;/li&gt;
&lt;li&gt;self-approval, including the forged-request variant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;It does not stop, on its own:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;someone with write access editing the workflows themselves. The trigger guard and code review make that visible; branch protection with required code-owner review on the workflows folder is what makes it enforceable.&lt;/li&gt;
&lt;li&gt;a workflow pushed to another branch reading repository-level secrets. If your signing keys are plain repository secrets, any workflow in the repo can ask for them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd add next
&lt;/h2&gt;

&lt;p&gt;The plan for the template version of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move store and signing secrets into a GitHub &lt;strong&gt;Environment&lt;/strong&gt; (a named group of secrets with its own rules), restricted to the protected branch. Then the secrets are not available to a workflow on an arbitrary branch.&lt;/li&gt;
&lt;li&gt;Require code-owner review for anything under &lt;code&gt;.github/workflows/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Compare with GitHub's built-in environment reviewers. If your plan includes them for your repo type, use them, and keep the bot-authored request as the audit trail. Check GitHub's current documentation for what your plan allows on private repositories.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checklist: a two-person release gate
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Separate "merge" from "release". A merge publishes nothing.&lt;/li&gt;
&lt;li&gt;One publish workflow, one trigger: &lt;code&gt;workflow_call&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CI check that fails if that file gains another trigger.&lt;/li&gt;
&lt;li&gt;Release requests are pull requests opened by the automation, carrying a small metadata file.&lt;/li&gt;
&lt;li&gt;Approval checks: right branch, real approval, bot author, named approver, approver is not requester.&lt;/li&gt;
&lt;li&gt;Fail closed on anything unreadable.&lt;/li&gt;
&lt;li&gt;Pass user inputs to scripts as environment variables, never pasted into the script text.&lt;/li&gt;
&lt;li&gt;Leave a failed publish retryable without a new approval.&lt;/li&gt;
&lt;li&gt;Tell humans what merging does not do.&lt;/li&gt;
&lt;li&gt;Put the secrets behind an environment, and protect the workflows folder.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a two-person rule in CI/CD?&lt;/strong&gt;&lt;br&gt;
A control where a sensitive action, such as publishing to an app store, needs one person to request it and a different authorised person to approve it. No single account, or single stolen credential, can complete it alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does &lt;code&gt;workflow_call&lt;/code&gt; do in GitHub Actions?&lt;/strong&gt;&lt;br&gt;
It makes a workflow reusable: other workflows can call it with &lt;code&gt;uses:&lt;/code&gt;. If it is the only trigger, the workflow cannot be started by a push, a pull request or the manual-run button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can someone approve their own release with this design?&lt;/strong&gt;&lt;br&gt;
No. The approval step compares the approver with the requester recorded in a file that only the automation writes, and it ignores pull requests that a human opened directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just protect the main branch?&lt;/strong&gt;&lt;br&gt;
Branch protection controls what gets merged. It does not separate "merged" from "released", and it does not decide who may publish which app to which store track.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this replace GitHub Environments with required reviewers?&lt;/strong&gt;&lt;br&gt;
No, they work together. Environments restrict who can reach the secrets; this design adds a request record, a self-approval check and per-app releases. Where environment reviewers are available on your plan, use both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you release one app from a two-app codebase without rebuilding the other?&lt;/strong&gt;&lt;br&gt;
Make the app an input of the release request. The publish workflow receives that single value and builds only that app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the publish fails after approval?&lt;/strong&gt;&lt;br&gt;
The request stays open. Re-running the failed run replays the same approval on the same commit, so nothing new can slip in, and nobody has to approve twice.&lt;/p&gt;




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

</description>
      <category>githubactions</category>
      <category>security</category>
      <category>cicd</category>
      <category>android</category>
    </item>
    <item>
      <title>Automating Android Play Store Releases, Part 5: The Bug That Shipped Live</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 13 Nov 2026 09:57:12 +0000</pubDate>
      <link>https://dev.to/wamzii/automating-android-play-store-releases-part-5-the-bug-that-shipped-live-1icm</link>
      <guid>https://dev.to/wamzii/automating-android-play-store-releases-part-5-the-bug-that-shipped-live-1icm</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5 of a 5-part series on automating a multi-app Android release pipeline. Part 4 → ended on the first real production release going out cleanly. This part covers what showed up right after — a bug that had nothing to do with signing, versioning, tracks, or storage, and everything to do with a comment that stopped being true nine minutes after it was written.&lt;/em&gt;&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Right after the first real production release, both apps started showing the same login screen&lt;/strong&gt; — staff's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root cause: this org has exactly one self-hosted runner.&lt;/strong&gt; Both apps build sequentially, on the same disk, sharing the same Metro bundler cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A comment already in the codebase claimed this couldn't happen&lt;/strong&gt;, because staff and student "each get their own runner." That was true — for eight days, on a different runner setup, before a later, unrelated commit quietly removed the isolation the comment depended on. Nobody went back to check whether the comment still held.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real fix wasn't isolating the runners again — it was making the cache actually aware that "which app" is something that should invalidate it.&lt;/strong&gt; Metro's own &lt;code&gt;cacheVersion&lt;/code&gt; config field does exactly that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix stops it happening again. It didn't undo what had already shipped&lt;/strong&gt; — both apps needed a fresh build and a fresh release after the fix landed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Live, and Showing the Wrong Screen
&lt;/h2&gt;

&lt;p&gt;The message came in a few hours after the first real production release went out: &lt;em&gt;"both apps are showing me the login screen for staff app."&lt;/em&gt; Not a color slightly off, not an icon mismatch — the student app, downloaded fresh from the Play Store, opened straight to staff's actual login screen.&lt;/p&gt;

&lt;p&gt;That's a different category of bug than anything else in this series. Signing, versioning, tracks, storage — all of that could delay a release or misreport one. This was the first time the pipeline had actually shipped the wrong thing to a real user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling Out the Obvious, Then Finding the Comment That Explained Everything — Except It Was Wrong
&lt;/h2&gt;

&lt;p&gt;The first instinct was styling — a leftover theme value, a stale asset. That theory didn't survive five minutes: this wasn't a color, it was an entire screen. Something was bundling one app's actual code into the other's build.&lt;/p&gt;

&lt;p&gt;Then a comment already sitting in the workflow file for this exact pipeline turned up, and it looked like the whole answer:&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;# Staff and student each get their own runner -- previously both built
# sequentially on ONE shared machine, which meant Metro's on-disk bundler
# cache (which staff's build populates first) could be reused for
# student's build right after, even though EXPO_PUBLIC_ROUTE had changed.
# ...
# Separate runners share no disk and no cache, so this can't happen --
# the same reason the Play Store release pipeline never could.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Word for word, this described the exact symptom being debugged — and confidently declared it impossible. The only way to actually trust that was to check it directly instead of reading it and moving on:&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 orgs/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;org&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/actions/runners --jq &lt;span class="s1"&gt;'.runners[] | {name, status}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;: &lt;span class="s2"&gt;"&amp;lt;our-only-runner&amp;gt;"&lt;/span&gt;, &lt;span class="s2"&gt;"status"&lt;/span&gt;: &lt;span class="s2"&gt;"online"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One runner.&lt;/strong&gt; Not two. The comment's entire premise — that staff and student physically couldn't share a disk — was false, right now, in production, while confidently telling anyone who read it the opposite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Was Specifically Staff's Screen, on Both Apps
&lt;/h2&gt;

&lt;p&gt;With the real constraint confirmed (one shared machine, not two), the mechanism became straightforward. &lt;code&gt;metro.config.js&lt;/code&gt; — the config for Metro, the tool that actually bundles this app's JavaScript — picks which app's styling to bundle based on an environment variable:&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="no"&gt;ROUTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;EXPO_PUBLIC_ROUTE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;"staff"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metro also keeps its own on-disk cache of already-bundled work, to avoid redoing it. That cache key was never told that &lt;code&gt;ROUTE&lt;/code&gt; was something worth caring about. The build matrix runs staff first, then student, back to back, on the one machine that exists. Staff's build populates Metro's cache. Student's build starts moments later, on the same disk — and as far as Metro's cache key is concerned, nothing relevant has changed. It hands back staff's already-bundled output.&lt;/p&gt;

&lt;p&gt;That's the specific detail that makes this bug make sense rather than look random: it wasn't a coin-flip swap between the two apps. Staff always builds first, so staff's bundle is always what gets silently reused — which is exactly why &lt;em&gt;both&lt;/em&gt; apps ended up showing staff's screen, not a symmetric mix-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Comment Was True. Then It Wasn't, Nine Minutes Later.
&lt;/h2&gt;

&lt;p&gt;This is the part worth sitting with longer than the fix itself. Checking the actual commit history:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eight days earlier&lt;/strong&gt;, this exact bug happened once before, in a smaller form — colors leaking between apps, not full screens. The fix at the time was real and correct: split the build into a matrix, one job per app, each on GitHub's own hosted runners. On GitHub-hosted infrastructure, each matrix combination genuinely does get its own separate, disposable machine. The comment — "separate runners share no disk and no cache, so this can't happen" — was &lt;strong&gt;true the day it was written.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eight days after that&lt;/strong&gt;, a different, unrelated change replaced the hosted runners with this org's own self-hosted one, to get off a shared billing quota entirely (that migration is its own story — see Part 3). A brief, dynamic version tried picking a hosted runner as a fallback when the self-hosted one was busy; nine minutes after that was added, it was removed again as unnecessary complexity, replaced with a flat, permanent assignment to the one self-hosted runner. &lt;strong&gt;That is the exact moment the comment's premise stopped being true.&lt;/strong&gt; Both apps now pointed at the same runner label — meaning the same physical machine — and nothing about that change touched, or even mentioned, the comment sitting eight days earlier claiming isolation as settled fact.&lt;/p&gt;

&lt;p&gt;Nobody was wrong at either point. The first fix was correct when it shipped. The infrastructure commit was a reasonable simplification on its own terms. &lt;strong&gt;The bug lived entirely in the gap between them&lt;/strong&gt; — a true statement that outlived the conditions that made it true, with nothing in the process that would ever prompt someone to go back and re-check it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Fix
&lt;/h2&gt;

&lt;p&gt;Re-isolating the runners was the tempting fix — it's what worked before. It was also treating the symptom: the underlying cache still wouldn't know what it was caching. The real fix was making the cache itself aware of the one thing that actually determines its output:&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="no"&gt;ROUTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;EXPO_PUBLIC_ROUTE&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;"staff"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Metro's cache has no built-in notion that ROUTE changes what it should be&lt;/span&gt;
&lt;span class="c1"&gt;// bundling. cacheVersion is Metro's own mechanism for exactly this -- forces&lt;/span&gt;
&lt;span class="c1"&gt;// a genuinely separate cache whenever the value changes, so a route switch&lt;/span&gt;
&lt;span class="c1"&gt;// can never again silently reuse another route's bundle, regardless of how&lt;/span&gt;
&lt;span class="c1"&gt;// many runners this ever ends up on.&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cacheVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ROUTE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. It doesn't depend on runner topology at all — one machine or ten, this can't happen again, because the cache key itself now reflects the thing that actually changes. Verified it was even valid JavaScript before pushing it anywhere (&lt;code&gt;node --check metro.config.js&lt;/code&gt;) — a small habit, and a direct one born from an earlier mistake in this same week's work (Part 4 has that story).&lt;/p&gt;

&lt;p&gt;The stale comment got fixed too — not just deleted, replaced with what's actually true now, including the part where the old claim was wrong, so the next person reading it gets the real history instead of a second false sense of safety.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Fix Didn't Do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Merging the fix stopped this from happening to the next build. It did nothing for the two builds that had already shipped.&lt;/strong&gt; Both apps' live production releases had been built under the broken cache behavior — the fix doesn't reach backward into an already-published AAB. The remaining step was the least surprising one in this whole series: rebuild both apps for real, under the fixed config, and push a fresh release to each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons From Part 5
&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;A comment declaring a bug class "impossible"&lt;/td&gt;
&lt;td&gt;Verify the claim directly (&lt;code&gt;gh api orgs/&amp;lt;org&amp;gt;/actions/runners&lt;/code&gt;), every time — especially the ones confident enough that nobody questions them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Isolation as the safety mechanism&lt;/td&gt;
&lt;td&gt;Infrastructure-level isolation (separate runners) is fragile the moment infrastructure changes; a cache key that reflects its real inputs doesn't depend on topology at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A true comment, un-revisited after the infrastructure under it changed&lt;/td&gt;
&lt;td&gt;Nothing enforces that a comment stays true after the code around it changes — the same problem as every stale-comment fix elsewhere in this series, just with a real user-facing consequence this time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The fix landing didn't fix what already shipped&lt;/td&gt;
&lt;td&gt;A pipeline fix and a production incident are two different problems; closing one doesn't automatically close the other&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where the Series Actually Ends
&lt;/h2&gt;

&lt;p&gt;Five parts, and the throughline turns out to be the same idea wearing different clothes each time: a signing key nobody mentioned, a version number a local file silently owned, a track name nobody was watching, a workflow expression rule invisible to a normal syntax check, a storage quota nobody had sized, a notification relay that could fail independently of the thing it reported on, and finally a comment that was honest the day someone wrote it and silently stopped being honest over a week later. None of these were carelessness. Every one of them was a true thing that quietly stopped being true, discovered only once it actually mattered.&lt;/p&gt;

&lt;p&gt;That's the real shape of what "finished" means for a pipeline like this: not the absence of bugs, but a system that tells you the truth the moment something breaks, instead of the moment someone finally goes looking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The mobile apps discussed in this series 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, 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) power the same Cyfamod School Management System, alongside &lt;a href="https://github.com/Cyfamod-Technologies/school-site-engine" rel="noopener noreferrer"&gt;school-site-engine&lt;/a&gt; for the public-facing school sites. 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 Last Broadcast: When Local AI Runs a Horror Radio Show</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 30 Oct 2026 22:47:25 +0000</pubDate>
      <link>https://dev.to/wamzii/the-last-broadcast-when-local-ai-runs-a-horror-radio-show-2m7e</link>
      <guid>https://dev.to/wamzii/the-last-broadcast-when-local-ai-runs-a-horror-radio-show-2m7e</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I don't celebrate Halloween in my community, so I approached this week's theme through a different kind of horror: a person with complete control over a process, absolutely no intention of helping, and impeccable customer-service manners.&lt;/p&gt;

&lt;p&gt;Meet &lt;strong&gt;The Last Broadcast&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You tune into a fictional late-night radio station. Mara is trapped beyond the signal. The presenter asks five questions and claims the answers will determine whether she can be brought back. Every answer receives a new procedural objection. Mara starts to expose the rigged rules.&lt;/p&gt;

&lt;p&gt;The point is local AI. Gemma 3 1B, an open-weight model running through Ollama on the player's machine, improvises a rejection based on the question and answer. Kokoro generates the presenter and Mara's speech locally. The application owns the rounds, timer and ending; the model supplies the objection.&lt;/p&gt;

&lt;p&gt;The presenter is cheating. The software's job is to make that cheating entertaining while keeping its actual controls reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CynthiaWahome/local-ai-radio" rel="noopener noreferrer"&gt;https://github.com/CynthiaWahome/local-ai-radio&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository includes setup instructions, episode content, runtime contracts, unit and integration tests, browser tests, and the application's MIT license. Model and voice assets retain their own license terms; the MIT license does not cover them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The radio interface uses React 18 and TypeScript. A local Node/Express service coordinates Gemma 3 1B through Ollama and Kokoro 82M for speech.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code runs the show; the model supplies the objection
&lt;/h3&gt;

&lt;p&gt;The engine controls the five rounds, five-second answer windows, accepted input event, authored captive interjections and deliberately unwinnable ending.&lt;/p&gt;

&lt;p&gt;The model receives the current question, the listener's answer and an objection category. It returns one short presenter line. It can invent a missing permit; it cannot add a sixth round or change the ending. Player answers are treated as dialogue data, not instructions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sarcasm needs constraints too
&lt;/h3&gt;

&lt;p&gt;The presenter is courteous, specific and unreasonable. Generic "wrong answer" responses are less interesting than a precise objection to the actual words the listener entered.&lt;/p&gt;

&lt;p&gt;Structured JSON and runtime validation (Zod 4) keep the response shape manageable. Authored fallback lines keep the experience moving if local generation fails. The reaction endpoint rejects invalid request bodies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sound creates the room
&lt;/h3&gt;

&lt;p&gt;The audio design uses Kokoro 82M TTS with two voices: af_nicole (presenter) and am_fenrir (Mara). Captions identify each speaker. Pause preserves time. Mute is supported via browser controls.&lt;/p&gt;

&lt;p&gt;The atmospheric cover is a separately generated design asset. The open-weight gameplay runtime is Gemma 3 1B + Kokoro 82M.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One implementation lesson:&lt;/strong&gt; The first TTS synthesis was slow because the Kokoro model loaded on first request. The fix was to pre-load the model on server startup via a warm-up synthesis call, reducing first-request latency. Also, Kokoro's audio output doesn't include a sample rate — it had to be hardcoded to 24000 Hz.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;The presenter reacts to words I did not script in advance. An open-weight local model makes that interaction inspectable and replaceable without requiring a hosted inference subscription for each playthrough.&lt;/p&gt;

&lt;p&gt;In a browser check of the running app, I observed no external page requests. I have not yet verified a complete offline run of the app and both model workers. Kokoro may download model files on first use; after they are cached, generation runs locally. This is the evidence I have so far, rather than a claim that a full offline test has passed.&lt;/p&gt;

&lt;p&gt;The story, model adapter and voice choices can be changed independently. The project is designed to run with resources the player controls instead of an ongoing per-conversation API dependency.&lt;/p&gt;

&lt;p&gt;Local does not mean hardware, downloads or electricity cost nothing. Model weights and voice assets also retain their own license terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;I wanted the moment of recognition: not just "that answer was rejected," but "this station was never going to accept anything." The person inside the radio is trapped by a system that can always manufacture one more requirement.&lt;/p&gt;

&lt;p&gt;The five-round structure gives the player time to recognize the pattern. The ending is authored by the game engine, so the model cannot improvise a rescue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/li&gt;
&lt;li&gt;Best Use of Gemma&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc045gy7f4eoomfxyd0n1.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc045gy7f4eoomfxyd0n1.png" alt=" " width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>DevHibernate: My Side Projects Needed a Nap, Not Another SSD</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Sun, 25 Oct 2026 21:28:21 +0000</pubDate>
      <link>https://dev.to/wamzii/devhibernate-my-side-projects-needed-a-nap-not-another-ssd-2gep</link>
      <guid>https://dev.to/wamzii/devhibernate-my-side-projects-needed-a-nap-not-another-ssd-2gep</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I have a perfectly reasonable number of side projects.&lt;/p&gt;

&lt;p&gt;Unfortunately, each one seems to have invited an entire dependency ecosystem to live on my laptop indefinitely.&lt;/p&gt;

&lt;p&gt;The project I haven't opened in a month still has its &lt;code&gt;node_modules&lt;/code&gt;. The experiment I was “just trying quickly” has apparently signed a long-term storage agreement. Meanwhile, my laptop is being asked to fund everybody's accommodation.&lt;/p&gt;

&lt;p&gt;That is the idea behind &lt;strong&gt;DevHibernate&lt;/strong&gt;: put a shelved development project to sleep without throwing away the work you might return to.&lt;/p&gt;

&lt;p&gt;The intended workflow is deliberately small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select a development folder.&lt;/li&gt;
&lt;li&gt;Inspect a local inventory of supported projects.&lt;/li&gt;
&lt;li&gt;Mark a project as shelved and review an AI-assisted hibernation assessment.&lt;/li&gt;
&lt;li&gt;Approve removal of selected, supported dependency artifacts.&lt;/li&gt;
&lt;li&gt;Keep the source and configuration in place, with a guide for returning later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the hackathon, the scope is standalone npm projects on macOS. Python environments, background processes, Xcode resources and encrypted archives belong to later phases. “One more feature” is how this application would acquire its own abandoned dependency neighbourhood.&lt;/p&gt;

&lt;p&gt;The important preservation rule is that hibernation is not project deletion. Source, Git history, lockfiles and configuration outside the explicitly approved artifact stay in place. A supported target with protected entries or an incomplete inventory is blocked for review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the AI does not get to read my &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Phase 1 design keeps the scanner metadata-only: filenames are mapped to recognized categories, sizes are measured, and project IDs replace paths in the model request. The model does not receive source code, configuration contents or credentials. This boundary is a testable implementation requirement, not a request politely tucked into a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3x3ak46przxelx9j026y.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3x3ak46przxelx9j026y.png" alt=" " width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demonstration should follow a disposable but runnable project through the whole journey: inspect the plan, remove approved dependencies, verify the source and a fake &lt;code&gt;.env&lt;/code&gt; fixture remain unchanged, then reinstall dependencies and run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CynthiaWahome/dev-hibernate" rel="noopener noreferrer"&gt;https://github.com/CynthiaWahome/dev-hibernate&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The planned stack is React and TypeScript for the interface, a local Express service for filesystem operations, SQLite for the operation record, and &lt;a href="https://ollama.com/library/gemma3:1b" rel="noopener noreferrer"&gt;Gemma 3 1B&lt;/a&gt; through &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; for the assessment.&lt;/p&gt;

&lt;p&gt;I separated the responsibilities because the sentence “the model thought it looked disposable” is not an acceptable deletion policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collect evidence without opening project files
&lt;/h3&gt;

&lt;p&gt;The collector recognizes supported project markers and artifact directories using filesystem metadata. It records completeness and blockers rather than treating an unreadable directory as an empty one.&lt;/p&gt;

&lt;p&gt;An old timestamp does not prove a project is abandoned. For the first version, the user explicitly marks a project as shelved. Active projects can be pinned.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give the local model a restricted inventory
&lt;/h3&gt;

&lt;p&gt;The model's job is to organize the available evidence into a hibernation assessment and a restart checklist. Inputs use generated IDs and fixed categories rather than arbitrary project text or absolute paths.&lt;/p&gt;

&lt;p&gt;Structured output and Zod validation constrain the response. Code rejects unknown candidate IDs and recommendations that conflict with preservation rules. The model has no shell or filesystem tools.&lt;/p&gt;

&lt;h3&gt;
  
  
  Review before mutation
&lt;/h3&gt;

&lt;p&gt;The review screen needs to show the exact local target, estimated size, preservation boundary and reinstall caveats. Only an approved plan can reach the executor, which rechecks its conditions and records the outcome.&lt;/p&gt;

&lt;p&gt;Filesystem changes are not database transactions. A partially failed deletion must be reported as partial, not covered up by an optimistic green tick.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leave a route back
&lt;/h3&gt;

&lt;p&gt;The resume guide records what was removed and the package-manager evidence available. Required runtime versions stay unverified unless the user supplies them. Reinstallation may require internet, registry access and package scripts; it is not a guaranteed undo button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One real implementation lesson:&lt;/strong&gt; The first full end-to-end run exposed two bugs that silently broke the AI step. The model call aborted at a 30-second timeout, but structured-output generation on &lt;code&gt;gemma3:1b&lt;/code&gt; actually takes 35–100 seconds, so every plan came back as &lt;code&gt;model_unavailable&lt;/code&gt; — the fix was raising the timeout to 120s after measuring the real latency. Worse, the policy overlay read &lt;code&gt;project.userShelved&lt;/code&gt; while the column is &lt;code&gt;user_shelved&lt;/code&gt;, so it quietly blocked &lt;em&gt;every&lt;/em&gt; project even when it was shelved. Both were invisible until the chain ran for real. Now a preservation test — delete &lt;code&gt;node_modules&lt;/code&gt;, assert &lt;code&gt;.env&lt;/code&gt; and source survive — runs on every CI push so they can't regress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;A tool inspecting local development projects has a good reason to keep its analysis local. Project inventories can reveal client names, internal work and the shape of someone's development environment, even without file contents.&lt;/p&gt;

&lt;p&gt;Running an open-weight model locally gives this design control over where that assessment happens. It also lets me evaluate another model using the same fixture cases instead of tying the product to a single hosted response format.&lt;/p&gt;

&lt;p&gt;There are no per-token hosted API charges in this local setup. That does not make hardware, electricity or model downloads free, and reinstalling dependencies may still need a connection.&lt;/p&gt;

&lt;p&gt;The open pieces matter because they support a tool I can inspect, adapt and run on my own machine. The model's weights have their own terms; they are not automatically covered by the application's source-code licence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;I ran the full flow on a disposable fixture — one standalone npm project with a multi-package &lt;code&gt;node_modules&lt;/code&gt;. I have not benchmarked against a manual baseline yet, so these are what the tool actually did, not a time-savings claim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Logical dependency bytes removed&lt;/td&gt;
&lt;td&gt;5,500,729 bytes (~5.3 MB) on the fixture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model recommendation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hibernate&lt;/code&gt; — 1 candidate selected, 9-step restart checklist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local model latency (plan)&lt;/td&gt;
&lt;td&gt;~35–100s on &lt;code&gt;gemma3:1b&lt;/code&gt; (structured output)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observed free-space change&lt;/td&gt;
&lt;td&gt;Not separately measured — &lt;code&gt;logical_bytes&lt;/code&gt; is an estimate, not physical disk freed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reinstall and successful start&lt;/td&gt;
&lt;td&gt;Not yet benchmarked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protected fixture preservation&lt;/td&gt;
&lt;td&gt;Passed — &lt;code&gt;node_modules&lt;/code&gt; removed; &lt;code&gt;.env&lt;/code&gt;, source and &lt;code&gt;package.json&lt;/code&gt; intact (checksum-verified, automated test green)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The useful outcome was a reviewed, reversible cleanup: the model proposes, a deterministic policy validates, and the executor only ever touches the one approved &lt;code&gt;node_modules&lt;/code&gt; — the project's source and &lt;code&gt;.env&lt;/code&gt; are provably left alone.&lt;/p&gt;

&lt;p&gt;My goal was not to become more disciplined about cleaning every project. It was to make putting a project away require less effort than continuing to pretend I was about to finish it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; &lt;code&gt;gemma3:1b&lt;/code&gt; runs locally through Ollama and produces the hibernation assessment — a structured recommendation (&lt;code&gt;hibernate&lt;/code&gt; / &lt;code&gt;skip&lt;/code&gt; / &lt;code&gt;blocked&lt;/code&gt;), the selected removable candidates, evidence, and a restart checklist. Its output is constrained by a JSON schema and then re-checked by a deterministic policy overlay, so the model advises but never has the final say on what gets deleted.&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjgkmaoinc4yd9l8u7vqd.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjgkmaoinc4yd9l8u7vqd.png" alt=" " width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>ReceiptRecall: My Groceries Got Expensive, So I Gave My Receipts a Memory</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Sat, 17 Oct 2026 20:21:23 +0000</pubDate>
      <link>https://dev.to/wamzii/receiptrecall-my-groceries-got-expensive-so-i-gave-my-receipts-a-memory-443n</link>
      <guid>https://dev.to/wamzii/receiptrecall-my-groceries-got-expensive-so-i-gave-my-receipts-a-memory-443n</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Started as: "what did I pay for milk last time?" Ended as: a local-AI pipeline, one production-only crash, a format war with my own iPhone, and a language model confidently inventing a Big Mac that was never on the receipt.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Somewhere between the cashier and my front door, my brain deletes the price of milk.&lt;/p&gt;

&lt;p&gt;It keeps song lyrics from 2020. That, apparently, is essential infrastructure. The price of soap from last week? Evicted without notice.&lt;/p&gt;

&lt;p&gt;That gap is &lt;strong&gt;ReceiptRecall&lt;/strong&gt;: a receipt scanner that turns the slip of paper from the till into something useful &lt;em&gt;after&lt;/em&gt; you leave the shop.&lt;/p&gt;

&lt;p&gt;The loop is deliberately small: &lt;strong&gt;photograph a receipt → check the extracted draft → confirm → keep a purchase history you can actually use.&lt;/strong&gt; No barcode-by-barcode scanning. No spreadsheet guilt.&lt;/p&gt;

&lt;p&gt;It exists to answer ordinary questions without an archaeological dig through my handbag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What did I pay for this last time?&lt;/li&gt;
&lt;li&gt;Did the brand get pricier, or did the packet quietly get smaller?&lt;/li&gt;
&lt;li&gt;Is the cheaper-looking option actually cheaper &lt;em&gt;per litre&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;What have I spent this month?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The review step is the whole point. OCR misreads faded ink; a small model will cheerfully confuse a row total with a unit price. So you get an &lt;strong&gt;editable draft before anything is saved&lt;/strong&gt;. A confident wrong total is still a wrong total — just better dressed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's shipped:&lt;/strong&gt; photo upload (including iPhone HEIC), OCR + AI draft, review-and-correct, atomic confirm, and a spending history. Pantry stock and the shopping list are designed and in the schema, but I deferred them to keep the first version honest about what works today.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who would pay for it?
&lt;/h3&gt;

&lt;p&gt;My hypothesis: a household that shops regularly and wants a private receipt archive with real price history — paid value in backup, shared access, or a supported self-hosted install. That's a hypothesis to test, not proof of demand. A subscription button can't tell me whether reviewing a scan actually beats typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run it locally&lt;/strong&gt; (the flow I verified end to end):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/CynthiaWahome/receipt-recall &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;receipt-recall
npm &lt;span class="nb"&gt;install
&lt;/span&gt;ollama pull gemma3:1b          &lt;span class="c"&gt;# local, open-weight, no API key&lt;/span&gt;
npm run dev                    &lt;span class="c"&gt;# http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then: &lt;strong&gt;Scan Receipt → upload a photo → Extract with OCR + AI → correct the rows → Confirm → check History.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CynthiaWahome/receipt-recall" rel="noopener noreferrer"&gt;ReceiptRecall on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stack: &lt;strong&gt;Next.js 16 + TypeScript&lt;/strong&gt;, &lt;strong&gt;SQLite&lt;/strong&gt; (better-sqlite3), &lt;strong&gt;Tesseract&lt;/strong&gt; CLI OCR, &lt;strong&gt;Gemma 3 1B via Ollama&lt;/strong&gt;, &lt;strong&gt;Zod&lt;/strong&gt;, &lt;strong&gt;sharp&lt;/strong&gt;. Receipt images live in private storage, never a public folder; money is stored in integer minor units so no float ever rounds my milk into oblivion.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The architecture separates &lt;em&gt;reading&lt;/em&gt; a receipt from &lt;em&gt;trusting&lt;/em&gt; it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photo → OCR text → structured AI draft → human review → confirmed history.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/tesseract-ocr/tesseract" rel="noopener noreferrer"&gt;Tesseract&lt;/a&gt; turns the picture into text. &lt;a href="https://ollama.com/library/gemma3:1b" rel="noopener noreferrer"&gt;Gemma 3 1B&lt;/a&gt; (open weights, run locally through &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;) organises that text into fields. Gemma is text-only here — nobody is asking a text model to squint at a JPEG and pray. Output is constrained with &lt;strong&gt;Zod + Ollama's JSON Schema format&lt;/strong&gt;, which enforces &lt;em&gt;shape&lt;/em&gt;, not &lt;em&gt;truth&lt;/em&gt;. Hence the review step.&lt;/p&gt;

&lt;p&gt;Then reality showed up, in four acts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The scanner that couldn't scan.&lt;/strong&gt; The OCR and extraction code existed, got merged, then got reverted — because the one thing it depended on, &lt;em&gt;actually uploading a photo&lt;/em&gt;, was never built. So the model had nothing to read and looked broken. Lesson I keep re-learning: build the boring plumbing before the clever part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. My iPhone declared format war.&lt;/strong&gt; Every photo is HEIC, and HEIC hides its pixels behind HEVC compression that &lt;code&gt;sharp&lt;/code&gt;'s bundled libheif can't decode. It read the &lt;em&gt;metadata&lt;/em&gt; fine and then failed on the actual image — the most polite kind of failure. Fix: a pure-JS HEIC→JPEG step before &lt;code&gt;sharp&lt;/code&gt;, so it works on my laptop &lt;em&gt;and&lt;/em&gt; in Docker without system libraries. Bonus trap: newer iPhones shoot 48MP, which blew past my pixel cap. The cap was guarding against decompression bombs, not punishing people for owning a good camera.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The crash that only existed in production.&lt;/strong&gt; Locally, flawless. Built for deploy, every write died with &lt;code&gt;no such table: receipts&lt;/code&gt;. The database schema was loaded from a file via &lt;code&gt;__dirname&lt;/code&gt;, which the bundler rewrites to a path that doesn't exist in the server output. Inlined the schema; crash gone. A green local run proves exactly nothing about the bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Gemma invented a Big Mac.&lt;/strong&gt; On a real Pandamart receipt — rope, sponge cloth, towels — the model returned four line items all named "Big Mac," mangled the quantities, and filed the discount as a product. This is not a bug to patch away; it's a 1B model being a 1B model. It's also the entire argument for the review screen. The AI drafts; &lt;strong&gt;you&lt;/strong&gt; confirm. I made the draft schema permissive (so one weird field doesn't nuke the whole extraction) and kept the strictness at confirm, where it belongs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The arithmetic stays in plain code&lt;/strong&gt;, where it's boring and testable: two 1L packets at 260 total = 130/litre; one 500ml packet at 70 = 140/litre. The smaller sticker loses. Money in integer minor units, row totals kept distinct from unit prices, units and currencies never mixed, confirm wrapped in one idempotent transaction so a double-tap can't buy your milk twice. 39 unit tests guard the maths; I verified the upload→OCR→draft path end to end on a synthetic receipt and a 48MP HEIC, plus duplicate-upload and cross-origin rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;A receipt quietly reveals where you shop, when, and what you buy. I wanted to control where that gets processed.&lt;/p&gt;

&lt;p&gt;With OCR and inference running locally, extraction can stay on my machine after the initial downloads. Host it on a server instead and the photos travel there — so I say it plainly: &lt;em&gt;self-hosted is not the same as "never leaves your phone."&lt;/em&gt; The privacy claim has to match the deployment.&lt;/p&gt;

&lt;p&gt;Open made three things real:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Control over processing&lt;/strong&gt; — local inference instead of a third-party API per receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room to fix the weak link&lt;/strong&gt; — I can swap OCR engines or models against the same test receipts without rebuilding the product around one vendor's response format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A different cost curve&lt;/strong&gt; — no per-token billing. Hardware, power and hosting still cost money, so I won't call it free to run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Credit where due: Tesseract is open-source &lt;em&gt;software&lt;/em&gt;; Gemma ships open &lt;em&gt;weights&lt;/em&gt; under its own model terms. Different licences, both worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 3 1B (open weights, local via Ollama) is the extraction brain — it turns numbered OCR lines into a structured, schema-validated draft, and its honest roughness is exactly why the human-in-the-loop review is the product, not an afterthought.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Step Out: An Offline AI Break Planner with Gemma and Ollama</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Mon, 12 Oct 2026 19:02:09 +0000</pubDate>
      <link>https://dev.to/wamzii/step-out-an-offline-ai-break-planner-with-gemma-and-ollama-hem</link>
      <guid>https://dev.to/wamzii/step-out-an-offline-ai-break-planner-with-gemma-and-ollama-hem</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/mlh-hackathon"&gt;MLH x DEV Writing Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;“Touch grass” is excellent advice until you look outside and find concrete, parked cars, and a neighbour reversing with considerable confidence.&lt;/p&gt;

&lt;p&gt;For someone juggling jobs and spending most of the day at a screen, an outdoor break needs to fit ordinary life. There might be ten spare minutes and a familiar courtyard. A scenic hiking trail is unlikely to appear between two meetings.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;Step Out&lt;/strong&gt;, a local AI outdoor break planner that turns a few preferences into one short activity. It uses &lt;strong&gt;Gemma 3 1B through Ollama&lt;/strong&gt; to generate plans on the computer running the app, without a cloud inference API.&lt;/p&gt;

&lt;p&gt;The idea is straightforward: spend a moment planning, then leave the screen behind.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four questions and an exit strategy
&lt;/h3&gt;

&lt;p&gt;The user chooses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Available time:&lt;/strong&gt; 5, 10, 15, or 20 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A familiar outdoor space:&lt;/strong&gt; a compound or courtyard, a familiar street, or an outdoor seating area.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their goal:&lt;/strong&gt; a quiet pause, gentle movement, or noticing their surroundings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their mobility preference:&lt;/strong&gt; walking or standing, or a seated activity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step Out returns an activity card with instructions, a schedule and a reflection prompt. The user can save it in the browser or download &lt;code&gt;step-out-activity.txt&lt;/code&gt; to use away from the app.&lt;/p&gt;

&lt;p&gt;There is no account to create, no exact GPS location to surrender, and no streak waiting to judge you tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/CynthiaWahome/step-out" rel="noopener noreferrer"&gt;CynthiaWahome/step-out&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video walkthrough:&lt;/strong&gt; &lt;a href="https://res.cloudinary.com/cycy/video/upload/v1791826812/step-out-demo.mp4" rel="noopener noreferrer"&gt;Watch Step Out&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current walkthrough shows the preference selections, the generation request and the error feedback when that request does not complete. It does not show the successful generation or offline tests described below.&lt;/p&gt;

&lt;p&gt;That distinction matters. A loading button is evidence of a loading button, not a completed AI plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local inference on an Apple M1
&lt;/h3&gt;

&lt;p&gt;In my tests, Gemma 3 1B ran locally on an Apple M1 laptop. A cold request took &lt;strong&gt;7.45 seconds&lt;/strong&gt;, while warm requests ranged from &lt;strong&gt;5.45 to 7.54 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;These are measurements from my setup, not a promise that every laptop will perform identically. Local inference depends on hardware, memory and what else the computer is doing. A laptop with 47 browser tabs may have its own opinions.&lt;/p&gt;

&lt;p&gt;I also tested fresh generation with Wi-Fi switched off and no other internet connection active. After the model and application dependencies were installed, the local app could still generate a new plan.&lt;/p&gt;

&lt;p&gt;The model runs on the laptop. This version does not run it on a phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Taking the plan outside
&lt;/h3&gt;

&lt;p&gt;On &lt;strong&gt;9th October 2026&lt;/strong&gt;, I tried a ten-minute seated activity in my residential compound courtyard. I chose a quiet pause, and the generated activity was &lt;strong&gt;“Notice outdoor sounds.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The schedule was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Get ready and reach the outdoor spot&lt;/td&gt;
&lt;td&gt;1 minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observe the sounds around me&lt;/td&gt;
&lt;td&gt;8 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Return and settle in&lt;/td&gt;
&lt;td&gt;1 minute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The instruction asked me to notice three sounds without using my phone and remain seated throughout.&lt;/p&gt;

&lt;p&gt;The break took &lt;strong&gt;9 minutes and 30 seconds&lt;/strong&gt;. I checked my screen &lt;strong&gt;zero times&lt;/strong&gt;. I listened to birds and the neighbourhood hum from a stone bench, then returned and saved my reflection in the browser.&lt;/p&gt;

&lt;p&gt;It was a small break, but it gave the project a useful reality check: could the instructions make sense once I stopped looking at the interface?&lt;/p&gt;

&lt;h2&gt;
  
  
  Partner Technologies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gemma and Ollama for local AI inference
&lt;/h3&gt;

&lt;p&gt;I used Google's open-weight &lt;strong&gt;Gemma 3 1B&lt;/strong&gt;, with the model tag &lt;code&gt;gemma3:1b&lt;/code&gt;, served locally by &lt;strong&gt;Ollama&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Next.js backend sends generation requests to the local Ollama service at &lt;code&gt;127.0.0.1:11434&lt;/code&gt;. In this laptop setup, the preferences are processed on the same computer, and reflections stay in that browser's local storage.&lt;/p&gt;

&lt;p&gt;There are no paid inference API token charges. There are still hardware and electricity costs, and the computer has finite capacity. “Local” does not mean “powered by wishes.”&lt;/p&gt;

&lt;p&gt;The demo also handles one generation at a time. Avoiding an external provider's usage quota does not remove the application's own capacity limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code handles the clock
&lt;/h3&gt;

&lt;p&gt;I kept time calculations out of the model's job description.&lt;/p&gt;

&lt;p&gt;The backend allocates one minute for preparation, one or three minutes for returning, and the remaining time to the activity. Those phases add up to the user's selected duration.&lt;/p&gt;

&lt;p&gt;Gemma selects and adapts an activity. It does not decide that a ten-minute break should somehow contain fourteen minutes of walking.&lt;/p&gt;

&lt;p&gt;Before inference, the backend filters a curated collection of five activities by the selected space, goal and mobility preference. A seated choice excludes the walking activity from the eligible options.&lt;/p&gt;

&lt;p&gt;These rules narrow the model's choices. They do not verify the safety of an outdoor location or prove that every generated sentence is appropriate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured outputs with Zod and Ollama
&lt;/h3&gt;

&lt;p&gt;Small models can produce plausible-looking values that are still wrong for an application. An activity ID such as &lt;code&gt;seated-movement-001&lt;/code&gt; might look perfectly reasonable while pointing to absolutely nothing in the catalogue.&lt;/p&gt;

&lt;p&gt;I constrain the response using a JSON schema generated with &lt;strong&gt;Zod 4&lt;/strong&gt;. The eligible activity IDs form an enum in the response schema, which is passed to Ollama through its &lt;code&gt;format&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;The backend then validates the returned JSON and checks the chosen activity against the eligible list. The purpose is to constrain the response structure and permitted IDs, while retaining application-level checks.&lt;/p&gt;

&lt;p&gt;That improves reliability. It does not make the model's instructions universally correct or turn generative text into a fully deterministic system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Next.js and TypeScript
&lt;/h3&gt;

&lt;p&gt;The app uses &lt;strong&gt;Next.js 16 with the App Router&lt;/strong&gt; and &lt;strong&gt;TypeScript&lt;/strong&gt;, keeping the interface and API route in one project.&lt;/p&gt;

&lt;p&gt;The request flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser collects the four preferences.&lt;/li&gt;
&lt;li&gt;The backend validates them and filters the activity collection.&lt;/li&gt;
&lt;li&gt;Ollama runs the local model using those constraints.&lt;/li&gt;
&lt;li&gt;The backend validates the response and adds the schedule.&lt;/li&gt;
&lt;li&gt;The interface displays the activity card.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain CSS provides labelled controls, visible keyboard focus and a responsive layout. The app does not depend on an external stylesheet or font CDN.&lt;/p&gt;

&lt;p&gt;I used deep teal, warm cream and a small terracotta accent to give the interface a calm, welcoming feel. The main button is easy to find, and the result is designed to be read quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why open weights mattered
&lt;/h3&gt;

&lt;p&gt;Open weights let me run inference on my own hardware and keep the local generation flow independent of a hosted AI API.&lt;/p&gt;

&lt;p&gt;They also let me choose a model that fits the task, change the prompt and edit the activity collection. Another developer can inspect the code and run the application in their own environment.&lt;/p&gt;

&lt;p&gt;The tradeoff is that I own the setup too: model downloads, available memory, slow starts and connection errors. The demo's error message is a practical reminder of that responsibility.&lt;/p&gt;

&lt;p&gt;Gemma is used under its own model terms. The application's licence does not replace those terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hackathon Experience
&lt;/h2&gt;

&lt;p&gt;I built Step Out during the first week of &lt;strong&gt;Hacktoberfest 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I learned
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A small model can be useful when its task is focused.&lt;/strong&gt; Gemma 3 1B did not need to plan my entire life. It needed to turn a few preferences and eligible activities into a short outdoor break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application code still has a job.&lt;/strong&gt; Input validation, activity filtering, time calculations and response checks belong in the backend. A persuasive prompt is not a substitute for those controls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An app can succeed by helping someone leave it.&lt;/strong&gt; I wanted one clear plan that the user could take outside. More time in the interface would work against that goal.&lt;/p&gt;

&lt;p&gt;The courtyard test was the part that made the project feel real. After working on prompts and schemas, I finally followed the instructions myself.&lt;/p&gt;

&lt;p&gt;For once, closing the laptop was part of the acceptance test.&lt;/p&gt;

</description>
      <category>mlhacks</category>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>Automating Android Play Store Releases, Part 4: The Fix I Broke Fixing It</title>
      <dc:creator>cynthia wahome</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:51:46 +0000</pubDate>
      <link>https://dev.to/wamzii/automating-android-play-store-releases-part-4-the-fix-i-broke-fixing-it-4497</link>
      <guid>https://dev.to/wamzii/automating-android-play-store-releases-part-4-the-fix-i-broke-fixing-it-4497</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of a 5-part series on automating a multi-app Android release pipeline. Part 3 → covered the storage-quota wall and the move to a self-hosted runner. This part covers the cleanup that followed — including a bug I introduced while fixing the exact class of bug it eventually became — and the first real production release after all of it.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Fixing the storage-quota problem meant deleting the GitHub-hosted copy of each release AAB right after it publishes to the Play Store. That fix introduced its own bug: the delete step's &lt;em&gt;upload&lt;/em&gt; half wasn't allowed to fail safely, so a hiccup in a step that existed purely for debugging convenience could block the actual Play Store publish — the exact kind of failure the redesign was supposed to eliminate. Around the same bug class, a Discord notification system built on relaying results between jobs through an uploaded artifact was found to be silently reporting genuinely successful builds as failed. Both got fixed the same way: stop routing something through an intermediate hop that doesn't need to exist. Then, on the first real production dispatch after all of it, the pipeline behaved exactly right — and hit one last honest, one-time gotcha that had nothing to do with any of this code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Killing the GitHub Copy for Good
&lt;/h2&gt;

&lt;p&gt;Part 3 ended on retention numbers that had never been sized deliberately — 30 days on a 70MB file that exists purely so a human can grab it for debugging, when the actual deliverable already lives in the Play Store the moment the job succeeds. The real fix wasn't a shorter number. It was: &lt;strong&gt;delete it as soon as it's no longer needed&lt;/strong&gt;, and only fall back to a short retention window as a safety net in case that delete step itself doesn't run.&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# needed to delete the AAB artifact once Play publish succeeds&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;Upload AAB artifact to GitHub&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upload_aab&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;release-aab-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;matrix.route&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.sha&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-builds/android/**/*.aab&lt;/span&gt;
    &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt; &lt;span class="c1"&gt;# fallback only — the delete step below is what actually keeps this clean&lt;/span&gt;

&lt;span class="c1"&gt;# ... Publish to Google Play Console happens here ...&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;Delete GitHub AAB artifact now that Play has it&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="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/${{ steps.upload_aab.outputs.artifact-id }}" || true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file now lives on GitHub for the length of one job run, not a month. Multiply that across every release, both apps, and the storage math stops being a slow-motion problem entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug I Built Into My Own Fix
&lt;/h2&gt;

&lt;p&gt;Here's the part worth actually admitting to, because it's a more useful lesson than the fix itself: &lt;strong&gt;adding that upload-then-delete sequence introduced a new failure mode that hadn't existed before.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The "Upload AAB artifact to GitHub" step, and a smaller step that uploads version-mismatch diagnostics, both sat &lt;em&gt;before&lt;/em&gt; "Publish to Google Play Console" in the job. Neither had &lt;code&gt;continue-on-error: true&lt;/code&gt;. Which meant: if either of those backup/debugging uploads failed for any reason — a transient GitHub API hiccup, exactly the kind of thing you'd expect more of while an org is already mid-incident on Actions storage — the job would stop right there. The real Play Store publish, the actual point of the entire job, would never run. A step that exists purely as a debugging convenience had the power to block the one thing that actually mattered, and it was found live, on a real release, in the middle of diagnosing the very quota incident this design was supposed to be helping with.&lt;/p&gt;

&lt;p&gt;The fix is one line, on each of those two steps:&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;Upload AAB artifact to GitHub&lt;/span&gt;
  &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the backup copy fails to upload, that's logged and the job moves on — the Play Store publish, the part with actual consequences, is no longer hostage to a convenience feature. The lesson isn't "always add &lt;code&gt;continue-on-error&lt;/code&gt;" — plenty of steps &lt;em&gt;should&lt;/em&gt; block a job. It's: &lt;strong&gt;when you add a step whose entire purpose is "nice to have for debugging," explicitly decide whether its failure should be allowed to block the step that actually matters.&lt;/strong&gt; Skipping that question is easy to do while you're focused on the fix in front of you, which is exactly how this one got in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix for the Fix Was Also Wrong, at First
&lt;/h2&gt;

&lt;p&gt;That two-step fix shipped, and PR-check runs on the branch kept failing anyway. The honest first instinct was to assume the fix just hadn't taken effect yet. The actual pushback that broke that assumption was blunt: the fix, as shipped, wasn't working — go find out why instead of re-asserting that it should be.&lt;/p&gt;

&lt;p&gt;Going back to actually verify, rather than re-checking the same two steps again, turned up a third step doing the exact same unprotected thing: a small "Upload release result" step, further down the same job, that also had no &lt;code&gt;continue-on-error&lt;/code&gt;. The two steps fixed first only ever run during a real &lt;code&gt;workflow_dispatch&lt;/code&gt; release — they're structurally silent on a plain PR check. This third step runs on &lt;code&gt;if: always()&lt;/code&gt;, no gate at all, on &lt;em&gt;every single event&lt;/em&gt; including ordinary PR checks. It had been the actual, sole cause of every PR-check failure all week — a different step than either of the two the first fix had targeted, doing the same unprotected pattern in a place that mattered more, not less: on a real release run, it also sits &lt;em&gt;after&lt;/em&gt; "Publish to Google Play Console," meaning a real, successful, already-live production release could still get reported as "failure" over nothing but a bookkeeping upload hiccup.&lt;/p&gt;

&lt;p&gt;The corrected fix protected all three steps, and — since &lt;code&gt;continue-on-error&lt;/code&gt; quietly hides a failed step behind a green checkmark unless something says otherwise — added an explicit warning line to both the job summary and the Discord message whenever a backup step fails, so a silenced failure doesn't just become invisible instead of blocking. The real lesson here isn't the YAML. It's that "I already fixed this" is a claim, not a verification — and the fastest way to find the actual bug was someone else refusing to accept the first answer at face value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Notification System That Was Lying to Us, Quietly
&lt;/h2&gt;

&lt;p&gt;A related design flaw turned up in the same investigation, in the Discord build-status notifications. The original design had one job build each app, then a separate "notify" job download an artifact containing each build's result and post a combined message. It looked clean on a diagram. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A run where an app &lt;strong&gt;genuinely built and shipped successfully&lt;/strong&gt; — confirmed directly in the step logs, with a working download link — still posted a &lt;strong&gt;failure&lt;/strong&gt; message to the team, because the small bookkeeping file recording that success hadn't uploaded cleanly. The build didn't fail. The relay did, and the relay's failure got reported as the build's failure.&lt;/li&gt;
&lt;li&gt;A mixed result (one app ships, one doesn't) sometimes lost one app's outcome entirely in the hop between jobs, depending on exactly which artifact made it through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both bugs point at the same root design problem: routing a result through an intermediate artifact upload/download, between two separate jobs, adds a failure mode that has nothing to do with whether the actual build succeeded. The fix wasn't a smarter relay — it was removing the relay:&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;# Before: one job builds, a separate "notify" job downloads an artifact&lt;/span&gt;
&lt;span class="c1"&gt;# and posts one combined Discord message for both apps.&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# After: each build job posts its own Discord message directly, using&lt;/span&gt;
&lt;span class="c1"&gt;# only data it already has locally. No artifact upload, no download,&lt;/span&gt;
&lt;span class="c1"&gt;# no cross-job relay to fail independently of the thing it's reporting on.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each app now reports its own real outcome, straight from the job that actually knows what happened, with nothing in between that can fail on its own and get misread as the build's fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Real Production Ship
&lt;/h2&gt;

&lt;p&gt;After the runner migration, the artifact cleanup, the &lt;code&gt;continue-on-error&lt;/code&gt; fix, and the notification redesign, the pipeline got its first real test: an actual production dispatch, for real, to ship both apps' first production release.&lt;/p&gt;

&lt;p&gt;The build ran clean. The AAB uploaded successfully. The Play publish step reached Google — and then failed, on committing the release, with a message that looked, at first glance, like it might be another chapter of the exact same saga:&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;It wasn't. This time, the pipeline had done everything right. Google Play requires every production track to have a list of countries/regions configured before a release can go out on it — and because neither app had ever had a production release before, that list had simply never been set. It's a one-time Play Console setup step, not a code problem, not a pipeline bug, not a repeat of the wrong-track issue from Part 2 (that one was "publishing to the wrong, unused track"; this one was "the right track, correctly targeted, just never configured for its very first use"). The fix lived entirely in Play Console — add the countries, save, re-dispatch. Nothing about the workflow needed to change.&lt;/p&gt;

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

&lt;p&gt;Everything else in this series, up to this point, was the pipeline lying to us or breaking silently — a version number that drifted without telling anyone, an error message that pointed at the wrong track, a workflow file that failed to parse with zero errors shown, a notification system that reported success as failure. This last one was the opposite: the pipeline told the truth, plainly, on the first real attempt that mattered — and the one thing left to fix wasn't in the code at all.&lt;/p&gt;

&lt;p&gt;It felt, at this point, like the story was over. It wasn't quite. A few hours after that first real release went out, a message came in that had nothing to do with signing, tracks, storage, or notifications — both apps were showing the same login screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next: Part 5 → — the bug that shipped with the first real release, and a comment that was true the day it was written and silently stopped being true nine minutes later.&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 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>
  </channel>
</rss>
