Continuous Deployment with Angular.io

Yoni Goyhman
WalkMe Engineering
Published in
9 min readApr 3, 2018

We’ve done it! Continuous integration and deployment (CI/CD) with Angular CLI. Here’s the full story showing our process, step by step, so you can do it too!

Angular.io is a framework for developing enterprise-grade mobile and web applications. While it has some learning curve, when compared to competitors like React and Vue, once mastered, it brings greater development (dev) and production advantages (e.g., better out-of-the-box-features, scalability, performance, etc.).

Angular CLI facilitates easy setup and dev with a basic app template, pre-configured web-pack, built-in unit and integration tests, and a set of scripts simplifying dev and build processes. The only thing lacking is a CI/CD environment, wherein we could easily integrate and deploy our application.

In this blog post, I will describe WalkMe’s CI /CD architecture, along with the steps required for building it, some of the issues we encountered, and how to overcome them.

This is NOT an angular tutorial, and basic understanding of angular concept (modules, DI etc.) is required for better understanding this article.

Goal

Our goal was to create an app that can be easily extended by multiple autonomous teams. We wanted to allow our teams to write their code independently, and to be able to reuse their components in future projects.

Even though our teams work independently, we wanted then to enjoy a shared set of tools (both infrastructure-wise, services-wise and basic components-wise) that can be used both in our main app, and in any other project.

Also, we wanted to allow our teams to deploy their code safely and quickly without worrying about whether their code will break in production, or, even worse, break someone else’s code in production.

Having six (you heard me, six!) different teams working on different application features required creating a well-thought-out architecture to support our requirements and facilitate the production of high quality code.

Architecture

We have chosen a multi-repository architecture to allow for the development and deployment of features completely separately from one other. Angular is not completely compatible with this type of architecture, so some minor adjustments had to be made in order to allow compilation and loading of npm modules.

Each feature is developed in a separate repository (“feature modules”), core and shared modules get a repository of their own (“common module”), and the complete app is served from another repository (“host application”) that mainly imports the relevant components and configurations.

This architecture allows us to use features in different host applications, with any set of features, in any route. This also separates shared code from specific code development, allowing better control over the development process (making it easier to locate bugs and faulty commits).

We chose TeamCity as our building platform so it would watch for version control system (VCS) changes, run scripts, and upload files. This is needed so that we can run and test our code automatically upon merge, while preventing deployment of faulty code.

TeamCity watches for changes and triggers a build process in response. A successful build will trigger a deployment process (or publish in the case of a feature module).

Creating packages

The different application components are created as npm packages that are later referenced inside our hosting application. We’ve used ng-packagr to create Angular-compatible npm packages. This made the process of bundling and exporting our code to npm much simpler.

Each package is created inside a complete Angular CLI app. This allows us to easily run and test each package separately:

  • Core and shared modules are created inside a common package (wm-common). Core contains common services, while shared is a UI component library for other features to use.
  • Feature modules are separate business-logic modules. Features use the common modules if required. Each feature declares and manages its own routing as described in the Angular guide. This is required to allow lazy loading.

After creating a package, it is published to npm to be served later. This is done in TeamCity after running lint, unit test and end-to-end tests (explained in the “Configuring TeamCity” section).

Example of creation script:

wm-utils.js

Using packages

Now that we have our packages published, let’s use them in our main application. One thing that is currently missing from Angular is the ability to lazy load modules directly from npm. Angular’s AOT (Ahead of Time) webpack plugin only resolves modules in the app folder, so even though webpack’s dynamic importing allows for resolving from the node_modules folder, we cannot do so. To overcome this, we need to wrap our lazy packages in a local module:

app/modules/feature-lazy/feature-lazy.module.ts

Later we can use this new module as a lazy module in our app-router:

app/app-routing.module.ts

Another thing we need to do is include our assets in list asset roots in angular.cli:

{  "glob": "**/*",  "input": "../node_modules/features-module/",  "output": "./assets/"}

We’ve create a post-install script to do this automatically:

This means, every packages that is created using our build process will add itself to the assets list. This also means we need to create a unique “name space” for our specific assets — otherwise assets might be overwritten by different modules. Thankfully, this is easily achieved by storing all assets in a <module-mane> folder.

Since our feature modules are lazy loaded, they get their own dependency injection tree. This means two things:

  1. All singleton services must implement a forRoot method as described in the Angular guideline. Our lazy modules should not import any services with the forRoot method.
  2. A module with a singleton has to have the same version in the feature and the host app. In the event of a version mismatch, Angular’s compiler will use different versions of a package for the host and for the feature module, which will cause a missing-injection-error for the lazy route.

