Skip to content

emmanuelnk/github-actions-workflow-ts

Repository files navigation

github-actions-workflow-ts

Stop writing workflows in YAML and use TypeScript instead!

github-actions-workflow-ts-logo

love opensource license npm version Tests coverage issues

Table of Contents

Installation

npm install --save-dev github-actions-workflow-ts

Or to use the zero dependency no-cli package (if you only want to generate a workflow JSON object and use it in something like projen):

npm install --save-dev github-actions-workflow-ts-lib

Overview

Introducing github-actions-workflow-ts: A seamless integration allowing developers to author GitHub Actions workflows with the power and flexibility of TypeScript.

Key Benefits:

  1. Type Safety: Elevate the confidence in your workflows with the robust type-checking capabilities of TypeScript.
  2. Modularity: Efficiently package and reuse common jobs and steps across various workflows, promoting the DRY (Don't Repeat Yourself) principle.
  3. Control Flow: Harness the inherent control flow mechanisms, like conditionals, available in imperative languages. This empowers developers to craft intricate workflows beyond the constraints of YAML.

Getting Started:

To embark on this efficient journey, create a new *.wac.ts file, for instance, deploy.wac.ts, in your project directory. Then, dive into authoring your enhanced GitHub Actions workflows!

Examples

Try it out on Replit

Want to quickly see it in action? Explore these Replit examples (create a free account to fork and modify my examples):

More Examples

Check the examples folder and the workflows folder for more advanced examples.

Below is a simple example:

// example.wac.ts

import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

// IMPORTANT - the instance of Workflow MUST be exported with `export`
export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
})

// add the defined step to the defined job
testJob.addStep(checkoutStep)

// add the defined job to the defined workflow
exampleWorkflow.addJob(testJob)

If you want to use the zero dependency package, you can do this:

import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts-lib'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
})

// the generated workflow object to be used in other parts of your project
// without conversion to YAML
console.log(exampleWorkflow.workflow)

Generating Workflow YAML

Using the CLI

When you have written your *.wac.ts file, you use the github-actions-workflow-ts CLI to generate the yaml files.

Don't forget to export the workflows that you want to generate in your *.wac.ts files i.e.

  // exporting `exampleWorkflow` will generate example-filename.yml
  export const exampleWorkflow = new Workflow('example-filename', { /***/ })

Then, from project root, run:

npx generate-workflow-files build

# OR

npx gwf build

Integration with Husky (recommended)

For seamless automation and to eliminate the possibility of overlooking updates in *.wac.ts files, integrating with a pre-commit tool is recommended. We recommend husky. With Husky, each commit triggers the npx github-actions-workflow-ts build command, ensuring that your GitHub Actions YAML files consistently reflect the latest modifications.

