Skip to main content
Home
GrN.dk

Main navigation

  • Articles
  • Cases
  • Services
  • Your Digital Project Manager
  • About Greg Nowak
  • Image Gallery
  • Contact
User account menu
  • Log in

Join my community / free newsletter — sign up here

Breadcrumb

  1. Home
  2. Cases

Mit Håndværk publishes itself to two app stores — and a real phone holds the veto

Client
Mit Håndværk — Danish marketplace app for craft, art and design (mithaandvaerk.dk); Flutter on Android and iOS, Laravel backend
Sector
Mobile delivery automation — Flutter CI/CD, on-device acceptance testing, Google Play and App Store publishing
Period
Pipeline live since 12 July 2026 · 18 production Play releases and 20 green device smokes by 26 July 2026

Mit Håndværk on Google Play ↗ Mit Håndværk on the App Store ↗

Diagram of the delivery pipeline in three lanes: Android to Google Play (commit on main, host gate of 44 tests, signed build, emulator smoke on a real device, Play production); iOS to TestFlight (App Store Connect subscription-approval gate, cloud macOS build, build number from Apple); and a daily device round that reseeds four subscription states and reads the screen back by OCR. Beside it, the real screenshot the pipeline pulls off the emulator on every publish.

At a glance

Mit Håndværk is a Danish marketplace app for craft, art and design — a Flutter app on Android and iOS over a Laravel backend, with subscriptions, real-time chat and push. This case is not about the app's screens. It is about the machinery that puts a new version of it in front of users: a pipeline that takes a commit on main and, roughly ten minutes later, has published it to Google Play production — but only after the release has been installed and cold-started on a real Android device and survived it.

The same pipeline covers Apple from a Linux server that cannot build iOS at all: a release tag hands the build to a cloud macOS machine, which signs it and uploads it to TestFlight — and the tag itself cannot be pushed unless the app's subscriptions are actually approved and priced in App Store Connect.

Everything in the picture above is read off the running system: the counters come out of the pipeline's own log, and the phone panel is the screenshot the publish gate pulls off the emulator on every single release.

10 minutes, unattended, from a commit on main to a published Google Play production release
18 production releases since 12 July 2026, and 20 device smokes before publishing, all green
44 tests must pass before an APK is even built — and a failed launch on the device can never be bypassed

The challenge

Shipping a Flutter app to both stores looks like a solved problem until you write down what actually goes wrong.

"It compiles" and "it starts" are different claims. A release build can pass static analysis and the whole unit suite and still die the moment it launches on a device — a missing native key, a plugin that only misbehaves in release mode, a shrinker that removed something reflection needed. None of that is visible on a build server. It is only visible on a phone.

There is no soft landing on Google Play here. Releases go to the production track at once, so the blast radius of a bad build is the entire user base, not a 5% canary. That single fact decides the whole design: the gate has to sit before the upload, and it has to be un-bypassable.

Apple's characteristic failure is not the build at all. The break this pipeline was hardened against did not fail to compile, sign or upload — it shipped perfectly and then told users "no products available", because a subscription product had left the APPROVED state in App Store Connect. Sandbox and TestFlight are structurally blind to that: they answer from a different configuration than production does. So the check cannot live in the app or in the test accounts; it has to be an assertion against App Store Connect itself, made before a release exists.

Both stores reject a re-used build number — and Play proved it, rejecting an upload outright with "versionCode already used" because a human was expected to bump a number in a file and, once, didn't.

The build host cannot run an emulator. It has no hardware virtualisation, so no Android AVD can boot on it, and being Linux it cannot touch Xcode either. Every on-device claim therefore depends on a second machine — which introduces the last problem: a robot that publishes must be able to tell "the app is broken" apart from "my test rig is offline". Conflate the two and it either blocks releases forever on infrastructure noise, or shrugs and publishes blind.

The solution

A polling CI, not a webhook. Every ten minutes the build host asks the repository whether main has moved. A new commit takes a single-flight lock (a signed build outruns the ten-minute tick) and is recorded once it is built — with the store upload tracked in its own state, so a transient upload failure retries on the next tick instead of being silently marked done. Only main is ever built: test branches can be pushed all day and publish nothing.

A host gate that runs before an artifact exists. Dependencies resolve, static analysis runs, the unit and widget suite runs as a hard gate — 44 tests today, up from 3 when the gate was introduced — repository hygiene is checked, and the live backend is probed read-only: the REST endpoint must answer 200 and the public WebSocket must complete a real handshake through Cloudflare. A failure here means no APK is produced at all, an alert mail goes out, and the bad commit is marked so it is not retried every ten minutes forever.