The host application

Our host application is basically a wrapper for our common and feature modules. It hardly contains any logic of its own, and is basically a configuration app.

The fact that the host application is eventually served to our users makes it a good candidate for testing. We would like to run our tests in the context of the host application, to make sure the different modules integrate well. We will use the built-in protractor package for creating and running integration tests. This can be done in two ways:

  1. Writing all tests in the host application—this will force us to rewrite the tests in every new host application.
  2. Writing the tests inside the feature modules themselves — this will require us to collect, compile and run all tests in the host application. We’ve created a script called all_e2e to do just that (the script looks for modules containing an e2e folder, copies them to a shared destination, compiles them and runs protractor. This is done after building and serving the host application).

Configuring TeamCity

TeamCity is a continuous integration (CI)tool that allows you to run scripts based on different triggers and events (e.g., code-merge to a specific repositories branch, other script successfully finished running, etc.). We are using TeamCity to automatically build, test and deploy our packages and host applications.

Our configuration includes four different script-types (all written in Node.js):

  1. Feature build script: Triggered upon a merge to the master branch of any feature repository, this script builds the feature application, runs lint, unit tests, e2e tests and all_e2e tests in the context of the main host application. Upon successful completion, it packs the feature module using ng-packager.
  2. Feature deploy script: Triggered upon a feature build script-success, this script publishes the feature module created in step 1 to npm. It also automatically generates documentation and test coverage analysis and uploads them to a designated Amazon S3 bucket.
  3. Host application build script: Triggered upon merge to the develop/master branch of the host application, this script runs lint, unit tests, e2e and all_e2e tests. Upon successful completion, it creates an Angular AOT distribution artifact (calling ng build --prod).
  4. Host application deploy script: Triggered manually, this script takes the artifact from the previous step, and uploads it to the appropriate environment’s CDN (i.e., the develop branch goes to QA, the master branch to production).

Pitfalls

Assets: Angular CLI’s webpack configuration resolves assets smaller than 10k bytes as inline base 64 encoded strings. Larger assets are resolved differently based on their respective URLs. If an asset’s URL is absolute (i.e., starts with /), it will be resolved as is. Otherwise, it will be renamed to include its hash, and served from the base of the assets folder. This creates a problem when packing modules using ng-packagr, which uses a slightly different configuration. We want to keep the hashing configuration, but cannot use it without our application breaking (assets will be missing in both build and run-time). Our current solution is to use absolute URLs for asset names, and to hash and rename them later as part of the build script. That way, our application will not break at build-time, and we will still enjoy the benefits of hashing (cache-busting). This script (a little bit ugly, but it works correctly) does the trick, and we run it as a step in the host application’s build script.

Run-time configuration: Angular CLI uses build-time configuration using environment.ts files. We want to use the same build for a different configuration (run-time configuration), so we’ve added a config.js file, which is replaced per environment. This way, exactly the same build will be deployed to all environments.

Dependency management: Building features as packages basically means they should be treated as “plugins” for the host application. As plugins, they should only use 3rd-party dependencies as peer-dependencies and not actual dependencies, and expect the host application to resolve them. Failing to do so could create a situation in which two feature packages are using different versions of a 3rd-party dependency; should this occur, a mismatch would occur upon build, causing the application to break (at build and run-time).

This is also true of the common package, which should be shared among all features. Adding the common package as a dependency of any of the features would potentially create a mismatch at the host application. Instead, list the common package in both peer and dev dependencies, and remember to add it as a dependency only at the host application. That way, you will be able to run both the host and features applications successfully.

Working locally: One major downside of a multi-repository architecture is the difficulty it presents when working locally. In a mono-repo architecture, you can easily watch for changes and update your code. With multi-repo, you are required to pack and symlink you local package to view local changes. You can do this by using yarn link from the location of your packed feature module, and then running your host application using ng serve --prod --preserve-symlink. Currently, Angular requires that you rerun the command upon changes, but it is a bug I believe will be fixed shortly (The bug is actually in webpack’s watch configuration, and not in Angular).

Summary and retrospective

Creating a CI architecture for Angular.io has proved to be more challenging than expected. The main source of the complexity is the multi-repo architecture. Using a mono-repo architecture would be much easier to configure, and it is even possible to use pre-configured 3rd-party tools such as nx.

We have been working with this architecture for the past six month, and, while we have started to enjoy the benefits of CI, working locally has become an issue for the developers. We are currently updating our architecture to include git sub-modules to help with development. I will write a post on this once we are done. Stay tuned!

--

--