Continuous code generation - TypeScript libraries

Jonas Lagoni Avatar

Jonas Lagoni

ยท7 min read
  1. Continuous code generation - Automated Utopia
  2. Continuous code generation - Versioning
  3. Continuous code generation - TypeScript libraries
  4. Continuous code generation - .NET libraries
  5. Continuous code generation - Automatically set up new libraries and APIs

First time putting a video together since, forever, so don't judge too harshly. No idea why it's so matrix-like at times, and no idea how to fix it, so it is what it is ๐Ÿ˜„

The video shows from start to finish the current state of the continuous code generation for TypeScript libraries. So what is happening in the video?

First, an API change happens, where we add a new channel for the public Rust API, through a PR (read more about the setup here). The API version is then bumped to 0.8.0 as it was a minor version change.

Once the version changes, it's time to remotely trigger the code generation process, in this example for the rust-ts-public-api library. The code generation process is what the previous blog post outlined. The CI system then provides a PR with the desired change based on the version change of the API and code template.

Once merged the library is then released with the appropriate new version and then afterward bumps the package version to 0.5.0.

As the video shows, it's not quite fully automated, as I still need to manually merge the PRs, it is, however, an "easy fix", as you can get the CI to auto-merge and even approve PRs if it's a specific bot that creates it (we have a similar setup at AsyncAPI). I just have not come around to include it yet even though it's the idea.

Generate new code