Then the phone gets a veto. The signed build is copied to a second machine that does have virtualisation, an Android emulator boots headless, and the app is installed fresh, its storage cleared, the log cleared, and launched through the real launcher intent. Ten seconds later two things are asserted: the process is still alive, and there is no fatal exception in the log. A screenshot is pulled back as evidence — that is the phone panel in the image above, produced by the pipeline itself, not staged for this page.

The verdict is three-way, and that is the important part. Pass publishes. A genuine launch failure always blocks the publish, can never be bypassed by any flag, and is terminal, so a broken commit is not re-tested and re-alerted every ten minutes. "Test rig unreachable" is its own outcome: it blocks by default too, but it is treated as transient — the release simply retries when the rig comes back. An operator can opt in to publishing without a device check, and that switch ships off.

Nobody bumps a version by hand. Before uploading, the pipeline asks Google Play for the highest versionCode it has ever seen across bundles, APKs and track releases, adds one, and stamps that on the build. The upload goes to the production track as a completed release, and a mail reports version, build number, commit and the smoke outcome — with the subject line saying whether the device check passed, was skipped, or blocked the release.

Apple, driven from a Linux host. A release tag triggers a cloud macOS builder, and the tag is gated before it is even pushed: a script queries App Store Connect and asserts that every subscription product is APPROVED at the expected Danish krone price — if not, the tag is not pushed and nothing ships. The identical check is vendored into the cloud build as a step of its own, so even a hand-pushed tag fails at the gate rather than producing a signed release of a broken purchase flow. It has been proven in both directions: a deliberately bogus product list failed a scratch build at the gate with zero artifacts, and the first real gated release tag went through all fourteen steps into TestFlight.

The iOS lane's remaining steps are all lessons. Swift Package Manager is disabled so CocoaPods alone embeds the push framework (both at once produced duplicate frameworks at archive time); the five credentials are validated before anything expensive starts; the committed lock file is dropped so pods resolve fresh; automatic code signing is forced to manual for CI only, because the committed project targets a developer's machine and an unattended runner has no Apple account to sign interactively with; signing files are fetched for both bundle identifiers — the app and its notification extension; and the build number is taken from the latest TestFlight build plus one, so a re-tag never needs a version edit.

iOS tests run in the cloud too. A second workflow with no trigger of its own is started through the build provider's REST API, boots an iOS simulator and runs the integration tests — which is how an iOS-only change gets a real test result from a Linux host that cannot build for iOS.

And every morning, independent of any release, the same device is used again. The newest build is re-installed and asserted to start; then four subscription states are reseeded on the backend — active, two flavours of cancelled, and lapsed — the app is driven per fixture, and the subscription screen is read back with OCR. That is not a stylistic choice: a Flutter view never reaches the accessibility "idle" state and exposes no text nodes to the standard Android tooling, so reading the pixels is the only reliable way to assert what the user is actually being told. It exists because of a real bug — a cancelled subscription that announced it expired "in 0 days" — and it guards that whole chain, from stored order state through the live reconcile to what the API and socket hand the app. It alerts only on a genuine app defect, and stays quiet when the rig is simply off; infrastructure hiccups never cry wolf.

The results

What the pipeline has actually done, read off its own log on 26 July 2026: running since 12 July, 1,838 polling runs, 18 releases published to the Google Play production track (versionCode 116 through 133), and 20 pre-publish device smokes — every one green. No release has ever been published without one, and the bypass switch that would allow it has never been used. In 17 of those 18 releases the entire run — host gate, signed build, device smoke, bundle upload — finished inside a single ten-minute window; the one exception was a Gradle build daemon that crashed mid-build and published on the next tick instead. The host gate has grown from 3 tests to 44 in that time, and the five failed builds in the whole period were all environment rather than app code: four on the first day, over file ownership between the shared Flutter SDK and the checkout, and that one crashed daemon.

On the Apple side the gate is live on the real ship path, not just in a test lane: the first production release tag pushed through it, on 24 July 2026, cleared all fourteen build steps — credential validation, the subscription-approval gate against App Store Connect, fresh pods, forced manual signing, profile fetch, an auto-incremented build number — and landed in TestFlight as a signed build. The App Store listing itself sits one version behind Google Play on purpose: Apple human-reviews production releases anyway, so submitting for review stays a deliberate human decision and is a single commented line away from being automatic.

What this does not claim. A green launch smoke proves the app installs and cold-starts without crashing — it does not prove every screen works. Screen-level confidence comes from the 44-test host suite, the morning OCR regression over four subscription states, and the simulator integration lane run on demand; there is no crash-rate or adoption figure quoted here, because measuring that is the store consoles' job, not this pipeline's.