See more
  • Install Husky:
    npm install --save-dev husky
    npx husky-init
  • In package.json, add the following script:
      "scripts": {
        "build:workflows": "npx gwf build && git add .github/workflows/*.yml",
      }
  • Install the pre-commit command to Husky and add our npm command to build the *.wac.ts files
    npx husky add .husky/pre-commit "npm run build:workflows"
  • Now every time you make a change to *.wac.ts, Husky will run the npx gwf build command and add the generated .github/workflows/*.yml to your commit

Config file

If you want to change how github-actions-workflow-ts generates the yaml files, you can create a wac.config.json file in your project root. See the example config file

See config options
Property Description Type Default Value
refs If true, convert duplicate objects into references in YAML Boolean false
headerText Replace the header text in generated YAML files with your own text.
If you want the source filename and path in the text, use <source-file-path> in
the text and it will be replaced with the path to the source-file.
Array<string> # ----DO-NOT-MODIFY-THIS-FILE----
# This file was automatically generated by github-actions-workflow-ts.
# Instead, modify <source-file-path>
# ----DO-NOT-MODIFY-THIS-FILE----
dumpOptions Options for the dump function of js-yaml. See all the options here Record <string, any> Uses the default options

Workflow Classes

new Step()

The building block of every NormalJob. Contains instructions on what to run in your Github Actions Runner in each job.

Example
import { Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

.addEnvs()

This adds environment variables to a step.

Example
import { Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
}).addEnvs({
  SOME_KEY: 'some-value',
  SOME_OTHER_KEY: 'some-other-value'
})

new NormalJob()

The most typical job that contains steps.

.addEnvs()

This adds environment variables to a job.

Example
import { NormalJob } from 'github-actions-workflow-ts'

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
}).addEnvs({
  SOME_KEY: 'some-value',
  SOME_OTHER_KEY: 'some-other-value'
})

.addStep()

This adds a single step to a normal Job

Example
import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

testJob.addStep(checkoutStep)

.addSteps()

This adds multiple steps to a normal Job

Example
import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const installNodeStep = new Step({
  name: 'Install Node',
  uses: 'actions/setup-node@v3',
  with: {
    'node-version': 18
  }
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

testJob.addSteps([
  checkoutStep,
  installNodeStep
])

.needs()

This adds any jobs that the current job depends on to the current job's needs property

Example
import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

const buildJob = new NormalJob('Build', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})


testJob.addStep(checkoutStep)
buildJob
  .needs([testJob])
  .addStep(checkoutStep)

export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
})

exampleWorkflow.addJobs([
  testJob,
  buildJob
])  

new ReusableWorkflowCallJob()

A job that allows you to call another workflow and use it in the same run.

Example
import { Workflow, ReusableWorkflowCallJob } from 'github-actions-workflow-ts'

const releaseJob = new ReusableWorkflowCallJob('ReleaseJob', {
	uses: 'your-org/your-repo/.github/workflows/reusable-workflow.yml@main',
	secrets: 'inherit',
})

export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
}).addJob(releaseJob)  

.needs()

Same as NormalJob.needs()

new Workflow()

.addEnvs()

This adds environment variables to a workflow.

Example
import { Workflow } from 'github-actions-workflow-ts'

export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
}).addEnvs({
  SOME_KEY: 'some-value',
  SOME_OTHER_KEY: 'some-other-value'
})

.addJob()

This adds a single job to a Workflow

Example
import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

testJob.addStep([checkoutStep])

export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
})

exampleWorkflow.addJob(testJob)  

.addJobs()

This adds multiple jobs to a Workflow

Example
import { Workflow, NormalJob, Step } from 'github-actions-workflow-ts'

const checkoutStep = new Step({
  name: 'Checkout',
  uses: 'actions/checkout@v3',
})

const testJob = new NormalJob('Test', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})

const buildJob = new NormalJob('Build', {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 2
})


testJob.addStep(checkoutStep)
buildJob.addStep(checkoutStep)

export const exampleWorkflow = new Workflow('example-filename', {
  name: 'Example',
  on: {
    workflow_dispatch: {}
  }
})

exampleWorkflow.addJobs([
  testJob,
  buildJob
])  

Workflow Types

You can also choose not to use the workflow helpers and just use plain old JSON. You get type safety by importing the types. The only exception is the Workflow class. You must export an instance of this class in order to generate your workflow files.

GeneratedWorkflowTypes

These are types generated right out of the Github Actions Workflow JSON Schema

ExtendedWorkflowTypes

These are types that I extended myself because they weren't autogenerated from the JSON Schema.

Example
import {
  Workflow,
  NormalJob,
  Step,
  expressions as ex,
  GeneratedWorkflowTypes as GWT, // all types generated from the official Github Actions Workflow JSON Schema
} from '../src'

const nodeSetupStep: GWT.Step = {
  name: 'Setup Node',
  uses: 'actions/setup-node@v3',
  with: {
    'node-version': '18.x',
  },
}

const firstNormalJob: GWT.NormalJob = {
  'runs-on': 'ubuntu-latest',
  'timeout-minutes': 5,
  steps: [
    nodeSetupStep,
    {
      name: 'Echo',
      run: 'echo "Hello, World!"',
    },
  ],
}

export const simpleWorkflowOne = new Workflow('simple-1', {
  name: 'ExampleSimpleWorkflow',
  on: {
    workflow_dispatch: {},
  },
  jobs: {
    firstJob: firstNormalJob,
  },
})

Helpers

multilineString()

This is a useful function that aids in writing multiline yaml like this:

  name: Run something
  run: |-
    command exec line 1
    command exec line 2
Examples

Example 1

import { multilineString } from 'github-actions-workflow-ts'

// multilineString(...strings) joins all strings with a newline 
// character '\n' which is interpreted as separate lines in YAML
console.log(multilineString('This is sentence 1', 'This is sentence 2'))
// 'This is sentence 1\nThis is sentence 2'

// it also has the ability to escape special characters
console.log(
  multilineString(
    `content="\${content//$'\n'/'%0A'}"`,
    `content="\${content//$'\r'/'%0D'}"`
  )
)
// `content="${content//$'\n'/'%0A'}"`
// `content="${content//$'\r'/'%0D'}"``

Example 2 - handling multiline string indentation If you want to do something like this

      - name: Check for build directory
        run: |-
          #!/bin/bash
          ls /tmp
          if [ ! -d "/tmp/build" ]; then
            mv /tmp/build .
            ls
          fi

then you just add the same indentation in the string:

  // If you want indentation then you can do this:
  new Step({
    name: 'Check for build directory',
    run: multilineString(
      `#!/bin/bash`,
      `ls /tmp`,
      `if [ ! -d "/tmp/build" ]; then`,
      `  mv /tmp/build .`, // notice the two spaces before 'mv ..'
      `  ls`,              // notice the two spaces before 'ls ..'
      `fi`,
    ),
  });

expressions

.expn()

Returns the expression string ${{ <expression> }}

Example
import { expressions } from 'github-actions-workflow-ts'

console.log(expressions.expn('hashFiles("**/pnpm-lock.yaml")'))
// '${{ hashFiles("**/pnpm-lock.yaml") }}'

