Release engineering · Change visibility

Deploying to a system that nobody can stop using

Deploying an internal tool is easy right up to the moment people depend on it. After that, a release at the wrong minute is a stopped process, and "we pushed a fix" means nothing to somebody who can't tell whether the fix is on the machine in front of them. The interesting problems here were never in the build — there isn't much to build. They were in the handover, and in what the people using the system are allowed to see afterwards.

2 branch-pinned deploy targets
20 s startup proof before cutover
7 request states mirrored both ways
Confidential client & operation

Built for the internal operations platform of a contract-logistics site, where the people affected by a release are on the floor using the system at the moment it happens.

The problem

An internal platform that a whole site logs into is production software, and production software has to be changed while people are using it. There is no quiet hour on a floor that runs shifts. So the release has to be routine enough to do often, and safe enough that a bad one doesn't cost anybody their afternoon.

The second problem is quieter and takes longer to notice. When users can't see what changed, every release is a rumour — a screen moved, a button behaves differently, and nobody can tell whether that was a fix, a fault, or their imagination. The same goes in the other direction: a request made in passing is a request that gets lost, and once people learn that, the operators who actually know what's wrong stop telling you.

  • The system is in use during every shift; there is no window to take it down
  • A release that fails must leave the site no worse off than not releasing at all
  • A change nobody can see is indistinguishable from a bug
  • A request with no visible state is a request that gets asked for twice, then abandoned

What we built

Two pipelines, one per branch, running on self-hosted runners on the machines they deploy to. A push to the test branch deploys to the test server; a push to the main branch deploys to production. They run the same script — the only meaningful differences are which branch is pulled and which machine the job is pinned to — so the test slot exercises the real deployment path rather than a rehearsal of it.

The deploy itself is deliberately blunt. The branch is downloaded as an archive over the API, the target directory is emptied — everything except the version- control and workflow directories — and the files are laid down fresh. That means a file deleted in the repository actually disappears from the server, instead of lingering as a stale module that still imports.

  • The downloaded archive is checked for a ZIP signature before anything is extracted — a response that isn't an archive never reaches the extract step
  • The deployment path must be explicitly configured; a blank one fails the job rather than writing somewhere arbitrary
  • Running process IDs are captured before a single file is touched
  • A page of the deployed branch's commit history is written into the deployed directory as a step of the job

Failing the safe way

Restarting is where an internal platform gets hurt, so that step carries most of the logic. The new process is started through the machine's own boot task; the job then watches for a process that isn't one of the ones it recorded at the start — ten checks, two seconds apart.

If a new process appears, the old ones are stopped and the deploy passes. If none appears inside that window and an old process is still serving, the job fails on purpose, says exactly why, and explicitly leaves the old process running. The failure mode becomes "the release didn't take", which somebody can look at in the morning, rather than "the site is down", which somebody has to fix now.

The check is on process identity rather than on a status message, because the thing that most often goes wrong in a deploy is that the new process exits during startup. A process that never came up cannot be mistaken for one that did. The tables behind this work are created and extended in place on every access — added columns and guarded indexes, never a drop or a rename — so an older process and a newer one can share the database during the handover without either of them tripping over the schema.

Closing the loop

The version-history page inside the application reads the file the deploy wrote, not the hosted repository. That inversion is the point: the page describes the code that is actually on that machine, it keeps working when the network or the credential doesn't, and each server's changelog reflects its own branch. If the file is missing it falls back to a live query, so a box set up by hand still shows something.

Small things separate a page people trust from one they stop opening. The file is produced by a PowerShell step, so it is read tolerantly of a byte-order mark; anything that isn't a list of records is discarded rather than half-rendered; entries before a fixed cutoff are filtered out, so the changelog starts at a deliberate point rather than at the first commit; and commit text is escaped before it reaches the page, because commit messages are text a human typed.

The other half is intake. Anyone on the floor can file a short request — seven request types, four priorities, what they do today, how many people it affects — which lands in a triage queue with four states, where it picks up an owner, a status and notes. Larger work goes through a full intake record routed to three named approval roles: all three must approve, any single denial settles it immediately, and one recorded vote per role is enforced by a database constraint rather than by the form. An intake record becomes an issue and a card on the development board, and the two sides reconcile every fifteen minutes under a database-level lock so only one process in the fleet ever polls.

  • The card shows the branch linked to the issue, read off the board — so a request in flight points at the code meant to satisfy it
  • Reconciliation refuses to overwrite a status set by the approval vote, so a card dragged on the board can't quietly revert a decision three people made
  • Priority resolves asymmetrically — the board wins where it has a value, the record is pushed up where it doesn't — so the two sides converge instead of flapping
  • Imported comments skip on the external identifier regardless of which side wrote them, so a comment pushed out is never re-imported as a copy; a filtered unique index on source and external identifier backs it in the schema
  • Issue creation reads the stored issue number first and searches the board before adding, so a retried sync can't produce a second issue
  • The manual sync button reports failure as failure — the sweep swallows its own errors internally, so the endpoint inspects the result rather than returning a success the board never actually reflected

The result

Releases became ordinary. The same pipeline runs to the test slot and to production, a failed release leaves the previous version serving, and each machine can say what it is running without anyone opening a terminal.

The honest measure of this work isn't deployment speed. It's that a request submitted from the floor has a visible state and an owner — and, for the work that becomes a project, a branch and eventually a line in a changelog the submitter can read in the same application they filed it from. That is the part that keeps people reporting problems, and the reports are worth more than the pipeline.

Built with

  • Python
  • Flask
  • GitHub Actions
  • Self-hosted Windows runners
  • PowerShell
  • SQL Server
  • GitHub REST & GraphQL APIs
  • Windows Server / IIS

On the details we left out

Client, location, system names and volumes are withheld deliberately. We're happy to go deeper on architecture and approach on a call.

Services this draws on

System Integration

Connect aging systems to modern APIs, data platforms, and SaaS tools.

See the service →

Custom Software & Web Development

Websites, internal tools, APIs, and automation for teams without a dev department.

See the service →

More projects

Observability · Internal platform

Knowing the platform is slow before a person walks over to say so

An internal platform with almost two hundred routes had no way of reporting on itself. We instrumented it from the inside — request timing, worker-thread introspection, user analytics and a live console viewer — without letting the monitoring break the thing it monitors.

Read the story →
Platform · Multi-year build

One application that replaced a decade of spreadsheets

A warehouse ran its floor on shared spreadsheets, emailed reports, and tribal knowledge. We replaced all of it with a single internal platform — and kept it running while people worked.

Read the story →
Integration · Inbound logistics

Knowing where the freight actually is

Inbound shipments were tracked by opening carrier websites one tab at a time. We replaced the tabs with live carrier integrations and a workflow that routes exceptions to the person who can fix them.

Read the story →

Got a system like this?

A free assessment gives you a clear picture and a prioritized plan — no commitment.

Book a free assessment