What it costs, stated plainly. Publishing straight to 100% production means a large dependency upgrade is worth its own release rather than being batched, and two of them must never be in flight at once — so those runs deliberately serialise and pause the schedule while they work. Because the build host cannot emulate anything, every on-device claim depends on a second machine being up, which is exactly why "rig offline" is a first-class verdict instead of a silent pass. And the honest summary of the whole design is the rule the publish script enforces: the machine may publish, but it may never publish something that failed to start.

A real app-launch failure always blocks the publish — never bypassable, and terminal, so a broken commit is not re-smoked and re-alerted every ten minutes.

The rule in the publish script that decides whether a build reaches Google Play
  • Flutter
  • Android
  • iOS
  • DevOps
  • Automation
  • Release operations
  • quality assurance
  • Google Play
  • App Store Connect
  • text recognition

Got a project that needs the same kind of hands-on delivery?

Your digital project manager

Review Greg on Google

Greg Nowak Google Reviews

 

Written recommendations from Trafik og Veje, Aarhus Municipality (2011) and AgroTech (2010) — read them on LinkedIn.

Illustrated infographic summarizing: WordPress 7.1 Forces the Editor Into an iframe—Test Your Custom Blocks
WordPress 7.1 Forces the Editor Into an iframe—Test Your Custom Blocks
2026-08-15

WordPress 7.1 removes the non-iframe editor fallback. Learn how to audit custom blocks, test real workflows and fix compatibility issues before launch.

Illustrated infographic summarizing: GitHub will stop sending jobs to stale self-hosted runners
GitHub will stop sending jobs to stale self-hosted runners
2026-08-14

GitHub starts enforcing runner versions on August 24, 2026. Audit and upgrade self-hosted runners before builds and deployments start stalling.

Illustrated infographic summarizing: Your AI Agent Has Shell Access. What Can It Reach?
Your AI Agent Has Shell Access. What Can It Reach?
2026-08-13

A practical guide to mapping what a shell-enabled AI agent can reach, then containing its access to files, credentials, networks, tools, and high-impact actions.

Illustrated infographic summarizing: Cloudflare Changed DoH JSON. What Else Is Parsing DNS as Text?
Cloudflare Changed DoH JSON. What Else Is Parsing DNS as Text?
2026-08-12

Cloudflare’s DoH JSON change exposes brittle DNS parsing. Find affected scripts, test both formats, and choose a safer integration contract.

Illustrated infographic summarizing: Your Website Can Answer Questions Now. Should It?
Your Website Can Answer Questions Now. Should It?
2026-08-11

NLWeb makes conversational website search practical to deploy. The real question is whether your content, users and team are ready to support it.

Illustrated infographic summarizing: AI Search Finally Has Reports. Now Connect Visibility to Revenue
AI Search Finally Has Reports. Now Connect Visibility to Revenue
2026-08-11

Google and Bing now expose first-party AI search data. The real task is connecting citations and impressions to analytics, CRM outcomes, and revenue.

Illustrated infographic summarizing: The Bot Passed Your CAPTCHA. What Did It Do Next?
The Bot Passed Your CAPTCHA. What Did It Do Next?
2026-08-11

Passing a challenge is only one signal. Session analysis, server-side validation and endpoint-specific controls help reduce bot abuse without blocking customers.

Illustrated infographic summarizing: WordPress 7.1 Moves Image Work Into the Browser—Test Every Media Hook
WordPress 7.1 Moves Image Work Into the Browser—Test Every Media Hook
2026-08-11

WordPress 7.1 shifts image processing into supported browsers. Here is what to test across hooks, CDNs, formats, security headers, and fallbacks.

Illustrated infographic summarizing: Prompt Caches Have Write Costs Now—Audit What Your Workflow Reuses
Prompt Caches Have Write Costs Now—Audit What Your Workflow Reuses
2026-08-10

GPT-5.6 makes cache writes billable. See how to spot wasted writes, stabilise prompt prefixes, place breakpoints and measure whether caching pays.

Illustrated infographic summarizing: The AI Crawler in Your Logs May Be Wearing a Borrowed Name
The AI Crawler in Your Logs May Be Wearing a Borrowed Name
2026-08-09

A User-Agent is a claim, not proof. See how to verify AI crawler traffic before it shapes reporting, robots.txt decisions, or WAF exceptions.

More articles
RSS feed

Footer

  • All articles
  • Contact

GrN.dk — AI automation, web platforms, web optimization, data handling and logistics.

© 2026 GrN.dk · LinkedIn · Contact · AI automation in Danish: nowa.dk

Behind GrN.dk: Individual Entrepreneur Codecrafter · Tax ID 305669096 · Bakhtrioni St. 22, 0194 Tbilisi, Georgia · official business register