How We Automated Angular Upgrades at Halodoc using AI

This post is for engineers who maintain more than one frontend and dread every major framework release. It describes how we turned Angular upgrades — historically a slow, repeated, per-repo chore — into a single AI skill that runs across our fleet with a human review gate at the end. We built it as a Claude Code / Cowork skill, but the design principles apply to any framework upgrade you have to do again and again.

Halodoc exists to simplify healthcare for millions of people across Indonesia, and for most of them that care starts on a screen — so keeping the frontend fast, current, and reliable is part of delivering it, not a side quest. The frontend that patients and partners touch is not one application. It is a fleet. Alongside our main websites and portals, we run dozens of Angular micro-apps — patient-facing health tools like symptom checkers, risk calculators, and self-assessments — plus the shared component libraries they all depend on. In total, more than 40 Angular applications and libraries across our web estate. A few of the larger surfaces go a step further: they are micro-frontends — a host application that loads independently built and deployed remotes at runtime, all sharing a single copy of Angular — and, as we'll see, that shared copy is what makes upgrading them the hardest case of all.

Every one of those apps needs to stay current with Angular. And every major Angular release used to mean the same thing: an engineer picks up a repo, runs the upgrade by hand, fixes whatever breaks, hunts down deprecations, and opens a merge request — then repeats the entire ritual on the next repo. It is rarely even the same upgrade twice: some apps track the latest release closely, while others have drifted several majors behind and have to be carried up through a string of intermediate versions.

When Angular 21 landed, we decided to stop doing it by hand. This is how we built an /angular-upgrade skill that does the mechanical work for us, and how running it across the fleet, repo by repo, produced a clean Angular 21 upgrade merge request for every repo it touched.

The problem isn't one upgrade — it's every repo, every release

A single Angular upgrade is annoying. A fleet of them, on a schedule — some lagging several major versions behind — is a tax on the whole team.

