TypeScript CircleCI Configs: Only 3 Lines

You can now create TypeScript CircleCI configs with 3 lines of code:

import { createConfig, JobNames } from "@getlocalci/create-config";

createConfig(JobNames.JsTest, JobNames.JsLint, JobNames.Vsix);
Code language: TypeScript (typescript)

No more copy-pasting .yml between repos.

Most of the time, you’ll only need to edit one repo.

For example, let’s say you want to bump the resource class to "large" on all of your repos:

CircleCI customize your resource class

You could make the edit in 1 repo, and it’ll apply to all of them.

Same if you want to update a deprecated image.

How?

With the new TypeScript Config SDK, thanks to Kyle Tryon and Jaryt Bustard.

You can keep your common config in a single repo, like @getlocalci/create-config.

Like how Kyle Tryon shows in his great post.

And you can import that config into other repos with only:

import { createConfig, JobNames } from "@getlocalci/create-config";

createConfig(JobNames.JsTest, JobNames.JsLint, JobNames.Vsix);
Code language: TypeScript (typescript)

You don’t even have to publish the config repo as an npm package, or compile the TypeScript.

You can import it with:

$ npm install https://github.com/getlocalci/create-config/ -DCode language: Bash (bash)

…and npm will install it from the default branch on GithHub.

There are 2 parts:

  1. Your common config repo
  2. Consuming repos that import that repo and call createConfig(). You might have dozens.

Here’s a real example of both:

1. Common Config Repo

This will have the jobs you usually use.

And it’ll export createConfig() so consuming repos can run these jobs.

This is just an example.

Your repo might have completely different jobs.

Create a new repo, and add an index.ts in the root.

We’ll start with some imports, and an object JobNames:

index.ts

import CircleCI from "@circleci/circleci-config-sdk";
import fs from "fs";
import glob from "glob";
import path from "path";

/**
 * Job names you can pass to createConfig().
 * These already have jobs created for them.
 */
export const JobNames = {
  E2eTest: "e2e-test",
  JsLint: "js-lint",
  JsTest: "js-test",
  PhpLint: "php-lint",
  PhpTest: "php-test",
  Vsix: "vsix",
  Zip: "zip",
};
Code language: TypeScript (typescript)

Consuming repos will import JobNames, so they know which jobs they can add.

On line 25, we’ll add preCreatedJobs that any config can use:

import CircleCI from "@circleci/circleci-config-sdk";
import fs from "fs";
import glob from "glob";
import path from "path";

/**
 * Job names you can pass to createConfig().
 * These already have jobs created for them.
 */
export const JobNames = {
  E2eTest: "e2e-test",
  JsLint: "js-lint",
  JsTest: "js-test",
  PhpLint: "php-lint",
  PhpTest: "php-test",
  Vsix: "vsix",
  Zip: "zip",
};

const nodeExecutor = new CircleCI.executors.DockerExecutor(
  "cimg/node:lts",
  "large"
);

const preCreatedJobs = [
  new CircleCI.Job(JobNames.JsLint, nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      command: "npm ci && npm run lint",
    }),
  ]),
  new CircleCI.Job(JobNames.JsTest, nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({ command: "npm ci && npm test" }),
  ]),
Code language: TypeScript (typescript)

Each value in preCreatedJobs will be a CircleCI.Job.

The constructor of CircleCI.Job accepts:

  1. Job name
  2. Executor
  3. Steps

You’ll get type-hinting for each of these:

TypeScript CircleCI Config SDK command type hinting

On line 103, we’ll add functions to create the config:

import CircleCI from "@circleci/circleci-config-sdk";
import fs from "fs";
import glob from "glob";
import path from "path";

/**
 * Job names you can pass to createConfig().
 * These already have jobs created for them.
 */
export const JobNames = {
  E2eTest: "e2e-test",
  JsLint: "js-lint",
  JsTest: "js-test",
  PhpLint: "php-lint",
  PhpTest: "php-test",
  Vsix: "vsix",
  Zip: "zip",
};

const nodeExecutor = new CircleCI.executors.DockerExecutor(
  "cimg/node:lts",
  "large"
);

