Three hours and forty-eight minutes after we opened a ten-minute review, the staff-login follow-up we had opened it for was still untouched in the backlog. A brand new ticket, opened along the way, had shipped a full multi-repo release system across all three repos and closed to staging. The review had surfaced release-flow drift instead of the follow-up.
The product is split across three repos: the main web app, an API service, and a zones service that handles regional routing. A single feature routinely touches two of the three, sometimes all three. JIRA had no way to know which repos a ticket belonged to, and the release process leaned on people remembering. That holds until the day someone doesn’t remember, and a hotfix ships to one repo’s main branch without a matching merge back into develop.
The env grid that came first
Earlier the same day, a separate ticket had finished a 6-environments-by-3-repos isolation matrix: dev, staging, and three named-cell environments, plus production, each fully isolated per repo. That work took 35 hours and change across two days. It also fixed a bad service DACL and a couple of 2016-era relics along the way. The mental model that came out of it was a grid: rows are repos, columns are environments, and each cell is an independent, lockable unit.
Once that grid existed for infrastructure, the release problem looked like the same shape. Rows are repos, the columns are release stages, and each cell needs to know whether it’s clear to ship without checking with the other cells first.
Repo flags stamped by the tooling
The fix for “which repos does this ticket touch” turned out to be boring on purpose. JIRA labels: repo:app, repo:app-api, repo:app-zones. No schema change, no custom field admin work, fully JQL-filterable, and a ticket that spans two repos just carries two labels.
The labels aren’t applied by hand. The branch-cut command stamps the label from whichever repo it cuts the branch in, and the close command verifies the stamp is there before it’ll let a ticket transition. If you cut a branch in the API repo, the ticket gets repo:app-api whether you remember to add it or not. A convention degrades the first time someone is in a hurry. A guard doesn’t.
The wrong turn: scripting the JIRA admin UI
Before we landed on labels, we tried to add a native “Other” option to a JIRA custom field so repo could be a real dropdown instead of free-text labels. JIRA’s field-option admin has no API for this, so we drove the admin UI through Playwright. We grabbed the “Edit Options” link off the page and had the script click through to it:
page.goto("https://[internal-host]" + href)It blew up:
playwright._impl._errors.Error: Page.goto: net::ERR_NAME_NOT_RESOLVED athttps://[internal-host]editcustomfieldoptions!default.jspa/?atl_token=...&fieldConfigSchemeId=11802&fieldConfigId=11802&customFieldId=11202&returnUrl=...The href was a path-and-query string missing its leading slash. Concatenating it onto the host produced garbage, and the browser tried to resolve a hostname that was actually half a URL.
The failure did leak the fieldConfigId (11802) and customFieldId (11202) we needed, so we built a direct URL and skipped the click-through. Even so, the dropdown-option approach was more fragile than free-text labels would ever be. Every teammate with admin rights could rename or delete an option and silently break the JQL filters downstream. Labels won by being harder to break.
Cell-unit locking
With repo flags in place as data, the release side needed a way to stop two repos from stepping on each other mid-release. The local cell grid gives every repo-and-release-stage pair its own lock. A release for app-api can be in flight while app-zones sits untouched, and neither blocks the other from cutting, testing, or shipping. What it does block is two people advancing the same cell at once, the classic multi-repo failure where two people race to close the same repo’s release.
The close path got hardened to match. The close script now does sibling detection: if a ticket carries more than one repo: label, it has to reconcile all of them, not just the one the caller happened to be sitting in. Hotfix tickets get special handling, since a hotfix ships through a master-plus-tag flow instead of the normal feature-into-develop merge:
if category == 'hotfix': if not args.no_merge: args.no_merge = True feature_branch = f'hotfix/{key}'A guard follows right after that, added after an earlier ticket where a hotfix got marked staging/live without the merge actually landing. It asserts that origin/master really contains the hotfix commits before the transition is allowed to fire. It exists because the tool used to trust the branch name over the git history, and on that earlier hotfix ticket the trust was wrong.
Develop-first, enforced by CI
The last piece makes all of this durable instead of a one-time cleanup: develop-first. Every repo hotfixes into develop before it touches main, and the back-merge from main into develop is never optional. That rule already existed as a written convention. What the release-system ticket added was a CI check on push to main in both repos that fails if main has commits missing from develop.
The same day this shipped, the check caught real drift. Develop had fallen behind main in both repos, ancestry-only with the same content, left over from the env-grid ticket’s hotfix earlier in the week. It fast-forwarded clean, but the old system would never have flagged it until it turned into a real conflict weeks later.
Three repos now ship independently, each with its own lock, its own label, and its own guard rail, and none of them have to ask a person which one they’re allowed to touch. The follow-up tickets from that session are the tally of what the tangent cost and what it bought back: a cross-repo guard gap, an organize-standard enforcement gap, and three older tickets whose work this one had already absorbed.
If your product spans several repos, look at how a ticket gets linked to its repos and how main gets merged back into develop. Where tooling does that work, it keeps working on a hurried day. Where a written rule does it, the first hurried day is when it stops.