The work is almost entirely mechanical, which is exactly why it is easy to get subtly wrong when a human is doing it for the fifth time in a day. A typical major-version bump means:

  • Updating @angular/*, @angular/cli, and the whole constellation of peer packages to compatible versions — and, where an app is several majors behind, repeating that for each intermediate release in turn.
  • Running the official ng update schematics and the automated migrations that ship with them.
  • Applying the newer Angular idioms — for us, that meant migrating templates from *ngIf/*ngFor/*ngSwitch to the built-in @if/@for/@switch control flow.
  • Flagging third-party libraries that have newer compatible releases and bumping them.
  • Fixing whatever no longer compiles, sweeping deprecated APIs, and confirming the build and tests still pass.
  • Opening a clean, reviewable MR.

None of this requires creativity. It requires doing the same sequence correctly, every time, on every repo — which is the definition of a task worth automating. And because the apps share libraries, doing it inconsistently across the fleet creates its own class of problems: a library on the new major and a consumer left on the old one is a version-skew bug waiting to happen.

The skill: grounding first, generation second

The core insight behind /angular-upgrade is the same one behind our data-engineering skills: reliability does not come from the model — it comes from the structure you build around it. An upgrade skill that "asks the model how to upgrade Angular" would be a coin flip. An upgrade skill that is grounded in Angular's own machine-readable upgrade data is auditable and repeatable.

Angular publishes the data behind its update guide as a structured, machine-readable file — recommendations.ts, in the angular/angular repository — that lists every migration step for every version. The skill fetches it and treats it as the source of truth: it reads the repo's current and target versions, then plans a path across multiple major versions in one run — carrying an app that is several releases behind up to the latest, applying each version's migrations in order rather than assuming a single-step bump. During its audit phase it walks every migration step Angular lists for that path and requires 100% coverage — each item either applied or explicitly accounted for — before it will declare a repo upgraded. Nothing is left to the model's memory of "what changed in Angular"; if Angular's data lists N steps, the skill checks off all N.

That coverage gate isn't a slogan — it's a checklist the run has to complete. Every upgrade writes an audit report that must reach 100% before the skill will call a repo done (the numbers below are illustrative, from one repo's v20 → v21 pass):

Angular v20 → v21 — Migration Guide Audit
─────────────────────────────────────────
Total items:     24
Audited:         24 / 24        ← gate: must equal Total
Applicable:       9   → fixed
Not applicable:  15   → documented (feature not used)

 #  Migration step                Status
 1  control-flow (*ngIf → @if)    APPLICABLE — FIXED
 2  inject() over constructor DI  ALREADY HANDLED
 3  standalone bootstrap          NOT APPLICABLE
 …

If any item is left unaudited, the phase fails and the skill stops — it never declares a repo upgraded on partial coverage.

One clarification, because it matters in our setting: "coverage" here is procedural, not a correctness guarantee. 100% means every step Angular lists was applied or explicitly marked not-applicable — it does not mean the resulting app is bug-free. What proves correctness is the build, the full test suite, and the human review, all of which still have to pass.

A single run is one command — /angular-upgrade 21, passing just the target major; the skill reads each repo's current version and plans the path itself. Around that grounded backbone, it runs the upgrade end to end:

  1. Discovery — read the repo's angular.json, package.json, and builder to establish the starting version, the target, and which kind of repo it is: a standalone application, a publishable component library, or a micro-frontend host, remote, or both — the kind sets the gates every later phase uses.
  2. Update — run ng update for Angular core, CLI, and the compatible ecosystem packages, applying the bundled schematics — stepping through each intermediate major when the app is several versions behind.
  3. Audit — walk the recommendations.ts items for every version in the path and drive coverage to 100%, including the @if/@for control-flow migration, then confirm the app actually renders — a remote checked by the entry it exposes, not just a host shell returning a 200.
  4. SSR migration — for server-rendered apps, update the Express server, server providers, routes, and bootstrap to the new Angular SSR APIs (a federated host boots through its federation entry point, not the bare server), while leaving our custom CSP/nonce handling, business redirects, and monitoring untouched (skipped entirely for non-SSR apps).
  5. Build-fix — compile, read the actual errors, and fix what the migrations didn't, iterating until the build is green.
  6. Lint fix — clear the ESLint errors the upgrade introduces, without weakening any lint rules.
  7. Deprecation sweep — clean up APIs deprecated in the new version.
  8. Test fix — run the suite once, then resolve failures by shared root cause rather than one at a time, never deleting or skipping tests.
  9. Library check — now that the repo builds and tests clean, inspect the remaining third-party dependencies and bump the ones with newer compatible releases, holding any shared, federated libraries to the fleet's agreed version, so a run also clears stale libraries rather than only moving the framework.
  10. Validate, report, and open the MR — run the production build and full test suite, generate an upgrade report, then commit on a clean branch and raise the merge request for human review.

Almost every phase runs unattended. Test fix (step 8) can sometimes need a human in the loop to iterate — on a large test suite the skill may not fix everything in one go, because the tests sometimes stop abruptly with no explicit reason and throw it off. Beyond that, the one standing human gate is the final review of the merge request (step 10) — where a developer checks the whole upgrade, the third-party library bumps included, before it merges. Federated fleets add a couple of bounded, fleet-level confirmations — the go-ahead to move the whole fleet together, and the gated publish of a shared library — both covered in the fleet section.

The design goal throughout is fail-fast: if a repo hits something the skill can't safely resolve, it stops and says so, rather than guessing and producing a plausible-but-wrong upgrade. A single SKILL.md orchestrates them, but the detail that is easy to get wrong lives in a library of focused reference files the skill pulls in only when a phase needs it — dependency-group ordering, tsconfig and internal-package handling, SSR and Express server patterns, build- and lint-fix tables, a deprecation map, and the upgrade-report template — plus, more recently, micro-frontend host-and-remote handling and publishable-library peer-dependency handling. It is the same principle as the grounding: structure over recall. Encoding a hard-won lesson means editing one small reference file, not rewriting the skill.

Here is the kind of transformation the audit phase guarantees, taken from one of our risk-calculator micro-apps. Before:

<div class="risk-result" *ngIf="riskScore !== null; else loading">
  <span *ngFor="let factor of riskFactors; trackBy: trackByFactorId">
    {{ factor.label }}: {{ factor.weight }}
  </span>
</div>
<ng-template #loading><app-spinner></app-spinner></ng-template>

After the migration, with the exact same behaviour:

@if (riskScore !== null) {
  <div class="risk-result">
    @for (factor of riskFactors; track factor.id) {
      <span>{{ factor.label }}: {{ factor.weight }}</span>
    }
  </div>
} @else {
  <app-spinner />
}

Because the migration is schematic-driven and then audited against Angular's own list, every condition, binding, and track expression is preserved — the skill isn't rewriting templates from imagination, it's applying Angular's official transform and verifying completeness.

When the unit of work is a fleet, not a repo

Most repos the skill touches are self-contained: upgrade it, review it, ship it. Micro-frontends break that assumption. A host and the remotes it loads live in separate repositories and are built and deployed independently — but at runtime they share a single copy of Angular (and a few other libraries) as a singleton, negotiated in the browser when the host stitches the remotes together. Put the host on Angular 21 while a remote it loads is still on 20 and that negotiation fails — or two copies of Angular load and the app misbehaves.

The trap is that a green build never reveals this. Each repo compiles, tests, and deploys perfectly well on its own; the mismatch only appears at runtime, once host and remote are running together. It is the same failure signature as the silent SSR fallback — everything looks healthy right up until it isn't.

So for a micro-frontend, the repo stops being the unit of work — the fleet is. The skill encodes that directly:

  • It refuses to upgrade in isolation. When it detects a host that loads remotes from other repositories, it stops and asks for explicit confirmation that the whole fleet is being upgraded together, rather than quietly upgrading one repo and drifting it away from the rest.
  • It reconciles shared versions first. Before any repo is touched, every shared library is pinned to one exact version across the entire fleet — not "whatever newest version each repo happens to resolve," which is exactly how repos drift apart. A read-only discovery step reads what each repo currently has installed and flags the mismatches, so the plan is grounded in reality rather than assumption.
  • It upgrades in dependency order. Shared component libraries first, so the target-version builds actually exist for the apps to depend on, then the remotes, then the host — and the later third-party sweep is forbidden from nudging any of those shared libraries off the agreed version.

Those shared libraries needed a second mode, too. A publishable component library has nothing to serve or render — its build is just a packaging step, and what unblocks the apps is each library's peer-dependency range, not its version number. So the skill drops the runtime checks and runs a library flow: bump the peer ranges to the target, rebuild, and open the MR — stopping short of publishing, which stays a gated release step.

None of this is written down in any framework's upgrade guide — it is the hard-won knowledge that some upgrades simply aren't a repo operation.

The last gate: reviewing what the skill produced

Automating the upgrade does not mean trusting the output blindly. Every skill run ends in a merge request, and every merge request gets reviewed — and this is where the approach proved its worth, because an AI-assisted review of the generated MRs caught real issues that a tired human skimming a sprawling diff of well over a hundred files would likely miss.

Before a run ever reaches that human gate, it has to clear its own automated ones: the production build has to compile, the linters have to pass with no rule weakened and no eslint-disable slipped in, and the full test suite has to run green with nothing skipped, deleted, or dropped from coverage — and for a server-rendered app, the page has to actually render (a real HTTP 200 with Angular markup, not a 200 error page). Only a run that clears all of those opens an MR at all; "the build passes" is the floor, not the bar.

Every run also writes more than code. Alongside the MR, the skill drops an upgrade report — a single markdown file the reviewer opens before reading a line of the diff. It records what the run actually did: before/after versions for every package (and any it deliberately skipped, with the reason), the migration-guide audit and its coverage count, files changed by type, the deprecation sweep, SSR and PM2 status, and the build, lint, and test results. Just as important, it records what is left — a Remaining Issues section listing anything the skill couldn't safely resolve, why, and the recommended fix, plus a per-phase completion table showing which phases completed and where, if anywhere, the run stopped early. A developer picking up the MR doesn't reverse-engineer the diff to understand the change; they read the report, see what is done and what still needs a human, and review from there.

On one shared-library upgrade, the review caught a stale peerDependencies range — the skill had moved the library to Angular 21 but left the peer range on the old major. On a server-rendered app it caught something subtler: from Angular 21, SSR silently drops to client-side rendering unless the server is told to trust the forwarding headers a CDN or proxy adds — and because the page still returns a 200, a shallow health check sails right past it. Both were config gaps around the migration, not the template transform, which reviewed clean.

That is the shape of the whole approach: let the machine do the repeatable mechanical work at fleet scale, and spend human (and AI-review) attention on the judgment calls — the stale peer range, the silent SSR fallback, the big cross-major third-party bumps — where it actually pays off.

Results

For Angular 21, we ran the skill across the fleet one repo at a time — and it produced a clean upgrade merge request for each of 45 repos: the micro-apps, the shared component libraries they depend on, and a micro-frontend fleet of a host and its remotes, all moving up to v21 with the same control-flow and type migrations applied consistently.

It wasn't fully hands-off everywhere: around four of the larger applications needed manual intervention, where Angular 20 → 21's internal changes surfaced underlying issues in their unit tests that took a couple of iterations to settle. Most of the changes in an upgrade are standard — unlike feature work, there's nothing net-new to invent, just the same well-specified set of migrations applied to each repo — leading to a near-perfect acceptance rate.

Beyond the count, the qualitative wins are what changed how the team feels about major releases:

  • Consistency across the fleet. Every app gets the identical, audited set of migrations instead of whatever each engineer remembered to do that day.
  • The grunt work disappears — and the hours with it. Discovery, ng update, control-flow migration, library bumps, build-fixing, lint and test fixes, and deprecation sweeps run without a human driving each keystroke. A major-version bump that used to take roughly a day of hands-on work per repo — the upgrade, unit-test fixes, lint cleanup, and dev testing — now runs unattended in about 20 minutes on a small-to-medium repo and around 45 on a large one. That is the unattended machine time, not an end-to-end figure: a human still reviews the resulting MR on top of it — but they are reviewing a finished upgrade instead of performing one, which is where the saving actually comes from.
  • Human attention moves to where it matters. Engineers review a clean MR and adjudicate the genuine edge cases, rather than performing the mechanical steps themselves.

What we learned

Ground the skill in the framework's own data. The single most important decision was auditing against Angular's recommendations.ts with a 100% coverage gate, rather than asking the model what changed. Repeatability beats recall.

Plan for the whole path, not a single hop. Because apps drift to different versions, the skill reads each repo's actual starting point and walks every intermediate major — so an app several releases behind lands on the latest with each version's migrations applied in order.

Some upgrades are a fleet operation, not a repo operation. Micro-frontends share one runtime copy of Angular across separately deployed repositories, so they have to move together on a single reconciled version. Because a green build never catches the mismatch, the skill enforces that whole-fleet discipline itself, rather than trusting each repo to arrive there on its own.

Automation raises the value of review, it doesn't remove it. The stale peer dependency and the silent SSR fallback were caught at the review gate, not during generation. A skill that opens an MR for a human to review is far more trustworthy than one that merges on its own.

Fail fast and say so. A skill that stops on an ambiguous repo and reports the blocker is worth more than one that produces a plausible-but-broken upgrade you only discover in production.

Framework upgrades will never be the exciting part of frontend work. But at fleet scale they are exactly the kind of repetitive, well-specified, error-prone task that a well-grounded AI skill handles better than a human doing it for the fifth time that afternoon — freeing the team to spend its judgment where judgment is actually required.

If you want to build your own

We built this for Angular, but the moving parts aren't Angular-specific. If you're automating a framework upgrade you have to keep repeating, five rules carried the whole thing:

  1. Ground it in the framework's own upgrade data, not the model's memory. Find the machine-readable list of migration steps the framework publishes — for Angular it's recommendations.ts — and make that the source of truth.
  2. Make coverage a gate, not a goal. Every step on that list has to end up either applied or explicitly marked not-applicable, and the run can't call itself done until all of them are.
  3. For shared or federated code, move the whole fleet together. A shared library and its consumers meet at runtime; upgrade them in lockstep on one reconciled version instead of one repo in isolation — that mismatch is the kind a green build never catches.
  4. When the tool is unsure, make it stop and ask — never guess. A run that pauses on a real decision is worth far more than one that ships a plausible-but-wrong change.
  5. Keep human review as the final gate. Let the machine do the mechanical work; a person signs off before anything deploys.

The five rules are framework-agnostic; the skill's Angular details are not — but they're contained to a short list of touchpoints you'd swap to point the same approach at, say, React or Vue: the package scopes (@angular/* and its version-locked companions like CDK/Material and @angular/ssr); the upgrade mechanism (ng update plus Angular schematics → the framework's own codemod/upgrade runner); the config files it reads (angular.json, ng-package.json, federation.config.* → e.g. next.config / vite.config); the build / serve / test commands and the test runner's success grammar; and, for micro-frontends, the federation adapter and the discovery script. Everything else — the grounding, the coverage gate, the lockstep discipline, the stop-and-ask and review gates — carries over unchanged.

We've open-sourced the skill itself — you'll find the full angular-upgrade skill (SKILL.md, its reference playbooks, and the discovery script) in our Halodoc AI Skills Repository.

References

Join us

Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels, and if solving hard problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com.

About Halodoc

Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke. Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.

We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions — and we remain steadfast in our journey to simplify healthcare for all Indonesians.