const preCreatedJobs = [
  new CircleCI.Job(JobNames.JsLint, nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({
      command: "npm ci && npm run lint",
    }),
  ]),
  new CircleCI.Job(JobNames.JsTest, nodeExecutor, [
    new CircleCI.commands.Checkout(),
    new CircleCI.commands.Run({ command: "npm ci && npm test" }),
  ]),
  new CircleCI.Job(
    JobNames.PhpLint,
    new CircleCI.executors.DockerExecutor("cimg/php:8.0", "large"),
    [
      new CircleCI.commands.Checkout(),
      new CircleCI.commands.Run({
        command: "composer i && composer lint",
      }),
    ]
  ),
  new CircleCI.Job(
    JobNames.PhpTest,
    new CircleCI.executors.DockerExecutor("cimg/php:8.1", "large"),
    [
      new CircleCI.commands.Checkout(),
      new CircleCI.commands.Run({ command: "composer i && composer test" }),
    ]
  ),
  new CircleCI.Job(
    JobNames.E2eTest,
    new CircleCI.executors.MachineExecutor("large", "ubuntu-2004:202111-02"),
    [
      new CircleCI.commands.Checkout(),
      new CircleCI.commands.Run({ command: "npm ci" }),
      new CircleCI.commands.Run({
        name: "Running e2e tests",
        command: "npm run wp-env start && npm run test:e2e",
      }),
      new CircleCI.commands.StoreArtifacts({ path: "artifacts" }),
    ]
  ),
  new CircleCI.Job(
    JobNames.Vsix,
    new CircleCI.executors.DockerExecutor("cimg/node:16.8.0-browsers", "large"),
    [
      new CircleCI.commands.Checkout(),
      new CircleCI.commands.Run({ command: "npm ci && npm run vsix" }),
      new CircleCI.commands.Run({
        command: `mkdir /tmp/artifacts
          mv ${
            JSON.parse(
              fs.existsSync("../../package.json")
                ? fs.readFileSync("../../package.json")?.toString()
                : "{}"
            ).name
          }*.vsix /tmp/artifacts`,
      }),
      new CircleCI.commands.StoreArtifacts({ path: "/tmp/artifacts" }),
    ]
  ),
  new CircleCI.Job(
    JobNames.Zip,
    new CircleCI.executors.DockerExecutor("cimg/php:8.0"),
    [
      new CircleCI.commands.Checkout(),
      new CircleCI.commands.Run({ command: "composer zip" }),
      new CircleCI.commands.StoreArtifacts({
        path: `${path.basename(
          glob.sync("../../*.php")?.[0] ?? "",
          ".php"
        )}.zip`,
      }),
    ]
  ),
];

/** Not needed for the public API, simply use createConfig(). */
export function getJobs(...jobs: (string | CircleCI.Job)[]): CircleCI.Job[] {
  return jobs.map((job) => {
    return typeof job === "string"
      ? preCreatedJobs.find((preCreatedJob) => {
          return job === preCreatedJob.name;
        })
      : job;
  });
}

/** Creates and writes the config, given the passed jobs. */
export function createConfig(...jobs: (string | CircleCI.Job)[]) {
  const config = new CircleCI.Config();
  const workflow = new CircleCI.Workflow("test-lint");
  config.addWorkflow(workflow);

  getJobs(...jobs).forEach((job) => {
    if (job) {
      config.addJob(job);
      workflow.addJob(job);
    }
  });

  fs.writeFile("./dynamicConfig.yml", config.stringify(), () => {});
}
Code language: TypeScript (typescript)

createConfig() on line 114 is the main function other repos will call.

That even writes the config file on line 126.

Next, create a package.json.

Most of the lines don’t matter, but you’ll need:

  "main": "index.ts",
  "type": "module",Code language: JSON / JSON with Comments (json)

…and:

  "dependencies": {
    "@circleci/circleci-config-sdk": "^0.11.0",
  }Code language: JSON / JSON with Comments (json)

You’ll also need:

tsconfig.json

{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ESNext",
    "moduleResolution": "Node",
    "noEmit": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
  }
}
Code language: JSON / JSON with Comments (json)

