Skip to main content
GrN.dk

Main navigation

  • Articles
  • Cases
  • Contact
  • Your Digital Project Manager
  • About Greg Nowak
  • Services
  • Portfolio
  • Container
    • Excel Freelancer
    • Kubuntu - tips and tricks
    • Linux Apache MySQL and PHP
    • News
    • Image Gallery
User account menu
  • Log in

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 ↗

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

 

Illustrated infographic summarizing: One Timeout, Two Orders: Make AI Actions Safe to Retry
One Timeout, Two Orders: Make AI Actions Safe to Retry
2026-07-25

A timed-out AI action may already have succeeded. Stable keys, durable ledgers, queues and stored results prevent a routine retry from duplicating real work.

Illustrated infographic summarizing: Your AI Visibility Dashboard Needs a Methodology, Not More Charts
Your AI Visibility Dashboard Needs a Methodology, Not More Charts
2026-07-24

A practical framework for measuring AI-search visibility with fixed prompts, repeated tests, separate metrics, retained evidence, and honest reporting.

Illustrated infographic summarizing: AI Admin APIs Are Here—But Your Directory Is Still the Source of Truth
AI Admin APIs Are Here—But Your Directory Is Still the Source of Truth
2026-07-23

New AI admin APIs can automate access and spend controls, but reliable governance still starts with authoritative directory data and clear ownership.

Illustrated infographic summarizing: OpenAI Presence Arrived—But Is Your Workflow Ready for an Agent?
OpenAI Presence Arrived—But Is Your Workflow Ready for an Agent?
2026-07-22

Before an AI agent can take on real work, its workflow needs clear scope, permissions, handoffs, evaluation cases, and production monitoring.

Illustrated infographic summarizing: Chatbot Transcripts Quietly Became a Retention and Redaction Problem
Chatbot Transcripts Quietly Became a Retention and Redaction Problem
2026-07-21

Chatbot transcripts spread across providers, logs and support tools. Here is how to map each copy, redact sensitive data and test deletion properly.

Illustrated infographic summarizing: Cloudflare Service Keys Stop in September: Find Every Caller
Cloudflare Service Keys Stop in September: Find Every Caller
2026-07-20

Cloudflare Service Keys stop working on September 30, 2026. Here is how to find every caller, move to scoped API tokens and avoid a late outage.

Illustrated infographic summarizing: Your AI Workflow Needs an Acceptance Test Before It Meets Customers
Your AI Workflow Needs an Acceptance Test Before It Meets Customers
2026-07-19

A practical way to test AI workflows using realistic scenarios, tool checks, human rubrics, regression suites, and clear release gates.

Three cover candidates for The Goats Were Load-Bearing fanned on a dark background: an ember-lit door, three slow knocks, and a founders' ledger
The Goats Were Load-Bearing: a fantasy where the bill always comes due
2026-07-19

A teaser for the upcoming darkly comic fantasy novel The Goats Were Load-Bearing — a village, a door that must stay poor, and the worst possible time to sell the herd. Readers pick the cover.

Vegan Power game: the yellow player catches falling fruit while a chicken and a cow look on
Vegan Power: The Little Game About Eating Fruit, Not Friends
2026-07-19

Vegan Power is a free browser game where you catch fruit, dodge the animals, protect seven hearts, and chase a better high score.

KotobaMon title screen: the Japanese logo コトバモン over a low-poly 3D island with monsters, cherry-blossom trees and a trainer.
KotobaMon: Shipping a 3D Browser Game With No Build Step and Self-Hosted Voice
2026-07-19

A look at fantasy.grn.dk, a browser-based 3D game that teaches Japanese with no build step, procedural art and self-hosted AI voice, and what its constraints show about shipping interactive products fast and cheap.

More articles
RSS feed

GrN.dk web platforms, web optimization, data analysis, data handling and logistics.