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.

Release pipeline: two branch-pinned targets, one script, a 20-second proof before cutoverAt the top, two deploy targets: the test branch to the test server and the main branch to the production server, run by the same script on self-hosted runners on the machines they deploy to. The script's steps run left to right: download the branch as an archive over the API, check the ZIP signature before extracting anything, record the running process ids before a file is touched, empty the target except the version-control and workflow directories and lay the files down fresh, then start the new process through the machine's own boot task. Between the old process slot and the new one sits the proof window: ten checks two seconds apart, watching for a process id that is not one of the recorded ones. If a new process is seen, the old ones are stopped and the release passes; if none appears and an old one is still serving, the job fails on purpose, says why, and leaves the old process running. Three notes close the figure: the schema is shared during the handover by extending tables in place, the changelog page reads the commit-history file the deploy wrote, and the intake board mirrors seven request states both ways without overwriting a status set by the approval vote.TWO TARGETS · ONE SCRIPT · SELF-HOSTED RUNNERS ON THE MACHINES THEY DEPLOY TOtest branchtest servermain branchproduction serversame script; only the branchand the machine differTHE DEPLOY SCRIPT · THE SAME STEPS TO EITHER TARGETDownloadthe branch as an archive,over the APICheckZIP signature verifiedbefore anything isextractedRecordrunning process idsnoted before a file istouchedLay down freshemptied (exceptversion-control andworkflow dirs), laid downfresh: a deleted fileactually disappearsStartthe new process, throughthe machine's own boottaskCUTOVER · TWO SLOTS AND A 20 S PROOF BETWEEN THEMOld processrecorded at the start; keepsserving through the windowNew processstarted by the boot task;proven by its process idProof window · 10 checks, 2 s apartwatching for a process id that is not one of the recorded onesNew process seenold processes stopped; the release passesNo new process inside the windowthe job fails on purpose, says why, and leaves the old process serving:the failure is 'the release didn't take', not 'the site is down'seennot seenAROUND THE HANDOVERSchema shared during the handover:tables are extended in place, with addedcolumns and guarded indexes, never adrop or a rename.The changelog page reads thecommit-history file the deploy wrote, notthe hosted repository, so each serverdescribes the code actually on it.Intake board: 7 request states mirroredboth ways, and a status set by theapproval vote is never overwritten by theboard.
One script deploys the test branch to the test server and main to production. A new process must be seen inside a 20-second proof window before the old one is stopped; if it is not, the job fails on purpose and the old process keeps serving.

What makes a deploy safe when there is no maintenance window?

A deploy is safe when a failed release leaves the old version serving. Two GitHub Actions pipelines on self-hosted Windows runners run the same script to test and production, start the new process through the machine's boot task, and stop the old one only after a new process appears, checked ten times two seconds apart.

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. That starts with source control and a real deploy, not files copied by hand.

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

One script, two slots

Test slotProduction slot
Triggered byA push to the test branchA push to the main branch
Runs onA self-hosted runner on the test serverA self-hosted runner on the production server
Deploy scriptThe same scriptThe same script
If the new process never appearsThe job fails and the old process keeps servingThe job fails and the old process keeps serving
Changelog showsThe test branch's own historyThe main branch's own history

What happens when a release fails to start?

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 same rule holds beyond releases, through parallel runs and a rollback plan for larger moves.

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.

An in-app changelog and a request board that syncs both ways

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. It is idempotent sync that a retry cannot duplicate, the only kind worth running on a timer.

  • 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. That is the baseline for maintenance and support for the apps we build.

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.

Questions about this build

Why check process identity instead of a status message?

Because the thing that most often goes wrong in a deploy is the new process exiting during startup. The job records the running process IDs before it touches a file, starts the new process through the boot task, and waits for one that was not on the list. A process that never came up cannot be mistaken for one that did, and if none appears the job fails and leaves the old process serving.

How do the old and new versions share one database during the handover?

The tables are created and extended in place on every access, with added columns and guarded indexes and never a drop or a rename. An older process and a newer one can both run against the same schema while the handover happens, so neither trips over a column the other has not seen yet.

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 System Integration →

Custom Software & Web Development

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

See Custom Software & Web Development →

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 →
Operations platform · Built for ourselves

One app that runs the whole company, and keeps score

The Do-Engine is the operations app we built to run our own business: a task board, a client book, a document vault, invoicing, a help desk and a mail client in one page, with a single XP ledger scoring everything that gets finished.

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 →
Get in touch

Is every release a gamble on the floor?

Tell us how you deploy today and what happens when it fails. Free assessment.

Prefer to talk?

Call 832-598-8234 or email msco@stoneagesoftware.com. Houston, Texas — serving Houston, The Woodlands, Conroe, Sugar Land, Katy, Pearland, and the Greater Houston metro.

What happens next

We read your message, an engineer replies with questions or a straight answer, and if a call would help, we book one. From the first reply on, you're talking to the people who'd do the work.

No obligation — we reply within one business day. This form sends your details to Stone Age Software LLC so we can respond to you. See our privacy policy for how we handle them. This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.