"allowSyntheticDefaultImports" is important, it’ll let you import CircleCI with:

import CircleCI from "@circleci/circleci-config-sdk";Code language: TypeScript (typescript)

2. Consuming Repo

This will import the common config above, and create a config from it.

You might have dozens of consuming repos.

.circleci/dynamic/index.ts

import { createConfig, JobNames } from "@getlocalci/create-config";

createConfig(JobNames.PhpLint, JobNames.PhpTest, JobNames.Zip);
Code language: TypeScript (typescript)

This will run the index.ts file via npm start:

.circleci/config.yml

version: 2.1
orbs:
  continuation: circleci/continuation@0.3
  node: circleci/node@5.0
setup: true
jobs:
  generate-config:
    executor: node/default
    steps:
      - checkout
      - node/install-packages:
          app-dir: .circleci/dynamic
      - run:
          name: Generate config
          command: npm start
          working_directory: .circleci/dynamic
      - continuation/continue:
          configuration_path: .circleci/dynamic/dynamicConfig.yml
workflows:
  dynamic-workflow:
    jobs:
      - generate-config
Code language: YAML (yaml)

.circleci/dynamic/package.json

{
  "name": "adapter-gravity-add-on-dynamic-config",
  "version": "0.1.0",
  "description": "Dynamic config for AGA",
  "author": "Ryan Kienstra",
  "main": "index.ts",
  "type": "module",
  "scripts": {
    "lint": "prettier --check index.ts",
    "lint:fix": "prettier --write index.ts",
    "start": "ts-node --esm --skipIgnore index.ts"
  },
  "license": "GPL-2.0-or-later",
  "devDependencies": {
    "@getlocalci/create-config": "github:getlocalci/create-config",
    "@types/node": "^18.7.20",
    "prettier": "^2.7.1",
    "ts-node": "^10.9.1",
    "tslib": "^2.4.0",
    "typescript": "^4.8.3"
  }
}
Code language: JSON / JSON with Comments (json)

Lines 7 and 11 above are important.

The --skipIgnore flag on line 11 interprets @getlocalci/create-config as TypeScript, so we don’t have to compile it earlier.

.circleci/dynamic/tysconfig.json

{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ESNext",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "noEmit": true
  }
}
Code language: JSON / JSON with Comments (json)

Finally, to enable the Config SDK, go to your repo’s CircleCI Advanced Settings:

https://app.circleci.com/settings/project/github/<GitHub org>/<GitHub repo>/advanced

For this example, the URL is:

https://app.circleci.com/settings/project/github/kienstra/adapter-gravity-add-on/advanced

Then, select ‘Enable dynamic config using setup workflows’:

TypeScript CircleCI Config enable setup workflows

Here’s the result:

TypeScript CircleCI configs jobs

Live TypeScript CircleCI Configs

These are all importing the common config:

Updating Consuming Repos

Let’s say in the common repo, you bump a container from "cimg/node:16.8.0" to "cimg/node:18.10.0".

And you want consuming repos to get that change.

In the common repo, merge the change into the default branch.

You don’t even have to compile the TypeScript, or do npm publish.

The consuming repo will interpret it as TypeScript.

In each consuming repo’s .circleci/dynamic, do:

$ npm update @getlocalci/create-configCode language: Bash (bash)

…but substitute the name of your common repo.

This Changes CircleCI

The TypeScript Config SDK is a huge feature.

Though of course, you can keep using the traditional config.yml if you prefer.

You can now keep your config in one repo, and mainly edit that repo.

This is like orbs, which can have commands and jobs.

But orbs can’t create the entire config for you.

The Config SDK can.

Here, you can reuse a config.

But you don’t get the typical headaches that come with reuse.

Like tagging and publishing an npm package.

If you like TypeScript…

This is now the way to write CircleCI configs.

Be the first to get CI/CD tips like this

You'll get these tips before they're on the blog page. See most up-to-date ways to use CircleCI®. No spam.

Reader interactions

One Reply to “TypeScript CircleCI Configs: Only 3 Lines”

  1. This makes it really easy to keep your configs up to date.

    If you need to bump an image version, you can do it in one place.

Comments are closed.