Automating Software Development Workflows with Github Actions 🚀 | by Ansh Sachdeva

credits :
  • GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows us to automate your build, test, and deployment pipeline.
  • We can create workflows that build and test every pull request to our repository or deploy merged pull requests to production. There are many use-cases and events which we can combine to get the outputs we desire.

In a nutshell, we define a set of commands in a file and push it inside our code. When this file is identified by Github, it will run those commands in a cloud device hosting our code.

  1. workflow: A workflow is a .yml (learn about its syntax here.) file that we add in the .github/workflows folder, and check out in our project repo. It consists of a set of jobs that will run on our code when triggered by an event. These jobs can also be triggered manually. We can have multiple workflows in our project
  2. Event: An event is a specific activity in a repository that triggers a workflow run. For eg, a trigger can originate from GitHub when someone creates a pull request, opens an issue, or pushes a commit to a repository. You can also trigger a workflow run on a schedule, by posting to a REST API, or manually. check this list for all events:

3. Jobs: Jobs are a set of steps that run sequentially, in the same runner. a workflow defines 1 or more Jobs and in a jobwe define 1 or more steps. Each Job runs in its own container and therefore the steps in each job are executed in parallel unless configured otherwise

4. Step: A step is the smallest unit of command in a Github workflow. Each step is either a shell script that will be executed or an action that will be run. Steps are executed in order and are dependent on each other. Since each step is executed on the same runner, we can share data from one step to another

Example of a Github workflow. We define Job1, Job2 and the steps inside in a workflow. it gets triggered by an event and Jobs run in parallel
  • As previously mentioned, the Jobs run in parallel containers while the steps run sequentially inside the same container. Therefore we can mix and match whether to run a particular task in a separate job (to allow it to run in parallel) or in the same job (to allow it to run in sequential order, alongside other steps)
  • The failure of a step in a job will result in failure of the whole job by default(unless configured otherwise) but it won’t affect the other jobs running in parallel

Considering the following example :

  1. name: Denotes the name of the GitHub action that will show up in the action run history
  2. on : This section denotes the events that will trigger the jobs defined in the job section. There can be many sub configurations in this block. For eg, if we want the workflow to get triggered by a pull requestwe can mention the names of branches against which the PR should be made to trigger. another sub-config available for PR is type: which can take the values ​​of closed, opened, edited, ..etc. The complete list of events can be found here
  3. jobs: This section consists of various jobs that will be executed in parallel once the event(s) mentioned in on section occur. their name can be anything:

3.1 Setting up a job.

A job could consist of many attributes for a specific configuration. An example of some of the common attributes would look like these:

contents: read
packages: write
runs-on: [self-hosted, ubuntu-latest]
if: ${{ <expression> }}
needs: my_first_job
  1. permissions: These are the container-level permissions that we are providing to our job. There can be multiple types of permissions.
  2. runs-on: here we are defining the machine that will be used to execute the job
  3. if: ${{}}}: These are conditional expressions that are executed in a declarative manner. the conditions can be applied to various levels in a workflow. it can be used to skip or perform the whole job or just a step. But what do these conditions work upon? the ${{}} can be used to evaluate any condition like ${{1+2==3}} . There are also certain properties that get generated for every step or job like ${{job.status == 'success'}}. These are called context.
  4. needs: This is an attribute that makes different parallel running jobs run sequentially. ideally, job2 in the first example should be running in parallel to my_first_job. by using needswe are making sure that job2 will run after my_first_job
  5. steps: A job contains a sequence of tasks called steps. Steps can run commands, run set up tasks, or run actions in your repository, a public repository, or action published at a Docker registry. Steps can take inputs (in the form of constants) and generate output. The steps in a job are executed sequentially. By default, the steps are configured to fail execution if the previous step has failed or given a failure output, but this behavior could be modified via conditional expressions.

3.2 Structure of a step

- name: Setup Path Filter task and Execute
uses: dorny/paths-filter@v2
id: filter
filters: |
md: ['']
- name: FAIL if mandatory files are not changed
if: ${{ == 'false' }}
uses: actions/github-script@v3
script: |
core.setFailed('Mandatory markdown files were not changed')
- name: Generate AAR and APK files.
shell: bash
run: ./gradlew assembleDebug
  1. name: This is an optional key that gives a small description of the step. If the name key is present, then it will show up in the workflow terminal during execution
  2. id: This is another optional key that uniquely identifies the step. we can get details about the step(its inputs, outputs, execution time, etc) using this key. here the 2nd step “FAIL if mandatory files are not changed” uses the input of its previous step to determine whether to run or fail the action via steps.filter.* syntax
  3. uses and run: These are 2 very important features of a step. a step must define either one of these.
  4. when using uses, we are letting the runner know that we want to execute a bunch of commands that are written in some action from the marketplace. It’s like using a library. for eg dorny/paths-filter is a popular activity for checking whether some file has changed or not
  5. When using run, we are letting the runner know that we want to execute a particular command on the host machine. This is like using a terminal in our own machine: whether the command can execute successfully or not depends upon the already present software on the system and whether it would support its execution. For eg : ./gradlew assembleDebug is a terminal command that we want to run, and it will only execute successfully if the host machine has gradle installed by default, or as a part of the previous sequential steps
  6. shell: This command is used in association with run command to let the host machine know about the terminal we want to use
  7. with: This attribute is used with steps that use 3rd party actions (via uses )only and provides inputs to them. you can pass numbers, strings, or even js scripts via arguments in the with block. The keys are defined by those actions themselves
  8. env: This attribute is similar to withbut is used to define inputs for the step itself, and can be used with both uses and run

Since we can have multiple workflows in our repo, there are chances that we will be using the same steps in each of them and this will cause duplication. And since duplication is bad and difficult to maintain, we might want a solution that allows us to isolate steps from the workflows in a centralized place and reuse them whenever required.

Here’s when Workflow Composition becomes helpful. Using workflow composition we can create separate files for each step and use them via uses syntax in any workflow. the steps will be independent of the job or system environment and therefore will be able to run in any workflow with a very small amount of integration.

Here’s an example of a workflow that DOES NOT use composition:

if we have to create 10 more workflows that use Check lint and Upload Lint results, it would require us to copy all those long Gradle commands and path names manually which are error-prone. It would also be difficult to maintain 10 such copies for future modifications.

With composition, this file becomes as small as this:

Only 2 lines are required to run both lint checks and upload results! Now we can create 10 or even 100 copies, we don’t need to worry about making a mistake in path files or Gradle commands! this is because these details are present in a separate YML file .github/mini_flows/s3_lint/action.yml with somewhat similar syntax:

Leave a Comment