Because I choose to host (or at least that's how it came to be, as initially it was hosted on Gitlab) it on GitHub, many of the automation steps are for GitHub actions. However, it should be "rather easy" to translate it to another CI system as the building blocks and processes are there.

Find the current version of the library

As part of the generation script, one step was to find the current version of the library to know where we are left off. For the TypeScript library, it is straightforward, as we can read the version with jq from the package.json file.

library_last_version=$(cat ./package.json | jq -r '.version')

Auto-generate the library

Next is to trigger the code generation when the API receives a version change.

To achieve this I choose to add a generation workflow in the API library and one where the AsyncAPI documents are located, to remotely trigger the generation workflow. This is not the only way to achieve it, but it was the one I found most suiting in this case.

For the generation workflow, it's rather simple as all it does is execute the generate.sh script, read the results of the generation process - and then create appropriate PR with the desired conventional commit. The workflow is then set to be triggered on workflow dispatch which can be done remotely.

1name: Auto update generated client
2on:
3  workflow_dispatch:
4
5jobs:
6  update-code:
7    runs-on: ubuntu-latest
8    steps:
9      - uses: actions/checkout@v3
10
11      - uses: actions/setup-node@v3
12        with:
13          node-version: '14'
14      
15      - name: Generate code
16        run: chmod +x ./generate.sh && ./generate.sh
17
18      - name: Set Environment Variables from generator file
19        uses: ./.github/actions/setvars
20        with:
21          varFilePath: ./.github/variables/generator.env
22
23      # Follow conventional commit for PR's to ensure accurate updates
24      - name: Create pull request for major version if needed
25        if: ${{env.major_version_change == 'true'}}
26        uses: peter-evans/create-pull-request@v4
27        with:
28          title: "feat!: major version change"
29          body: ${{env.commit_message}}
30
31      - name: Create pull request for minor version if needed
32        if: ${{env.minor_version_change == 'true'}}
33        uses: peter-evans/create-pull-request@v4
34        with:
35          title: "feat: minor version change"
36          body: ${{env.commit_message}}
37
38      - name: Create pull request for patch version if needed
39        if: ${{env.patch_version_change == 'true'}}
40        uses: peter-evans/create-pull-request@v4
41        with:
42          title: "fix: patch version change"
43          body: ${{env.commit_message}}

To remotely trigger the code generation from where the AsyncAPI documents are located, we can then use the GitHub action benc-uk/workflow-dispatch to trigger the workflow remotely. This is run whenever the bundled AsyncAPI document change. This is my lazy way of detecting version changes, as the AsyncAPI documents are bundled on each release.

1name: Trigger remote code generation for Rust public API
2on: 
3  workflow_dispatch: 
4  push:
5    branches:
6      - main
7    paths:
8      - 'bundled/rust_public.asyncapi.json'
9jobs:
10  generate:
11    runs-on: ubuntu-latest
12    steps:
13      - name: Update TS public game API library
14        uses: benc-uk/workflow-dispatch@v1
15        with:
16          workflow: Auto update generated library
17          token: ${{ secrets.GH_TOKEN }}
18          repo: GamingAPI/rust-ts-public-api
19      - # Update ... public game API library

The only thing we cannot trigger upon is when the code template receives a change. You could fall back to a cronjob which could trigger the generation workflow at some specific time, so it's semi-automatic.

Release the new library

This setup is a page out of the AsyncAPI setup, where I reuse the exact setup that Lukasz authored. If you want a more in-depth description of the setup, you can watch the Let's talk about contributing - CI/CD at AsyncAPI, otherwise, I will make sure to recap the major parts below.

It has two core concepts, the package.json file has specific release scripts and release configurations which is being triggered by a release workflow (for the library it's this workflow) that ensures everything is released based on the conventional commits it finds since the last release. Similar to what the setup is for the AsyncAPI files. Once released we bump the version of the library through this workflow.

Turning towards our code generation, the first dilemma is that the code template is generating a full project, meaning it already generate a package.json file. This means it does not contain our needed release scripts or dependencies for supporting the continuous release.

Luckily it's JSON, so we can jq once again to make it contain what we need.

We can therefore create a custom_package.json file to contain all our customized properties (more or less copy-pasted from the AsyncAPI setup):

1{
2  "name": "@gamingapi/rust-ts-public-api",
3  "description": "TypeScript public API wrapper for rust",
4  "publishConfig": {
5    "access": "public"
6  },
7  "keywords": [],
8  "author": "Jonas Lagoni ([email protected])",
9  "license": "Apache-2.0",
10  "bugs": {
11    "url": "https://github.com/GamingAPI/rust-ts-public-api/issues"
12  },
13  "files": [
14    "/lib",
15    "./README.md",
16    "./LICENSE"
17  ],
18  "homepage": "https://github.com/GamingAPI/rust-ts-public-api#readme",
19  "scripts": {
20    "generate:assets": "npm run build && npm run docs",
21    "bump:version": "npm --no-git-tag-version --allow-same-version version $VERSION",
22    "release": "semantic-release",
23    "prepublishOnly": "npm run generate:assets"
24  },
25  "devDependencies": {
26    "@semantic-release/commit-analyzer": "^8.0.1",
27    "@semantic-release/github": "^7.0.4",
28    "@semantic-release/npm": "^7.0.3",
29    "@semantic-release/release-notes-generator": "^9.0.1",
30    "conventional-changelog-conventionalcommits": "^4.2.3",
31    "semantic-release": "^17.0.4"
32  },
33  "release": {
34    "branches": [
35      "main"
36    ],
37    "plugins": [
38      [
39        "@semantic-release/commit-analyzer",
40        {
41          "preset": "conventionalcommits"
42        }
43      ],
44      [
45        "@semantic-release/release-notes-generator",
46        {
47          "preset": "conventionalcommits"
48        }
49      ],
50      "@semantic-release/npm",
51      "@semantic-release/github"
52    ]
53  }
54}

The generator script can then be adapted so after the code is generated, we can merge the package.json file from the code template without custom_package.json.

1# Merge custom package file with template generated
2jq -s '.[0] * .[1]' ./package.json ./custom_package.json > ./package_tmp.json
3rm ./package.json
4mv ./package_tmp.json ./package.json

Some reflection

While that finishes the setup for TypeScript libraries, it's far from over - cause this is quite a complex setup and quite a few steps to take to achieve it. I still think how all of this can be reduced to a single step... Maybe it's not necessary to have the library in a repository, but rather just releasing it through a library. But I need some more experience in how different languages do things to put something together.

It is hard for the consumer of the library to know exactly which versions represent which API versions. So some kind of compatibility matrix would be great to have... Not sure how that would be achievable ๐Ÿค”

The same goes for the commit messages, could be better to describe exactly which API changes they reflect. As you can see on the video, it only describes what triggered the change, either the template or the AsyncAPI document, which is not very informative - but maybe they don't need to be ๐Ÿค”

Photo by Iswanto Arif on Unsplash