.env()

Returns the expression string ${{ env.SOMETHING }}

Example
import { expressions } from 'github-actions-workflow-ts'

console.log(expressions.env('GITHUB_SHA'))
// '${{ env.GITHUB_SHA }}'

.secret()

Returns the expression string ${{ secrets.SOMETHING }}

Example
import { expressions } from 'github-actions-workflow-ts'

console.log(expressions.secret('GITHUB_TOKEN'))
// '${{ secrets.GITHUB_TOKEN }}'

.var()

Returns the expression string ${{ vars.SOMETHING }}

Example
import { expressions } from 'github-actions-workflow-ts'

console.log(expressions.var('SENTRY_APP_ID'))
// '${{ vars.SENTRY_APP_ID }}'

.ternary()

Example
import { expressions } from 'github-actions-workflow-ts'

// ternary(condition, ifTrue, ifFalse)
console.log(expressions.ternary("github.event_name == 'release'", 'prod', 'dev'))
// '${{ github.event_name == 'release' && 'prod' || 'dev' }}'

echoKeyValue

.to()

Returns the string echo "key=value" >> <SOMETHING>

Example
import { echoKeyValue } from 'github-actions-workflow-ts'

// echoKeyValue.to(key, value, to) returns 'echo "key=value" >> <SOMETHING>'
echoKeyValue.to('@your-org:registry', 'https://npm.pkg.github.com', '.npmrc')
// 'echo "@your-org:registry=https://npm.pkg.github.com" >> .npmrc'

.toGithubEnv()

Returns the string echo "key=value" >> $GITHUB_ENV

Example
import { echoKeyValue } from 'github-actions-workflow-ts'

// echoKeyValue.toGithubEnv(key, value, to) returns 'echo "key=value" >> $GITHUB_ENV'
echoKeyValue.toGithubEnv('NODE_VERSION', '18')
// 'echo "NODE_VERSION=18" >> $GITHUB_ENV'

.toGithubOutput()

Returns the string echo "key=value" >> $GITHUB_OUTPUT

Example
import { echoKeyValue } from 'github-actions-workflow-ts'

// echoKeyValue.toGithubOutput(key, value, to) returns 'echo "key=value" >> $GITHUB_OUTPUT'
echoKeyValue.toGithubOutput('NODE_VERSION', '18')
// 'echo "NODE_VERSION=18" >> $GITHUB_OUTPUT'

Contributing

See the Contributing Guide

Credits

Inspired by webiny/github-actions-wac which is also the original source of the filename extension (.wac.ts) used to distinguish the Github Actions YAML workflow TypeScript files. When I hit too many limitations with github-actions-wac, I decided to create github-actions-workflow-ts to address those limitations and add a lot more functionality.